From 6f23a57220815b2e0843279023a20407898726b5 Mon Sep 17 00:00:00 2001 From: Matthew Grotke Date: Mon, 1 Jun 2026 10:14:36 -0400 Subject: [PATCH] Development --- docker/routlin-dash/app/main.py | 4 +- docker/routlin-dash/app/navbar.json | 3 +- .../app/pages/dhcpleases/__init__.py | 0 .../app/pages/dhcpleases/content.json | 58 +++++ .../app/pages/dhcpreservations/__init__.py | 0 .../app/pages/dhcpreservations/action.py | 237 ++++++++++++++++++ .../app/pages/dhcpreservations/content.json | 206 +++++++++++++++ 7 files changed, 505 insertions(+), 3 deletions(-) create mode 100644 docker/routlin-dash/app/pages/dhcpleases/__init__.py create mode 100644 docker/routlin-dash/app/pages/dhcpleases/content.json create mode 100644 docker/routlin-dash/app/pages/dhcpreservations/__init__.py create mode 100644 docker/routlin-dash/app/pages/dhcpreservations/action.py create mode 100644 docker/routlin-dash/app/pages/dhcpreservations/content.json diff --git a/docker/routlin-dash/app/main.py b/docker/routlin-dash/app/main.py index 008a43d..496c4d2 100644 --- a/docker/routlin-dash/app/main.py +++ b/docker/routlin-dash/app/main.py @@ -5,7 +5,7 @@ from view_page import bp as view_page_bp from pages.actions.action import bp as actions_bp from pages.bannedips.action import bp as bannedips_bp from pages.ddns.action import bp as ddns_bp -from pages.dhcp.action import bp as dhcp_bp +from pages.dhcpreservations.action import bp as dhcpreservations_bp from pages.dnsblocking.action import bp as dnsblocking_bp from pages.dnsserver.action import bp as dnsserver_bp from pages.hostoverrides.action import bp as hostoverrides_bp @@ -31,7 +31,7 @@ app.register_blueprint(view_page_bp) app.register_blueprint(actions_bp) app.register_blueprint(bannedips_bp) app.register_blueprint(ddns_bp) -app.register_blueprint(dhcp_bp) +app.register_blueprint(dhcpreservations_bp) app.register_blueprint(dnsblocking_bp) app.register_blueprint(dnsserver_bp) app.register_blueprint(hostoverrides_bp) diff --git a/docker/routlin-dash/app/navbar.json b/docker/routlin-dash/app/navbar.json index 65d5185..53f19fb 100644 --- a/docker/routlin-dash/app/navbar.json +++ b/docker/routlin-dash/app/navbar.json @@ -18,7 +18,8 @@ { "type": "nav_item", "label": "Inter-VLAN Exceptions", "map_to": "intervlan", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Port Forwarding", "map_to": "portforwarding", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "Port Wrangling", "map_to": "portwrangling", "client_requirement": "client_is_administrator+" }, - { "type": "nav_item", "label": "DHCP", "map_to": "dhcp" }, + { "type": "nav_item", "label": "DHCP Leases", "map_to": "dhcpleases" }, + { "type": "nav_item", "label": "DHCP Reservations", "map_to": "dhcpreservations" }, { "type": "nav_item", "label": "DDNS", "map_to": "ddns" }, { "type": "nav_item", "label": "Host Overrides", "map_to": "hostoverrides", "client_requirement": "client_is_administrator+" }, { "type": "nav_item", "label": "VPN", "map_to": "vpn" }, diff --git a/docker/routlin-dash/app/pages/dhcpleases/__init__.py b/docker/routlin-dash/app/pages/dhcpleases/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/pages/dhcpleases/content.json b/docker/routlin-dash/app/pages/dhcpleases/content.json new file mode 100644 index 0000000..8b19e4d --- /dev/null +++ b/docker/routlin-dash/app/pages/dhcpleases/content.json @@ -0,0 +1,58 @@ +{ + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "DHCP Leases" + }, + { + "type": "p", + "text": "Active leases handed out by dnsmasq across all VLANs." + } + ] + }, + { + "type": "table", + "datasource": "live:dhcp_leases", + "empty_message": "No active DHCP leases found.", + "toolbar": { + "items": [ + { + "type": "select", + "name": "vlan_filter", + "value": "all", + "filter_col": "vlan_name", + "options": "%VLAN_FILTER_OPTIONS%" + } + ] + }, + "columns": [ + { + "label": "Hostname", + "field": "hostname" + }, + { + "label": "IP Address", + "field": "ip_address", + "class": "col-mono" + }, + { + "label": "MAC Address", + "field": "mac_address", + "class": "col-mono" + }, + { + "label": "VLAN", + "field": "vlan_name" + }, + { + "label": "Expires", + "field": "expires" + } + ] + } + ] +} diff --git a/docker/routlin-dash/app/pages/dhcpreservations/__init__.py b/docker/routlin-dash/app/pages/dhcpreservations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/docker/routlin-dash/app/pages/dhcpreservations/action.py b/docker/routlin-dash/app/pages/dhcpreservations/action.py new file mode 100644 index 0000000..3c0a71c --- /dev/null +++ b/docker/routlin-dash/app/pages/dhcpreservations/action.py @@ -0,0 +1,237 @@ +from pathlib import Path +import copy +import ipaddress + +from flask import Blueprint, request, redirect, flash +from auth import require_level +from config_utils import load_config, record_group, diff_fields, verify_config_hash +import sanitize +import validation as validate + +_PAGE = Path(__file__).parent.name + +bp = Blueprint(_PAGE, __name__) + +def _row_index(): + try: + return int(request.form.get('row_index', '')) + except (ValueError, TypeError): + return None + + +def _hash_ok(): + if not verify_config_hash(request.form.get('config_hash', '')): + flash('Configuration was modified by another session. Please refresh and try again.', 'error') + return False + return True + + +def _parse_ip(): + raw = request.form.get('ip', '').strip() + if not raw: + return 'dynamic' + ip = validate.ip(raw) + if not ip: + flash(f'The configuration has not been saved because "{raw}" is not a valid IP address.', 'error') + return None + return ip + + +def _check_ip_in_vlan_subnet(ip, vlan): + if not ip or ip == 'dynamic': + return None + subnet = vlan.get('subnet') + prefix = vlan.get('subnet_mask') + if not subnet or prefix is None: + return None + try: + network = ipaddress.IPv4Network(f'{subnet}/{prefix}', strict=False) + addr = ipaddress.IPv4Address(ip) + if addr == network.network_address: + return f'{ip} is the network address and cannot be assigned.' + if addr == network.broadcast_address: + return f'{ip} is the broadcast address and cannot be assigned.' + if addr not in network: + return f'{ip} is not within the {vlan["name"]} subnet ({subnet}/{prefix}).' + except ValueError: + return f'{ip} is not a valid IP address.' + return None + + +@bp.route('/action/dhcpreservations/addreservation_add', methods=['POST']) +@require_level('administrator') +def addreservation_add(): + vlan_name = sanitize.name(request.form.get('vlan_name', '')) + description = sanitize.text(request.form.get('description', '')) + hostname = validate.domainname(request.form.get('hostname', '')) + mac = sanitize.mac(request.form.get('mac', '')) + ip = _parse_ip() + radius_client = 'radius_client' in request.form + + if ip is None: + return redirect(f'/{_PAGE}') + if not vlan_name: + flash('The configuration has not been saved because a VLAN is required.', 'error') + return redirect(f'/{_PAGE}') + if not mac: + flash('The configuration has not been saved because a MAC address is required.', 'error') + return redirect(f'/{_PAGE}') + if not _hash_ok(): + return redirect(f'/{_PAGE}') + + cfg = load_config() + vlans = cfg.get('vlans', []) + vlan = next((v for v in vlans if v.get('name') == vlan_name), None) + if vlan is None: + flash(f'The configuration has not been saved because VLAN "{vlan_name}" was not found.', 'error') + return redirect(f'/{_PAGE}') + + subnet_err = _check_ip_in_vlan_subnet(ip, vlan) + if subnet_err: + flash(f'The configuration has not been saved because {subnet_err}', 'error') + return redirect(f'/{_PAGE}') + + conflict = validate.check_reservation_ip_conflicts(ip, vlan) + if conflict: + flash(f'The configuration has not been saved because {conflict}', 'error') + return redirect(f'/{_PAGE}') + + entry = { + 'description': description, + 'hostname': hostname, + 'mac': mac, + 'ip': ip, + 'radius_client': radius_client, + 'enabled': True, + 'vlan': vlan_name, + } + cfg.setdefault('dhcp_reservations', []).append(entry) + errors = validate.validate_config(cfg) + if errors: + for msg in errors: + flash(msg, 'error') + return redirect(f'/{_PAGE}') + + changes = diff_fields(None, entry) + flash(record_group(cfg, 'dhcp_reservations', 'mac', mac, changes, 'core apply'), 'success') + return redirect(f'/{_PAGE}') + + +@bp.route('/action/dhcpreservations/reservations_toggle', methods=['POST']) +@require_level('administrator') +def reservations_toggle(): + idx = _row_index() + if idx is None: + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') + if not _hash_ok(): + return redirect(f'/{_PAGE}') + + cfg = load_config() + items = cfg.get('dhcp_reservations', []) + if idx < 0 or idx >= len(items): + flash('Entry not found.', 'error') + return redirect(f'/{_PAGE}') + + res = items[idx] + old_enabled = res.get('enabled', True) + before = copy.deepcopy(res) + res['enabled'] = not old_enabled + errors = validate.validate_config(cfg) + if errors: + for msg in errors: + flash(msg, 'error') + return redirect(f'/{_PAGE}') + + changes = diff_fields(before, res) + flash(record_group(cfg, 'dhcp_reservations', 'mac', res['mac'], changes, 'core apply'), 'success') + return redirect(f'/{_PAGE}') + + +@bp.route('/action/dhcpreservations/reservations_edit', methods=['POST']) +@require_level('administrator') +def reservations_edit(): + idx = _row_index() + if idx is None: + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') + + description = sanitize.text(request.form.get('description', '')) + hostname = validate.domainname(request.form.get('hostname', '')) + mac = sanitize.mac(request.form.get('mac', '')) + ip = _parse_ip() + radius_client = 'radius_client' in request.form + + if ip is None: + return redirect(f'/{_PAGE}') + if not mac: + flash('The configuration has not been saved because a MAC address is required.', 'error') + return redirect(f'/{_PAGE}') + if not _hash_ok(): + return redirect(f'/{_PAGE}') + + cfg = load_config() + items = cfg.get('dhcp_reservations', []) + if idx < 0 or idx >= len(items): + flash('Entry not found.', 'error') + return redirect(f'/{_PAGE}') + + res = items[idx] + vlan_name = res.get('vlan', '') + vlan = next((v for v in cfg.get('vlans', []) if v.get('name') == vlan_name), None) + if vlan: + subnet_err = _check_ip_in_vlan_subnet(ip, vlan) + if subnet_err: + flash(f'The configuration has not been saved because {subnet_err}', 'error') + return redirect(f'/{_PAGE}') + conflict = validate.check_reservation_ip_conflicts(ip, vlan) + if conflict: + flash(f'The configuration has not been saved because {conflict}', 'error') + return redirect(f'/{_PAGE}') + + before = copy.deepcopy(res) + res.update({ + 'description': description, + 'hostname': hostname, + 'mac': mac, + 'ip': ip, + 'radius_client': radius_client, + 'enabled': 'enabled' in request.form, + }) + errors = validate.validate_config(cfg) + if errors: + for msg in errors: + flash(msg, 'error') + return redirect(f'/{_PAGE}') + + changes = diff_fields(before, res) + flash(record_group(cfg, 'dhcp_reservations', 'mac', mac, changes, 'core apply'), 'success') + return redirect(f'/{_PAGE}') + + +@bp.route('/action/dhcpreservations/reservations_delete', methods=['POST']) +@require_level('administrator') +def reservations_delete(): + idx = _row_index() + if idx is None: + flash('Invalid request.', 'error') + return redirect(f'/{_PAGE}') + if not _hash_ok(): + return redirect(f'/{_PAGE}') + + cfg = load_config() + items = cfg.get('dhcp_reservations', []) + if idx < 0 or idx >= len(items): + flash('Entry not found.', 'error') + return redirect(f'/{_PAGE}') + + removed = items.pop(idx) + errors = validate.validate_config(cfg) + if errors: + for msg in errors: + flash(msg, 'error') + return redirect(f'/{_PAGE}') + + changes = diff_fields(removed, None) + flash(record_group(cfg, 'dhcp_reservations', 'mac', removed['mac'], changes, 'core apply'), 'success') + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/dhcpreservations/content.json b/docker/routlin-dash/app/pages/dhcpreservations/content.json new file mode 100644 index 0000000..9e5ee29 --- /dev/null +++ b/docker/routlin-dash/app/pages/dhcpreservations/content.json @@ -0,0 +1,206 @@ +{ + "client_requirement": "client_is_viewer+", + "items": [ + { + "type": "header_page_title", + "items": [ + { + "type": "h1", + "text": "DHCP Reservations" + }, + { + "type": "p", + "text": "IP reservations and VLAN authorizations." + } + ] + }, + { + "type": "table", + "datasource": "config:dhcp_reservations", + "empty_message": "No DHCP reservations configured.", + "toolbar": { + "items": [ + { + "type": "select", + "name": "vlan_filter", + "value": "all", + "options": "%VLAN_FILTER_OPTIONS%", + "filter_col": "vlan_name" + } + ] + }, + "columns": [ + { + "label": "Description", + "field": "description" + }, + { + "label": "Hostname", + "field": "hostname", + "class": "col-mono" + }, + { + "label": "MAC", + "field": "mac", + "class": "col-mono" + }, + { + "label": "IP", + "field": "ip", + "class": "col-mono" + }, + { + "label": "VLAN", + "field": "vlan_name" + }, + { + "label": "RADIUS", + "field": "radius_client", + "render": "badge_yes_no" + }, + { + "label": "Status", + "field": "enabled", + "render": "badge_enabled_disabled" + } + ], + "row_actions": [ + { + "client_requirement": "client_is_administrator+", + "action": "/action/dhcpreservations/reservations_edit", + "method": "inline_edit", + "text": "Edit", + "class": "btn-ghost btn-sm", + "fields": [ + { + "col": "description", + "input_type": "text" + }, + { + "col": "hostname", + "input_type": "text", + "validate": "VALIDATION_NETWORK_NAME" + }, + { + "col": "mac", + "input_type": "text", + "validate": "VALIDATION_MAC" + }, + { + "col": "ip", + "input_type": "text", + "validate": "VALIDATION_IPV4_FORMAT|VALIDATION_ADDRESS", + "attrs": { + "data-vlan-subnets": "%VLAN_SUBNET_INFO_JSON%" + } + }, + { + "col": "radius_client", + "input_type": "checkbox", + "checkbox_label": "Enabled" + }, + { + "col": "enabled", + "input_type": "checkbox", + "checkbox_label": "Enabled" + } + ] + }, + { + "client_requirement": "client_is_administrator+", + "action": "/action/dhcpreservations/reservations_delete", + "method": "post", + "text": "Delete", + "class": "btn-danger btn-sm" + } + ] + }, + { + "type": "card", + "id": "add-form", + "label": "Add Reservation/Authorization", + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/dhcpreservations/addreservation_add", + "method": "post", + "items": [ + { + "type": "field", + "label": "VLAN", + "name": "vlan_name", + "input_type": "select", + "options": "%VLAN_NAMES_AS_OPTIONS%", + "hint": "VLAN this reservation belongs to." + }, + { + "type": "field", + "label": "Description", + "name": "description", + "input_type": "text", + "placeholder": "e.g. NAS" + }, + { + "type": "field", + "label": "Hostname", + "name": "hostname", + "input_type": "text", + "validate": "VALIDATION_NETWORK_NAME", + "optional": true, + "placeholder": "e.g. nas", + "attrs": { + "data-res-hosts-by-vlan": "%RESERVATION_HOSTNAMES_BY_VLAN_JSON%" + } + }, + { + "type": "field", + "label": "MAC Address", + "name": "mac", + "input_type": "text", + "validate": "VALIDATION_MAC", + "placeholder": "e.g. aa:bb:cc:dd:ee:ff" + }, + { + "type": "field", + "label": "IP Address", + "name": "ip", + "input_type": "text", + "validate": "VALIDATION_IPV4_FORMAT|VALIDATION_ADDRESS", + "optional": true, + "placeholder": "e.g. 192.168.10.50", + "hint": "Leave blank to authorize device on this VLAN dynamically.", + "attrs": { + "data-res-ips-by-vlan": "%RESERVATION_IPS_BY_VLAN_JSON%", + "data-vlan-subnets": "%VLAN_SUBNET_INFO_JSON%", + "data-vlan-select": "vlan_name" + } + }, + { + "type": "field", + "label": "RADIUS Client", + "name": "radius_client", + "input_type": "checkbox", + "hint": "This device acts as a RADIUS authenticator, verifying credentials of other devices on the network." + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "action": "/action/dhcpreservations/addreservation_add", + "method": "post", + "text": "Add" + }, + { + "type": "button_cancel", + "text": "Cancel" + } + ] + } + ] + } + ] + } + ] +}