diff --git a/docker/router-dash/Dockerfile b/docker/router-dash/Dockerfile index 0833fde..27bb3f8 100644 --- a/docker/router-dash/Dockerfile +++ b/docker/router-dash/Dockerfile @@ -2,6 +2,7 @@ FROM python:3.12-slim WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt +ARG CACHE_BUST COPY app/*.py . EXPOSE 25327 CMD ["python", "main.py"] diff --git a/docker/router-dash/app/action_apply_banned_ips.py b/docker/router-dash/app/action_apply_banned_ips.py index 8881ca6..ece76b0 100644 --- a/docker/router-dash/app/action_apply_banned_ips.py +++ b/docker/router-dash/app/action_apply_banned_ips.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import sanitize import validate @@ -55,7 +55,7 @@ def add_banned_ip(): }) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -79,7 +79,7 @@ def toggle_banned_ip(): items[idx]['enabled'] = not items[idx].get('enabled', True) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -109,7 +109,7 @@ def edit_banned_ip(): items[idx].update({'description': description, 'ip': ip, 'enabled': enabled}) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -133,5 +133,5 @@ def delete_banned_ip(): removed = items.pop(idx) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) diff --git a/docker/router-dash/app/action_apply_blocklists.py b/docker/router-dash/app/action_apply_blocklists.py index a5a6c85..e4ec91d 100644 --- a/docker/router-dash/app/action_apply_blocklists.py +++ b/docker/router-dash/app/action_apply_blocklists.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, run_update_blocklists, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import re import sanitize import validate @@ -78,7 +78,7 @@ def add_blocklist(): }) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -112,7 +112,7 @@ def edit_blocklist(): }) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -136,13 +136,12 @@ def delete_blocklist(): removed = items.pop(idx) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @bp.route('/action/update_blocklists', methods=['POST']) @require_level('administrator') def update_blocklists(): - run_update_blocklists() - flash('Blocklist refresh triggered.', 'success') + flash(queued_msg('core update-blocklists'), 'success') return redirect(VIEW) diff --git a/docker/router-dash/app/action_apply_dhcp_reservations.py b/docker/router-dash/app/action_apply_dhcp_reservations.py index eda1f20..6ca485f 100644 --- a/docker/router-dash/app/action_apply_dhcp_reservations.py +++ b/docker/router-dash/app/action_apply_dhcp_reservations.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import sanitize import validate @@ -86,7 +86,7 @@ def add_dhcp_reservation(): }) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -112,7 +112,7 @@ def toggle_dhcp_reservation(): res['enabled'] = not res.get('enabled', True) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -157,7 +157,7 @@ def edit_dhcp_reservation(): }) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -182,5 +182,5 @@ def delete_dhcp_reservation(): removed = vlans[vi]['reservations'].pop(ri) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) diff --git a/docker/router-dash/app/action_apply_general.py b/docker/router-dash/app/action_apply_general.py index 10b2bbc..81b95cf 100644 --- a/docker/router-dash/app/action_apply_general.py +++ b/docker/router-dash/app/action_apply_general.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import sanitize bp = Blueprint('action_apply_general', __name__) @@ -35,5 +35,5 @@ def apply_general(): }) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect('/view/view_general') diff --git a/docker/router-dash/app/action_apply_host_overrides.py b/docker/router-dash/app/action_apply_host_overrides.py index 3401e25..e260e4d 100644 --- a/docker/router-dash/app/action_apply_host_overrides.py +++ b/docker/router-dash/app/action_apply_host_overrides.py @@ -2,7 +2,7 @@ import ipaddress from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import sanitize bp = Blueprint('action_apply_host_overrides', __name__) @@ -74,7 +74,7 @@ def add_host_override(): }) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -98,7 +98,7 @@ def toggle_host_override(): items[idx]['enabled'] = not items[idx].get('enabled', True) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -135,7 +135,7 @@ def edit_host_override(): items[idx].update({'description': description, 'host': host, 'ip': ip, 'enabled': enabled}) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -159,5 +159,5 @@ def delete_host_override(): removed = items.pop(idx) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) diff --git a/docker/router-dash/app/action_apply_iface_config.py b/docker/router-dash/app/action_apply_iface_config.py new file mode 100644 index 0000000..0fbdae5 --- /dev/null +++ b/docker/router-dash/app/action_apply_iface_config.py @@ -0,0 +1,80 @@ +import os + +from flask import Blueprint, request, redirect, flash +from auth import require_level +from config_utils import verify_core_hash, queued_msg, queue_command +import sanitize + +bp = Blueprint('action_apply_iface_config', __name__) + +_VIEW = '/view/view_general' + +_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth', + 'tun', 'tap', 'ppp', 'virbr', + 'podman', 'vnet', 'macvtap', 'fc-') + +def _valid_interface(name): + try: + return name in { + n for n in os.listdir('/sys/class/net') + if not n.startswith(_EXCLUDE_PREFIXES) + and os.path.exists(f'/sys/class/net/{n}/device') + } + except Exception: + return False + + +@bp.route('/action/apply_iface_config', methods=['POST']) +@require_level('administrator') +def apply_iface_config(): + if not verify_core_hash(request.form.get('config_hash', '')): + flash('Configuration was modified by another session. Please refresh and try again.', 'error') + return redirect(_VIEW) + + iface = sanitize.interface_name(request.form.get('iface', '')) + mtu = request.form.get('mtu', '').strip() + mac = sanitize.mac(request.form.get('mac', '')) + original_mtu = request.form.get('original_mtu', '').strip() + original_mac = sanitize.mac(request.form.get('original_mac', '')) + + if not iface: + flash('No interface specified.', 'error') + return redirect(_VIEW) + + if not _valid_interface(iface): + flash(f"Interface '{iface}' does not exist on this system.", 'error') + return redirect(_VIEW) + + mtu_int = None + if mtu: + try: + mtu_int = int(mtu) + if not (68 <= mtu_int <= 9000): + raise ValueError + except ValueError: + flash('MTU must be an integer between 68 and 9000.', 'error') + return redirect(_VIEW) + + mac_raw = request.form.get('mac', '').strip() + if mac_raw and not mac: + flash('MAC address must be in the format aa:bb:cc:dd:ee:ff.', 'error') + return redirect(_VIEW) + + if not mtu_int and not mac: + flash('No changes specified.', 'error') + return redirect(_VIEW) + + queued = False + if mtu_int and str(mtu_int) != original_mtu: + queue_command(f'mtu {iface} {mtu_int}') + queued = True + if mac and mac != original_mac: + queue_command(f'mac {iface} {mac}') + queued = True + + if not queued: + flash('No changes detected.', 'info') + return redirect(_VIEW) + + flash(queued_msg(), 'success') + return redirect(_VIEW) diff --git a/docker/router-dash/app/action_apply_inter_vlan.py b/docker/router-dash/app/action_apply_inter_vlan.py index 6ec4c51..acd054d 100644 --- a/docker/router-dash/app/action_apply_inter_vlan.py +++ b/docker/router-dash/app/action_apply_inter_vlan.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import sanitize import validate @@ -85,7 +85,7 @@ def add_inter_vlan(): core.setdefault('inter_vlan_exceptions', []).append(entry) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -109,7 +109,7 @@ def toggle_inter_vlan(): items[idx]['enabled'] = not items[idx].get('enabled', True) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -138,7 +138,7 @@ def edit_inter_vlan(): items[idx]['enabled'] = request.form.get('enabled') == 'on' save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -162,5 +162,5 @@ def delete_inter_vlan(): items.pop(idx) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) diff --git a/docker/router-dash/app/action_apply_interface.py b/docker/router-dash/app/action_apply_interface.py index 86560d5..3ba45f1 100644 --- a/docker/router-dash/app/action_apply_interface.py +++ b/docker/router-dash/app/action_apply_interface.py @@ -1,9 +1,8 @@ -import re -import subprocess +import os from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import sanitize bp = Blueprint('action_apply_interface', __name__) @@ -11,11 +10,17 @@ bp = Blueprint('action_apply_interface', __name__) _VIEW = '/view/view_general' +_EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth', + 'tun', 'tap', 'ppp', 'virbr', + 'podman', 'vnet', 'macvtap', 'fc-') + def _get_system_interfaces(): try: - r = subprocess.run(['ip', 'link', 'show'], capture_output=True, text=True, timeout=5) - names = re.findall(r'^\d+:\s+(\S+):', r.stdout, re.MULTILINE) - return {n.split('@')[0] for n in names} - {'lo'} + return { + n for n in os.listdir('/sys/class/net') + if not n.startswith(_EXCLUDE_PREFIXES) + and os.path.exists(f'/sys/class/net/{n}/device') + } except Exception: return set() @@ -23,41 +28,32 @@ def _get_system_interfaces(): @bp.route('/action/apply_interface', methods=['POST']) @require_level('administrator') def apply_interface(): - idx_raw = request.form.get('row_index', '').strip() - interface = sanitize.interface_name(request.form.get('interface', '')) + wan = sanitize.interface_name(request.form.get('wan_interface', '')) + lan = sanitize.interface_name(request.form.get('lan_interface', '')) - try: - idx = int(idx_raw) - if idx not in (0, 1): - raise ValueError - except (ValueError, TypeError): - flash('Invalid request.', 'error') + if not wan or not lan: + flash('Both WAN and LAN interfaces are required.', 'error') return redirect(_VIEW) - if not interface: - flash('Interface name is required.', 'error') + if wan == lan: + flash('WAN and LAN interfaces must be different.', 'error') return redirect(_VIEW) if not verify_core_hash(request.form.get('config_hash', '')): flash('Configuration was modified by another session. Please refresh and try again.', 'error') return redirect(_VIEW) + available = _get_system_interfaces() + for iface in (wan, lan): + if available and iface not in available: + flash(f"Interface '{iface}' does not exist on this system.", 'error') + return redirect(_VIEW) + core = load_core() gen = core.setdefault('general', {}) - - other_key = 'lan_interface' if idx == 0 else 'wan_interface' - if interface == gen.get(other_key, ''): - flash('WAN and LAN interfaces must be different.', 'error') - return redirect(_VIEW) - - available = _get_system_interfaces() - if available and interface not in available: - flash(f"Interface '{interface}' does not exist on this system.", 'error') - return redirect(_VIEW) - - key = 'wan_interface' if idx == 0 else 'lan_interface' - gen[key] = interface + gen['wan_interface'] = wan + gen['lan_interface'] = lan save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(_VIEW) diff --git a/docker/router-dash/app/action_apply_mdns.py b/docker/router-dash/app/action_apply_mdns.py index cb15f36..4601fa9 100644 --- a/docker/router-dash/app/action_apply_mdns.py +++ b/docker/router-dash/app/action_apply_mdns.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import sanitize bp = Blueprint('action_apply_mdns', __name__) @@ -25,5 +25,5 @@ def apply_mdns(): }) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect('/view/view_mdns') diff --git a/docker/router-dash/app/action_apply_port_forwarding.py b/docker/router-dash/app/action_apply_port_forwarding.py index 178c83f..bac06b3 100644 --- a/docker/router-dash/app/action_apply_port_forwarding.py +++ b/docker/router-dash/app/action_apply_port_forwarding.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import sanitize import validate @@ -86,7 +86,7 @@ def add_port_forward(): core.setdefault('port_forwarding', []).append(entry) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -110,7 +110,7 @@ def toggle_port_forward(): items[idx]['enabled'] = not items[idx].get('enabled', True) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -139,7 +139,7 @@ def edit_port_forward(): items[idx]['enabled'] = request.form.get('enabled') == 'on' save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -163,5 +163,5 @@ def delete_port_forward(): removed = items.pop(idx) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) diff --git a/docker/router-dash/app/action_apply_upstream_dns.py b/docker/router-dash/app/action_apply_upstream_dns.py index fa98631..9d2b805 100644 --- a/docker/router-dash/app/action_apply_upstream_dns.py +++ b/docker/router-dash/app/action_apply_upstream_dns.py @@ -1,19 +1,30 @@ from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import sanitize bp = Blueprint('action_apply_upstream_dns', __name__) - @bp.route('/action/apply_upstream_dns', methods=['POST']) @require_level('administrator') def apply_upstream_dns(): - strict_order = 'strict_order' in request.form - cache_size_raw = request.form.get('cache_size', '').strip() - upstream_servers = [sanitize.ip(s) for s in request.form.getlist('upstream_servers') if s.strip()] - upstream_servers = [s for s in upstream_servers if s] + strict_order = 'strict_order' in request.form + cache_size_raw = request.form.get('cache_size', '').strip() + submitted = request.form.getlist('upstream_servers') + + for s in submitted: + if not s.strip(): + flash('Remove blank server entries before saving.', 'error') + return redirect('/view/view_upstream_dns') + + upstream_servers = [] + for s in submitted: + clean = sanitize.ip(s.strip()) + if not clean: + flash(f"'{s.strip()}' is not a valid IP address.", 'error') + return redirect('/view/view_upstream_dns') + upstream_servers.append(clean) try: cache_size = int(cache_size_raw) @@ -27,13 +38,19 @@ def apply_upstream_dns(): flash('Configuration was modified by another session. Please refresh and try again.', 'error') return redirect('/view/view_upstream_dns') - core = load_core() + core = load_core() + current = core.get('upstream_dns', {}) + if (strict_order == bool(current.get('strict_order', False)) and + cache_size == int(current.get('cache_size', 0)) and + upstream_servers == current.get('upstream_servers', [])): + flash('No changes detected.', 'info') + return redirect('/view/view_upstream_dns') + core.setdefault('upstream_dns', {}).update({ - 'strict_order': strict_order, - 'cache_size': cache_size, + 'strict_order': strict_order, + 'cache_size': cache_size, 'upstream_servers': upstream_servers, }) save_core(core) - - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect('/view/view_upstream_dns') diff --git a/docker/router-dash/app/action_apply_vlans.py b/docker/router-dash/app/action_apply_vlans.py index a2245f2..fa6ef90 100644 --- a/docker/router-dash/app/action_apply_vlans.py +++ b/docker/router-dash/app/action_apply_vlans.py @@ -1,6 +1,6 @@ from flask import Blueprint, request, redirect, flash from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg +from config_utils import load_core, save_core, verify_core_hash, queued_msg import sanitize import ipaddress as _ipaddress @@ -24,7 +24,7 @@ def _hash_ok(): def _derive_vlan_id(subnet, prefix): - """Return VLAN ID (1–4094) derived from the active octet of the network address, + """Return VLAN ID (1-4094) derived from the active octet of the network address, or None if not derivable. byte_index = (prefix-1) // 8.""" try: network = _ipaddress.ip_network(f'{subnet}/{prefix}', strict=False) @@ -64,7 +64,7 @@ def add_vlan(): vlan_id = _derive_vlan_id(subnet, subnet_mask) if vlan_id is None: - flash('Cannot derive a valid VLAN ID (1–4094) from this subnet/prefix combination.', 'error') + flash('Cannot derive a valid VLAN ID (1-4094) from this subnet/prefix combination.', 'error') return redirect(VIEW) if not _hash_ok(): @@ -94,7 +94,7 @@ def add_vlan(): vlans.append(entry) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -142,14 +142,14 @@ def edit_vlan(): return redirect(VIEW) existing = vlans[idx] - # is_vpn is never changed via edit — toggling it would invalidate peers/reservations. + # is_vpn is never changed via edit -- toggling it would invalidate peers/reservations. is_vpn = existing.get('is_vpn', False) # Use submitted subnet_mask, or fall back to whatever is already stored. final_mask = subnet_mask if subnet_mask is not None else existing.get('subnet_mask', 24) vlan_id = _derive_vlan_id(subnet, final_mask) if vlan_id is None: - flash('Cannot derive a valid VLAN ID (1–4094) from this subnet/prefix combination.', 'error') + flash('Cannot derive a valid VLAN ID (1-4094) from this subnet/prefix combination.', 'error') return redirect(VIEW) current_id = existing.get('vlan_id') @@ -173,7 +173,7 @@ def edit_vlan(): }) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) @@ -197,5 +197,5 @@ def delete_vlan(): removed = vlans.pop(idx) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(VIEW) diff --git a/docker/router-dash/app/action_apply_vpn.py b/docker/router-dash/app/action_apply_vpn.py index 1f10576..198727b 100644 --- a/docker/router-dash/app/action_apply_vpn.py +++ b/docker/router-dash/app/action_apply_vpn.py @@ -4,7 +4,7 @@ import re from flask import Blueprint, make_response, redirect, flash, request from auth import require_level -from config_utils import load_core, save_core, verify_core_hash, apply_msg, CONFIGS_DIR +from config_utils import load_core, save_core, verify_core_hash, queued_msg, CONFIGS_DIR import sanitize import validate @@ -19,6 +19,24 @@ def _wg_vlan(core): return next((v for v in core.get('vlans', []) if v.get('is_vpn')), None) +def _wg_vlan_by_name(core, name): + return next((v for v in core.get('vlans', []) if v.get('is_vpn') and v.get('name') == name), None) + + +def _find_peer_by_flat_idx(core, flat_idx): + """Return (vlan, peer_list_index) by flat index across all VPN VLANs in order.""" + i = 0 + for vlan in core.get('vlans', []): + if not vlan.get('is_vpn'): + continue + peers = vlan.get('peers', []) + for j in range(len(peers)): + if i == flat_idx: + return vlan, j + i += 1 + return None, None + + def _wg_iface(vlan, core): """Return the WireGuard interface name (wg0, wg1, ...) for a VPN VLAN.""" wg_vlans = [v for v in core.get('vlans', []) if v.get('is_vpn')] @@ -180,7 +198,7 @@ def apply_vpn(): overrides.pop('mtu', None) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(_VIEW) @@ -188,12 +206,17 @@ def apply_vpn(): @require_level('administrator') def add_vpn_peer(): peer_name = sanitize.name(request.form.get('peer_name', '')) + peer_vlan_nm = request.form.get('peer_vlan', '').strip() peer_ip_raw = request.form.get('peer_ip', '').strip() split_tunnel = 'split_tunnel' in request.form + enabled = 'enabled' in request.form if not peer_name: flash('Peer name is required.', 'error') return redirect(_VIEW) + if not peer_vlan_nm: + flash('Assigned VLAN is required.', 'error') + return redirect(_VIEW) peer_ip = validate.ip(peer_ip_raw) if not peer_ip: flash(f'"{peer_ip_raw}" is not a valid IP address.', 'error') @@ -203,18 +226,29 @@ def add_vpn_peer(): return redirect(_VIEW) core = load_core() - vpn_vlan = _wg_vlan(core) + vpn_vlan = _wg_vlan_by_name(core, peer_vlan_nm) if vpn_vlan is None: - flash('No WireGuard VLAN found in configuration.', 'error') + flash(f'VPN VLAN "{peer_vlan_nm}" not found.', 'error') return redirect(_VIEW) + try: + network = ipaddress.IPv4Network(f"{vpn_vlan['subnet']}/{vpn_vlan['subnet_mask']}", strict=False) + if ipaddress.IPv4Address(peer_ip) not in network: + flash(f'{peer_ip} is not within the subnet {vpn_vlan["subnet"]}/{vpn_vlan["subnet_mask"]} of {peer_vlan_nm}.', 'error') + return redirect(_VIEW) + except Exception: + pass + peers = vpn_vlan.setdefault('peers', []) if any(p.get('name') == peer_name for p in peers): flash(f'A peer named "{peer_name}" already exists.', 'error') return redirect(_VIEW) - if any(p.get('ip') == peer_ip for p in peers): - flash(f'IP address {peer_ip} is already assigned to another peer.', 'error') - return redirect(_VIEW) + for v in core.get('vlans', []): + if not v.get('is_vpn'): + continue + if any(p.get('ip') == peer_ip for p in v.get('peers', [])): + flash(f'IP address {peer_ip} is already assigned to another peer.', 'error') + return redirect(_VIEW) private_key, public_key = _generate_wg_keypair() peers.append({ @@ -222,7 +256,7 @@ def add_vpn_peer(): 'ip': peer_ip, 'public_key': public_key, 'split_tunnel': split_tunnel, - 'enabled': True, + 'enabled': enabled, }) save_core(core) @@ -232,8 +266,8 @@ def add_vpn_peer(): @bp.route('/action/edit_vpn_peer', methods=['POST']) @require_level('administrator') def edit_vpn_peer(): - idx = _row_index() - if idx is None: + flat_idx = _row_index() + if flat_idx is None: flash('Invalid request.', 'error') return redirect(_VIEW) @@ -248,105 +282,86 @@ def edit_vpn_peer(): return redirect(_VIEW) core = load_core() - vpn_vlan = _wg_vlan(core) - if vpn_vlan is None: - flash('No WireGuard VLAN found.', 'error') - return redirect(_VIEW) - - peers = vpn_vlan.get('peers', []) - if idx < 0 or idx >= len(peers): + vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx) + if vlan is None: flash('Peer not found.', 'error') return redirect(_VIEW) - # Reject duplicate name if it belongs to a different peer - if any(i != idx and p.get('name') == peer_name for i, p in enumerate(peers)): + peers = vlan.get('peers', []) + if any(j != peer_idx and p.get('name') == peer_name for j, p in enumerate(peers)): flash(f'A peer named "{peer_name}" already exists.', 'error') return redirect(_VIEW) - peers[idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled}) + peers[peer_idx].update({'name': peer_name, 'split_tunnel': split_tunnel, 'enabled': enabled}) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(_VIEW) @bp.route('/action/toggle_vpn_peer', methods=['POST']) @require_level('administrator') def toggle_vpn_peer(): - idx = _row_index() - if idx is None: + flat_idx = _row_index() + if flat_idx is None: flash('Invalid request.', 'error') return redirect(_VIEW) if not _hash_ok(): return redirect(_VIEW) core = load_core() - vpn_vlan = _wg_vlan(core) - if vpn_vlan is None: - flash('No WireGuard VLAN found.', 'error') - return redirect(_VIEW) - - peers = vpn_vlan.get('peers', []) - if idx < 0 or idx >= len(peers): + vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx) + if vlan is None: flash('Peer not found.', 'error') return redirect(_VIEW) - peers[idx]['enabled'] = not peers[idx].get('enabled', True) + peers = vlan.get('peers', []) + peers[peer_idx]['enabled'] = not peers[peer_idx].get('enabled', True) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(_VIEW) @bp.route('/action/delete_vpn_peer', methods=['POST']) @require_level('administrator') def delete_vpn_peer(): - idx = _row_index() - if idx is None: + flat_idx = _row_index() + if flat_idx is None: flash('Invalid request.', 'error') return redirect(_VIEW) if not _hash_ok(): return redirect(_VIEW) core = load_core() - vpn_vlan = _wg_vlan(core) - if vpn_vlan is None: - flash('No WireGuard VLAN found.', 'error') - return redirect(_VIEW) - - peers = vpn_vlan.get('peers', []) - if idx < 0 or idx >= len(peers): + vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx) + if vlan is None: flash('Peer not found.', 'error') return redirect(_VIEW) - peers.pop(idx) + vlan.get('peers', []).pop(peer_idx) save_core(core) - flash(apply_msg(), 'success') + flash(queued_msg('core apply'), 'success') return redirect(_VIEW) @bp.route('/action/regenerate_vpn_peer', methods=['POST']) @require_level('administrator') def regenerate_vpn_peer(): - idx = _row_index() - if idx is None: + flat_idx = _row_index() + if flat_idx is None: flash('Invalid request.', 'error') return redirect(_VIEW) if not _hash_ok(): return redirect(_VIEW) core = load_core() - vpn_vlan = _wg_vlan(core) - if vpn_vlan is None: - flash('No WireGuard VLAN found.', 'error') - return redirect(_VIEW) - - peers = vpn_vlan.get('peers', []) - if idx < 0 or idx >= len(peers): + vlan, peer_idx = _find_peer_by_flat_idx(core, flat_idx) + if vlan is None: flash('Peer not found.', 'error') return redirect(_VIEW) private_key, public_key = _generate_wg_keypair() - peer = peers[idx] + peer = vlan['peers'][peer_idx] peer['public_key'] = public_key save_core(core) - return _conf_response(vpn_vlan, peer['name'], peer['ip'], private_key) + return _conf_response(vlan, peer['name'], peer['ip'], private_key) diff --git a/docker/router-dash/app/api_apply_status.py b/docker/router-dash/app/api_apply_status.py new file mode 100644 index 0000000..dd30668 --- /dev/null +++ b/docker/router-dash/app/api_apply_status.py @@ -0,0 +1,28 @@ +from flask import Blueprint, request, jsonify +from auth import require_level +from config_utils import ( + _load_done_set, _is_locked, _lock_mtime, + _seconds_until_next_run, _entry_ts_from_queue, +) + +bp = Blueprint('api_apply_status', __name__) + + +@bp.route('/api/apply-status') +@require_level('viewer') +def apply_status(): + entry_uuid = request.args.get('uuid', '') + if not entry_uuid: + return jsonify({'status': 'unknown'}) + + if entry_uuid in _load_done_set(): + return jsonify({'status': 'complete'}) + + if _is_locked(): + mtime = _lock_mtime() + entry_ts = _entry_ts_from_queue(entry_uuid) + if mtime and entry_ts is not None and entry_ts < mtime: + return jsonify({'status': 'running'}) + return jsonify({'status': 'pending', 'next_in': None}) + + return jsonify({'status': 'pending', 'next_in': _seconds_until_next_run()}) diff --git a/docker/router-dash/app/config_utils.py b/docker/router-dash/app/config_utils.py index b2ce756..a790864 100644 --- a/docker/router-dash/app/config_utils.py +++ b/docker/router-dash/app/config_utils.py @@ -1,18 +1,16 @@ -import json, subprocess, hashlib -from markupsafe import Markup +import json, subprocess, hashlib, os, uuid +from datetime import datetime, timezone +from flask import session -_APPLY_CMD = 'sudo python3 ~/router/core.py --apply' - - -def apply_msg(): - """Return a Markup flash message for the apply reminder.""" - return Markup( - f'Configuration updated. To apply changes, run: ' - f'{_APPLY_CMD}' - ) - -CONFIGS_DIR = '/configs' -CORE_FILE = f'{CONFIGS_DIR}/core.json' +CONFIGS_DIR = '/configs' +CORE_FILE = f'{CONFIGS_DIR}/core.json' +DASHBOARD_QUEUE = f'{CONFIGS_DIR}/.dashboard-queue' +DASHBOARD_DONE = f'{CONFIGS_DIR}/.dashboard-done' +DASHBOARD_LAST_RUN = f'{CONFIGS_DIR}/.dashboard-last-run' +DASHBOARD_LOCK = f'{CONFIGS_DIR}/.dashboard-lock' +DASHB_TIMER_NAME = 'router-dashboard-queue' +DASHB_INTERVAL_SECS = 60 +QUEUE_MAX_LINES = 50 def load_core(): @@ -42,6 +40,150 @@ def verify_core_hash(submitted): return submitted == core_hash() +def _load_done_set(): + try: + done = set() + for line in open(DASHBOARD_DONE).read().splitlines(): + parts = line.split() + if parts: + done.add(parts[0]) + return done + except Exception: + return set() + + +def _read_pending(done_set): + pending = [] + try: + lines = open(DASHBOARD_QUEUE).read().splitlines() + except Exception: + return pending + for line in lines: + try: + parts = line.split(None, 3) + if len(parts) == 4: + entry_uuid, entry_ts, _dt, rest = parts + cmd_user = rest.rsplit(' (', 1) + entry_cmd = cmd_user[0].strip('[]') + entry_user = cmd_user[1].rstrip(')') if len(cmd_user) == 2 else '' + if entry_uuid not in done_set: + pending.append((entry_uuid, int(entry_ts), entry_cmd, entry_user)) + except Exception: + pass + return pending + + +def get_pending_entries(): + return _read_pending(_load_done_set()) + + +def _format_timing(secs): + if secs is None: + return None + if secs <= 5: + return 'momentarily' + if secs < 60: + return f'in about {secs} seconds' + mins = round(secs / 60) + return f'in about {mins} minute{"s" if mins != 1 else ""}' + + +def _trim_if_needed(): + try: + lines = [l for l in open(DASHBOARD_QUEUE).read().splitlines() if l] + if len(lines) <= QUEUE_MAX_LINES: + return + done_set = _load_done_set() + pending = [l for l in lines if l.split()[0] not in done_set] + with open(DASHBOARD_QUEUE, 'w') as f: + f.write('\n'.join(pending) + ('\n' if pending else '')) + open(DASHBOARD_DONE, 'w').close() + except Exception: + pass + + +def _queue_command(cmd): + done_set = _load_done_set() + pending = _read_pending(done_set) + current_user = session.get('email_address', 'unknown') + for entry_uuid, entry_ts, entry_cmd, entry_user in pending: + if entry_cmd == cmd and entry_user == current_user: + return entry_uuid, entry_ts + entry_uuid = str(uuid.uuid4()) + now = datetime.now() + entry_ts = int(now.timestamp()) + dt_str = now.strftime('%Y-%m-%dT%H:%M:%S') + user = session.get('email_address', 'unknown') + with open(DASHBOARD_QUEUE, 'a') as f: + f.write(f'{entry_uuid} {entry_ts} {dt_str} [{cmd}] ({user})\n') + _trim_if_needed() + return entry_uuid, entry_ts + + +def _entry_ts_from_queue(entry_uuid): + try: + for line in open(DASHBOARD_QUEUE).read().splitlines(): + parts = line.split(None, 2) + if len(parts) >= 2 and parts[0] == entry_uuid: + return int(parts[1]) + except Exception: + pass + return None + + +def _seconds_until_next_run(): + try: + last_run = float(open(DASHBOARD_LAST_RUN).read().strip()) + elapsed = datetime.now(timezone.utc).timestamp() - last_run + return int(max(0, DASHB_INTERVAL_SECS - elapsed)) + except Exception: + return None + + +def _is_locked(): + try: + return os.path.getsize(DASHBOARD_LOCK) > 0 + except Exception: + return False + + +def _lock_mtime(): + try: + return os.path.getmtime(DASHBOARD_LOCK) + except Exception: + return None + + +def queue_command(cmd): + """Queue a command without generating a flash message.""" + return _queue_command(cmd) + + +def queued_msg(cmd=None): + """Queue cmd if given, then return a timing message. + Without cmd, just returns timing (for commands already queued by the caller).""" + entry_ts = None + if cmd is not None: + _entry_uuid, entry_ts = queue_command(cmd) + if _is_locked(): + mtime = _lock_mtime() + if entry_ts is not None and mtime and entry_ts < mtime: + return 'Configuration saved. Changes are being applied now.' + return 'Configuration saved. Changes will be applied on the next run.' + timing = _format_timing(_seconds_until_next_run()) + if timing: + return f'Configuration saved. Changes will be applied {timing}.' + if cmd is None: + return 'Changes queued. The processing service is not running.' + parts = cmd.split() + cli_cmd = f'sudo python3 {parts[0]}.py --{parts[1]}' if len(parts) == 2 else cmd + install_cmd = f'sudo python3 {parts[0]}.py --install' if len(parts) >= 1 else 'core.py --install' + from markupsafe import Markup + return Markup(f'Configuration saved. The command processing service is not installed. ' + f'Run {install_cmd} to enable it, ' + f'or {cli_cmd} to apply manually.') + + def run_apply(): try: subprocess.run( diff --git a/docker/router-dash/app/main.py b/docker/router-dash/app/main.py index 8ca56a3..9161e73 100644 --- a/docker/router-dash/app/main.py +++ b/docker/router-dash/app/main.py @@ -23,6 +23,8 @@ from action_change_password import bp as action_change_password_bp from action_clear_ddns_log import bp as action_clear_ddns_log_bp from action_apply_ddns_providers import bp as action_apply_ddns_providers_bp from action_apply_interface import bp as action_apply_interface_bp +from action_apply_iface_config import bp as action_apply_iface_config_bp +from api_apply_status import bp as api_apply_status_bp app = Flask(__name__) app.secret_key = os.environ.get('SECRET_KEY', os.urandom(24)) @@ -49,6 +51,8 @@ app.register_blueprint(action_change_password_bp) app.register_blueprint(action_clear_ddns_log_bp) app.register_blueprint(action_apply_ddns_providers_bp) app.register_blueprint(action_apply_interface_bp) +app.register_blueprint(action_apply_iface_config_bp) +app.register_blueprint(api_apply_status_bp) def _seed_initial_account(): email = os.environ.get('INITIAL_MANAGER_EMAIL', '').strip().lower() diff --git a/docker/router-dash/app/sanitize.py b/docker/router-dash/app/sanitize.py index 29bda90..575073e 100644 --- a/docker/router-dash/app/sanitize.py +++ b/docker/router-dash/app/sanitize.py @@ -153,9 +153,13 @@ def ip_or_cidr(value, max_len=49): except ValueError: return '' -def mac(value, max_len=17): - """MAC address: hex digits and colons.""" - return _strip(value.upper(), r'[^0-9A-F:]', max_len) +def mac(value): + """MAC address in aa:bb:cc:dd:ee:ff format. Colons required; no other separators accepted. + Returns lowercase colon-separated MAC if valid, '' otherwise.""" + s = str(value).strip().lower() + if re.fullmatch(r'([0-9a-f]{2}:){5}[0-9a-f]{2}', s): + return s + return '' def url(value, max_len=500): """URL: printable ASCII except quotes, braces, brackets, backslash, spaces.""" diff --git a/docker/router-dash/app/view_page.py b/docker/router-dash/app/view_page.py index 4489fe1..801e45c 100644 --- a/docker/router-dash/app/view_page.py +++ b/docker/router-dash/app/view_page.py @@ -4,7 +4,7 @@ import json, re, subprocess, os, sys, html as html_mod import sanitize import validate from datetime import datetime, timezone -from config_utils import core_hash +from config_utils import core_hash, get_pending_entries, _seconds_until_next_run, _format_timing, _is_locked, _lock_mtime bp = Blueprint('view_page', __name__) @@ -74,11 +74,65 @@ def _prefix_to_dotted(n): def _get_system_interfaces(): - """Return sorted list of physical-ish interface names from `ip link show`.""" - out = _run('ip link show') - names = re.findall(r'^\d+:\s+(\S+):', out, re.MULTILINE) - ifaces = sorted({n.split('@')[0] for n in names} - {'lo'}) - return ifaces + _EXCLUDE_PREFIXES = ('lo', 'wg', 'docker', 'br-', 'veth', + 'tun', 'tap', 'ppp', 'virbr', + 'podman', 'vnet', 'macvtap', 'fc-') + try: + return sorted( + n for n in os.listdir('/sys/class/net') + if not n.startswith(_EXCLUDE_PREFIXES) + and os.path.exists(f'/sys/class/net/{n}/device') + ) + except Exception: + return [] + + +def _iface_info(iface): + base = f'/sys/class/net/{iface}' + def _rd(path): + try: + with open(f'{base}/{path}') as f: + return f.read().strip() + except Exception: + return None + wireless = os.path.isdir(f'{base}/wireless') + state = (_rd('operstate') or 'unknown').upper() + if state == 'UNKNOWN': + state = 'UP' + carrier_raw = _rd('carrier') + carrier = (carrier_raw == '1') if carrier_raw is not None else None + speed_raw = _rd('speed') + try: + mbps = int(speed_raw) + if mbps <= 0: + speed = None + elif mbps >= 1000 and mbps % 1000 == 0: + speed = f'{mbps // 1000} Gbps' + else: + speed = f'{mbps} Mbps' + except (TypeError, ValueError): + speed = None + mac = _rd('address') + perm_mac = _rd('perm_address') + if perm_mac and perm_mac == '00:00:00:00:00:00': + perm_mac = None + # DEBUG + # if not perm_mac: perm_mac = 'de:ad:be:ef:f0:0d' + def _int(val): + try: return int(val) if val else None + except ValueError: return None + return { + 'name': iface, + 'wireless': wireless, + 'state': state, + 'carrier': carrier, + 'speed': speed, + 'mtu': _rd('mtu'), + 'min_mtu': _int(_rd('min_mtu')), + 'max_mtu': _int(_rd('max_mtu')), + 'mac': mac, + 'perm_mac': perm_mac, + } def _iface_status(iface): @@ -247,7 +301,8 @@ def _config_datasource(name): if ptype == 'noip': row['credentials'] = f"U: {p.get('username', '-')}" elif ptype in ('cloudflare', 'duckdns'): - row['credentials'] = '(token set)' if p.get('api_token') else '(not set)' + tok = p.get('api_token', '') + row['credentials'] = f'API Token: {tok[:8]}…' if tok else '(not set)' else: row['credentials'] = '-' row['hostnames'] = json.dumps(p.get('hostnames', p.get('subdomains', []))) @@ -263,15 +318,16 @@ def _config_datasource(name): return rows if name == 'vpn_peers': - wg_vlan = next((v for v in vlans if v.get('is_vpn')), None) - if not wg_vlan: - return [] rows = [] - for peer in wg_vlan.get('peers', []): - row = dict(peer) - row['split_tunnel'] = 'yes' if peer.get('split_tunnel') else 'no' - row['pubkey_short'] = peer.get('public_key', '')[:20] + '...' if peer.get('public_key') else '-' - rows.append(row) + for i, vlan in enumerate(v for v in vlans if v.get('is_vpn')): + iface = f'wg{i}' + vlan_display = f'{iface} (VLAN {vlan.get("vlan_id", "?")})' + for peer in vlan.get('peers', []): + row = dict(peer) + row['vlan_display'] = vlan_display + row['split_tunnel'] = 'yes' if peer.get('split_tunnel') else 'no' + row['pubkey_short'] = peer.get('public_key', '')[:20] + '...' if peer.get('public_key') else '-' + rows.append(row) return rows return [] @@ -436,6 +492,8 @@ def collect_tokens(): vlans = core.get('vlans', []) tokens['GENERAL_WAN_INTERFACE'] = str(gen.get('wan_interface', '-')) tokens['GENERAL_LAN_INTERFACE'] = str(gen.get('lan_interface', '-')) + tokens['GENERAL_WAN_STATUS'] = _iface_status(gen.get('wan_interface', '')) + tokens['GENERAL_LAN_STATUS'] = _iface_status(gen.get('lan_interface', '')) tokens['GENERAL_LOG_MAX_KB'] = str(gen.get('log_max_kb', '-')) sys_ifaces = _get_system_interfaces() @@ -448,8 +506,15 @@ def collect_tokens(): [{'value': i, 'label': i} for i in sys_ifaces] ) tokens['NETWORK_INTERFACE_STATUS_OPTIONS'] = json.dumps( - [{'value': i, 'label': f'{i} — {_iface_status(i).title()}'} for i in sys_ifaces] + [{'value': i, 'label': f'{i} - {_iface_status(i).title()}'} for i in sys_ifaces] ) + iface_data = [_iface_info(i) for i in sys_ifaces] + tokens['NETWORK_INTERFACE_DATA_JSON'] = json.dumps(iface_data) + max_speed_len = max( + (len(str(d.get('speed') or '')) for d in iface_data), + default=len('Speed') + ) + tokens['NETWORK_INTERFACE_STATS_SPEED_PAD'] = str(max(max_speed_len, len('Speed'))) tokens['GENERAL_LOG_ERRORS_ONLY'] = 'true' if gen.get('log_errors_only') else 'false' tokens['GENERAL_DNSMASQ_LOG_QUERIES'] = 'true' if gen.get('dnsmasq_log_queries') else 'false' @@ -490,7 +555,12 @@ def collect_tokens(): for p in validate.VALID_DDNS_PROVIDERS ]) - wg_vlan = next((v for v in vlans if v.get('is_vpn')), {}) + wg_vlans_list = [v for v in vlans if v.get('is_vpn')] + tokens['VPN_VLAN_OPTIONS'] = json.dumps([ + {'value': v.get('name', ''), 'label': f'wg{i} (VLAN {v.get("vlan_id", "?")})'} + for i, v in enumerate(wg_vlans_list) + ]) + wg_vlan = wg_vlans_list[0] if wg_vlans_list else {} vpn = wg_vlan.get('vpn_information', {}) overrides = vpn.get('explicit_overrides', {}) tokens['VPN_LISTEN_PORT'] = str(vpn.get('listen_port', '')) @@ -693,6 +763,20 @@ def _render_item(item, tokens, inherited_req=None): body = render_items(item.get('items', []), tokens, req) return f'
{header}
{body}
' + if t == 'field_status': + label = e(item.get('label', '')) + raw = apply_tokens(item.get('value', ''), tokens).upper() + badge_map = { + 'UP': ('badge-enabled', 'Up'), + 'DOWN': ('badge-warning', 'Down'), + 'INVALID': ('badge-danger', 'Invalid'), + } + badge_cls, badge_text = badge_map.get(raw, ('badge-disabled', raw.title() or 'Unknown')) + return (f'
' + f'' + f'
{badge_text}
' + f'
') + if t == 'info_bar': variant = item.get('variant', 'info') text = e(apply_tokens(item.get('text', ''), tokens)) @@ -737,7 +821,14 @@ def _render_item(item, tokens, inherited_req=None): method = e(item.get('method', 'post')) inner = render_items(item.get('items', []), tokens, req) hash_field = f'' - return f'
{hash_field}{inner}
' + originals = json.dumps(_collect_form_originals(item.get('items', []), tokens)) + orig_field = f'' + return f'
{hash_field}{orig_field}{inner}
' + + if t == 'hidden': + name = e(item.get('name', '')) + value = e(apply_tokens(item.get('value', ''), tokens)) + return f'' if t == 'field': return _render_field(item, tokens) @@ -851,7 +942,7 @@ def _render_field(item, tokens): for o in options ) return (f'
' - f'' + f'' f'{hint_html}
') if input_type == 'number': @@ -868,19 +959,162 @@ def _render_field(item, tokens): f' class="form-input">{e(value)}' f'{hint_html}') - dyn_hint = '' if item.get('readonly') else '' + if input_type == 'interface_picker': + current = apply_tokens(item.get('value', ''), tokens) + try: + ifaces = json.loads(apply_tokens(item.get('data', '[]'), tokens)) + except Exception: + ifaces = [] + state_map = { + 'UP': ('badge-enabled', 'Up'), + 'DOWN': ('badge-warning', 'Down'), + 'INVALID': ('badge-danger', 'Invalid'), + } + rows_html = '' + cur_sc, cur_st = 'badge-disabled', '' + cur_speed = cur_mtu = cur_mac = cur_perm_mac = cur_min_mtu = cur_max_mtu = None + try: + speed_pad = int(tokens.get('NETWORK_INTERFACE_STATS_SPEED_PAD', '0')) + except Exception: + speed_pad = 0 + def _pad_speed(val): + s = val or '-' + return ' ' * max(0, speed_pad - len(s)) + e(s) + for ifc in ifaces: + iname = ifc.get('name', '') + wireless = ifc.get('wireless', False) + state = ifc.get('state', 'UNKNOWN') + carrier = ifc.get('carrier') + raw_speed = ifc.get('speed') + raw_mtu = ifc.get('mtu') + raw_mac = ifc.get('mac') + speed = raw_speed or '-' + mtu = raw_mtu or '-' + mac = raw_mac or '-' + sc, st = state_map.get(state, ('badge-disabled', state.title())) + type_txt = 'Wireless' if wireless else 'Wired' + if wireless: + carrier_txt = '-' + else: + carrier_txt = 'Yes' if carrier else ('No' if carrier is False else '-') + sel_cls = ' selected' if iname == current else '' + if iname == current: + cur_sc, cur_st = sc, st + cur_speed, cur_mtu, cur_mac = raw_speed, raw_mtu, raw_mac + cur_perm_mac = ifc.get('perm_mac') + cur_min_mtu = ifc.get('min_mtu') + cur_max_mtu = ifc.get('max_mtu') + padded_speed = _pad_speed(raw_speed) + padded_mtu = ' ' * max(0, 4 - len(raw_mtu or '-')) + e(raw_mtu or '-') + rows_html += (f'' + f'{e(iname)}' + f'{e(type_txt)}' + f'{st}' + f'{e(carrier_txt)}' + f'{e(speed)}' + f'{e(mtu)}' + f'{e(mac)}' + f'') + table_html = (f'
' + f'' + f'' + f'' + f'{rows_html}' + f'
InterfaceTypeStateCarrierSpeedMTUMAC
') + btn_label = f'{e(current) or "Select..."}' + btn_badge = (f'{e(cur_st)}' + if current else '') + if current and any([cur_speed, cur_mtu, cur_mac]): + ext_meta = (f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'
SpeedMTUMAC
{_pad_speed(cur_speed)}{" " * max(0, 4 - len(cur_mtu or "-"))}{e(cur_mtu or "-")}{e(cur_mac or "-")}
') + else: + ext_meta = '' + configure_btn = ( + f'' + ) if current else '' + return (f'
' + f'' + f'
' + f'' + f'
' + f'' + f'{ext_meta}' + f'{configure_btn}' + f'
' + f'
{table_html}
' + f'
' + f'
') + + validate = item.get('validate', '') + validate_attr = f' data-validate="{e(validate)}"' if validate else '' + dyn_hint = '' if (item.get('readonly') or item.get('dyn_hint') or validate) else '' return (f'
' f'{hint_html}{dyn_hint}
') + f' placeholder="{placeholder}" class="form-input{extra_cls}"{readonly}{validate_attr}>{hint_html}{dyn_hint}') + + +def _collect_form_originals(items, tokens): + """Walk form items and return {name: value} for all input fields (used for original_values).""" + result = {} + for item in items: + t = item.get('type', '') + if t == 'field': + name = item.get('name', '') + input_type = item.get('input_type', 'text') + if not name or input_type == 'hidden': + continue + value = apply_tokens(item.get('value', ''), tokens) + if input_type == 'checkbox': + result[name] = '1' if value.lower() in ('true', '1', 'yes') else '0' + elif input_type == 'select' and not value: + try: + opts = json.loads(apply_tokens(item.get('options', '[]'), tokens)) + value = opts[0]['value'] if opts else '' + except Exception: + pass + result[name] = value + else: + result[name] = value + elif t == 'editable_list': + name = item.get('name', '') + if not name: + continue + try: + vals = json.loads(apply_tokens(item.get('items', '[]'), tokens)) + vals = [str(v) for v in vals] + except Exception: + vals = [] + result[name] = vals + elif t == 'subnet_row': + result[item.get('subnet_name', 'subnet')] = apply_tokens(item.get('subnet_value', ''), tokens) + result[item.get('prefix_name', 'subnet_mask')] = apply_tokens(item.get('prefix_value', '24'), tokens) + elif t == 'field_row': + result.update(_collect_form_originals(item.get('items', []), tokens)) + return result def _render_editable_list(item, tokens): label = e(item.get('label', '')) name = e(item.get('name', '')) - ph = e(apply_tokens(item.get('item_placeholder', ''), tokens)) - add_lbl = e(apply_tokens(item.get('add_label', 'Add'), tokens)) - hint = e(apply_tokens(item.get('hint', ''), tokens)) + ph = e(apply_tokens(item.get('item_placeholder', ''), tokens)) + add_lbl = e(apply_tokens(item.get('add_label', 'Add'), tokens)) + hint = e(apply_tokens(item.get('hint', ''), tokens)) hint_html = f'

{hint}

' if hint else '' + validate = e(item.get('validate', '')) try: items_list = json.loads(apply_tokens(item.get('items', '[]'), tokens)) @@ -894,8 +1128,9 @@ def _render_editable_list(item, tokens): f'' for v in items_list ) + validate_attr = f' data-validate="{validate}"' if validate else '' return (f'
' - f'
' + f'
' f'{rows}' f'' f'
{hint_html}
') @@ -917,7 +1152,10 @@ def _render_table(item, tokens, inherited_req=None): t_inner = render_items(toolbar.get('items', []), tokens, req) toolbar_html = f'
{t_inner}
' - thead = ''.join(f'{e(c.get("label",""))}' for c in columns) + thead = ''.join( + f'{e(c.get("label",""))}' if c.get("class") else f'{e(c.get("label",""))}' + for c in columns + ) if row_actions: thead += '' @@ -1088,6 +1326,30 @@ def render_layout(view_id, content_html, tokens): existing_ids = tokens.get('EXISTING_VLAN_IDS_JSON', '[]') existing_names = tokens.get('EXISTING_VLAN_NAMES_JSON', '[]') existing_interfaces = tokens.get('EXISTING_VLAN_INTERFACES_JSON', '[]') + current_user = session.get('email_address', '') + pending = get_pending_entries() + my_uuid = next((u for u, t, c, usr in pending if usr == current_user), None) + + secs = _seconds_until_next_run() + locked = _is_locked() + lock_mtime = _lock_mtime() + other_bars = '' + seen_other_users = set() + for o_uuid, o_ts, o_cmd, o_user in pending: + if o_user == current_user: + continue + if o_user in seen_other_users: + continue + seen_other_users.add(o_user) + if locked and lock_mtime and o_ts < lock_mtime: + text = f'{e(o_user)}\'s changes are being applied now...' + cls = 'info-bar-warning info-bar-running' + else: + timing = _format_timing(secs) + text = f'{e(o_user)} has pending changes which will be applied {timing}.' if timing else f'{e(o_user)} has pending changes which will be applied on the next timer tick.' + cls = 'info-bar-warning' + other_bars += f'
{text}
\n' + return (f'\n\n\n' f' \n' f' \n' @@ -1096,9 +1358,9 @@ def render_layout(view_id, content_html, tokens): f'\n\n' f'{titlebar_html}\n' f'{navbar_html}\n' - f'
\n{content_html}\n
\n' + f'
\n{other_bars}{content_html}\n
\n' f'{footer_html}\n' - f'\n' + f'\n' f'\n' f'\n') @@ -1206,6 +1468,146 @@ function networkBitsMessage(octets, prefix) { return parts.join('; ') + ' for /' + prefix; } +function classifyMac(s) { + if (!s) return 'empty'; + if (/[^0-9a-fA-F:]/.test(s)) return 'invalid_char'; + if (/::/.test(s)) return 'invalid_struct'; + var groups = s.split(':'); + if (groups.length > 6) return 'too_many'; + for (var i = 0; i < groups.length; i++) { + if (groups[i].length > 2) return 'invalid_group'; + } + if (groups.length === 6 && groups.every(function(g) { return g.length === 2; })) return 'complete'; + return 'incomplete'; +} + +function classifyIp(s) { + if (!s) return 'empty'; + if (/[^0-9a-fA-F:.]/.test(s)) return 'invalid_char'; + if (s.indexOf(':') !== -1) { + // IPv6 + if (/:::/.test(s) || (s.match(/::/g) || []).length > 1) return 'invalid_struct'; + var v6parts = s.split(':').filter(function(p) { return p !== ''; }); + if (!v6parts.every(function(p) { return /^[0-9a-fA-F]{1,4}$/.test(p) || /^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(p); })) return 'invalid'; + var fullGroups = s.replace(/[^:]/g, '').length; + if (s.indexOf('::') !== -1 || fullGroups === 7) return 'complete'; + return 'incomplete'; + } + // IPv4 + if (/\.\./.test(s) || s.charAt(0) === '.') return 'invalid_struct'; + var parts = s.split('.'); + if (parts.length > 4) return 'invalid_struct'; + for (var i = 0; i < parts.length; i++) { + if (!parts[i]) continue; + var n = parseInt(parts[i], 10); + if (isNaN(n) || n > 255 || String(n) !== parts[i]) return 'invalid_range'; + } + if (parts.length === 4 && parts.every(function(p) { return p !== ''; })) return 'complete'; + return 'incomplete'; +} + +function classifyIpv4(s) { + if (!s) return 'empty'; + if (s.indexOf(':') !== -1) return 'invalid_struct'; + return classifyIp(s); +} + +function classifyIpv6(s) { + if (!s) return 'empty'; + if (s.indexOf('.') !== -1 && s.indexOf(':') === -1) return 'invalid_struct'; + if (s.indexOf(':') === -1) return 'incomplete'; + return classifyIp(s); +} + +function classifyUrl(s) { + if (!s) return 'empty'; + if (/[^A-Za-z0-9\-._~:/?#\[\]@!$&'()*+,;=%]/.test(s)) return 'invalid_char'; + var sl = s.toLowerCase(); + if ('https://'.startsWith(sl) || 'http://'.startsWith(sl)) return 'incomplete'; + var sep = sl.indexOf('://'); + if (sep === -1) return 'invalid_struct'; + var scheme = sl.slice(0, sep); + if (scheme !== 'http' && scheme !== 'https') return 'invalid_struct'; + var afterScheme = s.slice(sep + 3); + if (!afterScheme) return 'incomplete'; + var hostEnd = afterScheme.search(/[/:?#]/); + var host = hostEnd === -1 ? afterScheme : afterScheme.slice(0, hostEnd); + var rest = hostEnd === -1 ? '' : afterScheme.slice(hostEnd); + if (!host) return 'incomplete'; + if (/\.\./.test(host) || host.charAt(0) === '.' || host.charAt(host.length - 1) === '.') return 'invalid_struct'; + var labels = host.split('.'); + for (var i = 0; i < labels.length; i++) { + if (!/^[a-zA-Z0-9]([a-zA-Z0-9\-]*[a-zA-Z0-9])?$/.test(labels[i])) return 'invalid_struct'; + } + if (rest.charAt(0) === ':') { + var portMatch = rest.slice(1).match(/^\d+/); + if (!portMatch) return 'incomplete'; + if (parseInt(portMatch[0]) < 1 || parseInt(portMatch[0]) > 65535) return 'invalid_struct'; + } + return 'complete'; +} + +function classifyPort(s) { + if (!s) return 'empty'; + if (/[^0-9]/.test(s)) return 'invalid_char'; + var n = parseInt(s, 10); + if (n < 1 || n > 65535) return 'out_of_range'; + return 'complete'; +} + +function classifyIpv4Cidr(s) { + if (!s) return 'empty'; + var slash = s.indexOf('/'); + if (slash === -1) return classifyIpv4(s); + var ipCls = classifyIpv4(s.slice(0, slash)); + if (ipCls !== 'complete') return ipCls; + var prefix = s.slice(slash + 1); + if (!prefix) return 'incomplete'; + if (/[^0-9]/.test(prefix)) return 'invalid_char'; + var n = parseInt(prefix, 10); + if (n < 0 || n > 32) return 'invalid_struct'; + return 'complete'; +} + +function classifyEndpoint(s) { + if (!s) return 'empty'; + if (s.indexOf(':') !== -1) return classifyIp(s); + if (/^[0-9.]+$/.test(s)) return classifyIp(s); + return classifyDomainname(s); +} + +function classifyDashname(s) { + if (!s) return 'empty'; + if (/[^a-z0-9-]/.test(s)) return 'invalid_char'; + if (s.charAt(0) === '-') return 'invalid_struct'; + if (/--/.test(s)) return 'invalid_struct'; + if (s.charAt(s.length - 1) === '-') return 'incomplete'; + return 'complete'; +} + +function classifyDomainname(s) { + if (!s) return 'empty'; + if (/[^a-zA-Z0-9.-]/.test(s)) return 'invalid_char'; + if (s.charAt(0) === '.') return 'invalid_struct'; + if (/\.\./.test(s)) return 'invalid_struct'; + if (s.charAt(s.length - 1) === '.') return 'incomplete'; + var labels = s.split('.'); + for (var i = 0; i < labels.length; i++) { + var l = labels[i]; + if (l.charAt(0) === '-' || l.charAt(l.length - 1) === '-') return 'invalid_struct'; + } + return 'complete'; +} + +function classifyNetworkname(s) { + if (!s) return 'empty'; + if (/[^a-zA-Z0-9_-]/.test(s)) return 'invalid_char'; + if (s.charAt(0) === '-' || s.charAt(0) === '_') return 'invalid_struct'; + if (/[-_]{2,}/.test(s)) return 'invalid_struct'; + if (s.charAt(s.length - 1) === '-' || s.charAt(s.length - 1) === '_') return 'incomplete'; + return 'complete'; +} + function classifySubnet(s) { if (!s) return 'empty'; if (/[^0-9.]/.test(s)) return 'invalid_char'; @@ -1225,8 +1627,9 @@ function classifySubnet(s) { function setFieldHint(input, message, state) { // state: 'error' | 'warning' | 'ok' var fg = input.closest('.form-group'); - if (fg) { - var hint = fg.querySelector('.field-dyn-hint'); + var hintContainer = fg || input.parentElement; + if (hintContainer) { + var hint = hintContainer.querySelector('.field-dyn-hint'); if (hint) { hint.textContent = message; hint.style.display = message ? '' : 'none'; @@ -1443,8 +1846,11 @@ document.addEventListener('click', function(e) { } else if (inputType === 'credentials') { td.innerHTML = buildCredentialsHtml(rowData.provider || 'noip', rowData); } else { + var validateAttr = fDef.validate ? ' data-validate="' + esc(fDef.validate) + '"' : ''; + var hintHtml = fDef.validate ? '' : ''; td.innerHTML = ''; + '" value="' + esc(String(val)) + '" class="form-input inline-edit-input"' + validateAttr + '>' + hintHtml; + if (fDef.validate && typeof validateEl === 'function') validateEl(td.querySelector('input')); } }); @@ -1531,58 +1937,190 @@ document.querySelectorAll('.js-hide-card').forEach(function(btn) { }); }); +function _elMakeRow(list, value) { + var row = document.createElement('div'); + row.className = 'editable-list-item'; + var inp = document.createElement('input'); + inp.type = 'text'; inp.name = list.dataset.name; inp.value = value; + inp.placeholder = list.dataset.placeholder || ''; inp.className = 'form-input'; + var btn = document.createElement('button'); + btn.type = 'button'; btn.className = 'btn btn-ghost btn-sm editable-list-remove'; + btn.textContent = 'Remove'; + btn.addEventListener('click', function() { row.remove(); }); + row.appendChild(inp); row.appendChild(btn); + return row; +} + +document.querySelectorAll('.editable-list').forEach(function(list) { + list.querySelectorAll('.editable-list-item').forEach(function(row) { + row.querySelector('.editable-list-remove').addEventListener('click', function() { row.remove(); }); + }); + list.querySelector('.editable-list-add').addEventListener('click', function() { + list.insertBefore(_elMakeRow(list, ''), this); + }); +}); + +var validateEl; (function() { - document.querySelectorAll('form').forEach(function(form) { - var cancelBtn = form.querySelector('.btn-cancel'); - if (!cancelBtn) return; - var origValues = {}; - form.querySelectorAll('input, textarea, select').forEach(function(el) { - if (el.name) origValues[el.name] = el.type === 'checkbox' ? el.checked : el.value; - }); - function checkChanged() { - var changed = false; - form.querySelectorAll('input, textarea, select').forEach(function(el) { - if (!el.name) return; - var cur = el.type === 'checkbox' ? el.checked : el.value; - if (cur !== origValues[el.name]) changed = true; - }); - cancelBtn.disabled = !changed; + var _ipMsgs = { invalid_char: 'Invalid character', invalid_struct: 'Invalid format', + invalid_range: 'Octet out of range', invalid: 'Invalid IP address' }; + var _msgs = { + ip: _ipMsgs, + ipv4: _ipMsgs, + ipv6: _ipMsgs, + mac: { invalid_char: 'Invalid character', invalid_struct: 'Invalid format', + too_many: 'Too many groups', invalid_group: 'Each group must be exactly 2 hex characters' }, + subnet: { invalid_char: 'Invalid character', invalid_struct: 'Invalid format', + range: 'Octet out of range' }, + url: { invalid_char: 'Invalid character', invalid_struct: 'Invalid URL format' }, + port: { invalid_char: 'Digits only', out_of_range: 'Must be between 1 and 65535' }, + ipv4cidr: { invalid_char: 'Invalid character', invalid_struct: 'Prefix must be 0–32', + invalid_range: 'Octet out of range' }, + endpoint: { invalid_char: 'Invalid character', invalid_struct: 'Invalid hostname or IP', + invalid_range: 'Octet out of range', invalid: 'Invalid IP address' }, + dashname: { invalid_char: 'Lowercase letters, digits and hyphens only', + invalid_struct: 'No leading, trailing or consecutive hyphens' }, + domainname: { invalid_char: 'Letters, digits, hyphens and dots only', + invalid_struct: 'Invalid domain format' }, + networkname: { invalid_char: 'Letters, digits, hyphens and underscores only', + invalid_struct: 'No leading, trailing or consecutive special characters' } + }; + var _classifiers = { ip: classifyIp, ipv4: classifyIpv4, ipv6: classifyIpv6, mac: classifyMac, + subnet: classifySubnet, url: classifyUrl, + port: classifyPort, ipv4cidr: classifyIpv4Cidr, + endpoint: classifyEndpoint, + dashname: classifyDashname, domainname: classifyDomainname, networkname: classifyNetworkname }; + + validateEl = function(el) { + var list = el.closest('.editable-list[data-validate]'); + var vtype = el.dataset.validate || (list ? list.dataset.validate : ''); + var classify = _classifiers[vtype]; + if (!classify) return; + var cls = classify(el.value); + if (list) { + el.classList.remove('field-invalid', 'field-warning'); + if (cls === 'incomplete') el.classList.add('field-warning'); + else if (cls !== 'empty' && cls !== 'complete') el.classList.add('field-invalid'); + } else { + var msgs = _msgs[vtype] || {}; + if (cls === 'complete' || cls === 'empty') { + setFieldHint(el, el._postValidate ? el._postValidate(cls) : '', 'ok'); + } else if (cls === 'incomplete') { + setFieldHint(el, el._postValidate ? el._postValidate(cls) : '', 'warning'); + } else { + setFieldHint(el, msgs[cls] || 'Invalid', 'error'); + } } - form.addEventListener('input', checkChanged); - form.addEventListener('change', checkChanged); - cancelBtn.addEventListener('click', function() { - form.querySelectorAll('input, textarea, select').forEach(function(el) { - if (!el.name) return; - if (el.type === 'checkbox') { - el.checked = origValues[el.name]; - } else { - el.value = origValues[el.name]; - } - }); - cancelBtn.disabled = true; + }; + + // Regular fields (not inside editable lists) — initial state + expose _triggerValidate + document.querySelectorAll('input[data-validate]').forEach(function(el) { + if (el.closest('.editable-list')) return; + el._triggerValidate = function() { validateEl(el); }; + validateEl(el); + }); + + // Document-level delegation for regular fields (covers static + dynamically added inputs) + document.addEventListener('input', function(ev) { + var el = ev.target; + if (el.tagName !== 'INPUT' || !el.dataset.validate || el.closest('.editable-list')) return; + validateEl(el); + }); + + // Editable lists: validate existing items, delegation + MutationObserver for added items + document.querySelectorAll('.editable-list[data-validate]').forEach(function(list) { + if (!_classifiers[list.dataset.validate]) return; + list.querySelectorAll('input').forEach(function(inp) { validateEl(inp); }); + list.addEventListener('input', function(ev) { + if (ev.target.tagName === 'INPUT') validateEl(ev.target); }); + new MutationObserver(function(mutations) { + mutations.forEach(function(m) { + m.addedNodes.forEach(function(node) { + if (node.nodeType !== 1) return; + var inp = node.querySelector ? node.querySelector('input') : null; + if (inp) validateEl(inp); + }); + }); + }).observe(list, {childList: true}); }); })(); -document.querySelectorAll('.editable-list').forEach(function(list) { - var name = list.dataset.name; - var ph = list.dataset.placeholder; - function attachRemove(row) { - row.querySelector('.editable-list-remove').addEventListener('click', function() { - row.remove(); - }); - } - list.querySelectorAll('.editable-list-item').forEach(attachRemove); - list.querySelector('.editable-list-add').addEventListener('click', function() { - var row = document.createElement('div'); - row.className = 'editable-list-item'; - row.innerHTML = ''; - list.insertBefore(row, this); - attachRemove(row); +(function() { + document.querySelectorAll('form').forEach(function(form) { + var origInput = form.querySelector('input[name="original_values"]'); + if (!origInput) return; + var original; + try { original = JSON.parse(origInput.value); } catch(ex) { return; } + + var submitBtns = form.querySelectorAll('button[type="submit"]'); + var cancelBtns = form.querySelectorAll('.btn-cancel'); + submitBtns.forEach(function(b) { b.disabled = true; }); + cancelBtns.forEach(function(b) { b.disabled = true; }); + + // Only track fields named in original — naturally excludes config_hash, + // row_index, etc., while including hidden inputs (e.g. picker values). + function snapshot() { + var state = {}; + Object.keys(original).forEach(function(k) { if (Array.isArray(original[k])) state[k] = []; }); + form.querySelectorAll('input, select, textarea').forEach(function(el) { + if (!el.name || !(el.name in original)) return; + var val = el.type === 'checkbox' ? (el.checked ? '1' : '0') : el.value; + if (Array.isArray(state[el.name])) { state[el.name].push(val); } + else if (Array.isArray(original[el.name])) { state[el.name] = [val]; } + else { state[el.name] = val; } + }); + return JSON.stringify(state); + } + + var baseSnap = snapshot(); + + function checkDirty() { + var dirty = snapshot() !== baseSnap; + submitBtns.forEach(function(b) { b.disabled = !dirty; }); + cancelBtns.forEach(function(b) { b.disabled = !dirty; }); + } + + function resetToBase() { + // Reset editable lists (DOM rebuild) + form.querySelectorAll('.editable-list').forEach(function(list) { + var addBtn = list.querySelector('.editable-list-add'); + list.querySelectorAll('.editable-list-item').forEach(function(r) { r.remove(); }); + (original[list.dataset.name] || []).forEach(function(v) { + list.insertBefore(_elMakeRow(list, v), addBtn); + }); + }); + // Reset all tracked inputs; dispatch change so custom widgets update themselves + form.querySelectorAll('input, select, textarea').forEach(function(el) { + if (!el.name || !(el.name in original) || el.closest('.editable-list')) return; + var orig = original[el.name]; + var newVal = orig !== undefined ? String(orig) : ''; + if (el.type === 'checkbox') { + el.checked = (orig === '1'); + el.dispatchEvent(new Event('change', {bubbles: true})); + } else if (el.value !== newVal) { + el.value = newVal; + el.dispatchEvent(new Event('change', {bubbles: true})); + } + }); + checkDirty(); + form.querySelectorAll('input[data-validate]').forEach(function(el) { + if (typeof validateEl === 'function') validateEl(el); + }); + } + + cancelBtns.forEach(function(b) { b.addEventListener('click', resetToBase); }); + form.addEventListener('input', checkDirty); + form.addEventListener('change', checkDirty); + new MutationObserver(checkDirty).observe(form, {childList: true, subtree: true}); + + form._resetDirtyState = function() { + baseSnap = snapshot(); + submitBtns.forEach(function(b) { b.disabled = true; }); + cancelBtns.forEach(function(b) { b.disabled = true; }); + }; }); -}); +})(); (function() { function updateCredFields(container, provider) { @@ -1602,6 +2140,191 @@ document.querySelectorAll('.editable-list').forEach(function(list) { sel.addEventListener('change', function() { updateCredFields(container, this.value); }); }); })(); + +function startApplyPoller(uuid, bar, mine) { + var nextIn = null; + var pollTimer = null; + var tickTimer = null; + + function user() { return bar.getAttribute('data-apply-user') || ''; } + function esc(s) { return s.replace(/&/g,'&').replace(//g,'>'); } + function setHtml(html) { bar.innerHTML = html; } + + function updateCountdown() { + if (nextIn === null) { + setHtml(mine ? 'Configuration saved. The command processing service is not installed. Run core.py --install to enable it, or core.py --apply to apply manually.' + : esc(user()) + ' has pending changes. The command processing service is not installed.'); + return; + } + var timing = nextIn <= 5 ? 'momentarily' + : nextIn < 60 ? 'in about ' + nextIn + ' seconds' + : 'in about ' + Math.round(nextIn / 60) + ' minute' + (Math.round(nextIn / 60) !== 1 ? 's' : ''); + setHtml(mine ? 'Configuration saved. Changes will be applied ' + timing + '.' + : esc(user()) + ' has pending changes which will be applied ' + timing + '.'); + } + + function onStatus(data) { + if (data.status === 'complete') { + bar.classList.remove('info-bar-running'); + setHtml(mine ? 'Changes have been applied.' : esc(user()) + '\'s changes have been applied.'); + clearTimeout(pollTimer); + clearTimeout(tickTimer); + return; + } + if (data.status === 'running') { + bar.classList.add('info-bar-running'); + setHtml(mine ? 'Configuration saved. Changes are being applied now...' + : esc(user()) + '\'s changes are being applied now...'); + } else { + bar.classList.remove('info-bar-running'); + if (data.next_in !== null && data.next_in !== undefined) { nextIn = data.next_in; } + updateCountdown(); + } + pollTimer = setTimeout(doPoll, 3000); + } + + function doPoll() { + fetch('/api/apply-status?uuid=' + encodeURIComponent(uuid)) + .then(function(r) { return r.json(); }) + .then(onStatus) + .catch(function() { pollTimer = setTimeout(doPoll, 3000); }); + } + + function tick() { + if (nextIn !== null && nextIn > 0) { nextIn--; updateCountdown(); } + tickTimer = setTimeout(tick, 1000); + } + + doPoll(); + tick(); +} + +(function() { + if (typeof APPLY_UUID !== 'undefined' && APPLY_UUID) { + var bar = document.querySelector('.info-bar-flash.info-bar-success'); + if (bar) startApplyPoller(APPLY_UUID, bar, true); + } + document.querySelectorAll('[data-apply-uuid]').forEach(function(bar) { + startApplyPoller(bar.getAttribute('data-apply-uuid'), bar, false); + }); +})(); + +(function() { + function closeAll() { + document.querySelectorAll('.iface-picker-dropdown.open').forEach(function(d) { + d.classList.remove('open'); + }); + } + document.querySelectorAll('.iface-picker').forEach(function(picker) { + var btn = picker.querySelector('.iface-picker-btn'); + var header = picker.querySelector('.iface-picker-header'); + var dropdown = picker.querySelector('.iface-picker-dropdown'); + var hidden = picker.querySelector('input[type="hidden"]'); + + function applySelection(iface) { + var row = dropdown.querySelector('.iface-picker-row[data-iface="' + iface + '"]'); + if (!row) return; + btn.querySelector('.iface-picker-name').textContent = iface; + var badge = btn.querySelector('.iface-picker-badge'); + if (!badge) { badge = document.createElement('span'); btn.appendChild(badge); } + badge.className = 'badge ' + row.dataset.stateClass + ' iface-picker-badge'; + badge.textContent = row.dataset.stateLabel; + var stats = header.querySelector('.iface-picker-stats'); + if (!stats) { + stats = document.createElement('table'); + stats.className = 'iface-picker-stats'; + stats.innerHTML = 'SpeedMTUMAC'; + header.appendChild(stats); + } + stats.querySelector('tbody tr').innerHTML = + '' + (row.dataset.speed || '-') + '' + + '' + (row.dataset.mtu || '-') + '' + + '' + (row.dataset.mac || '-') + ''; + dropdown.querySelectorAll('.iface-picker-row').forEach(function(r) { + r.classList.toggle('selected', r === row); + }); + } + + hidden.addEventListener('change', function() { applySelection(hidden.value); }); + + btn.addEventListener('click', function(e) { + e.stopPropagation(); + var wasOpen = dropdown.classList.contains('open'); + closeAll(); + if (!wasOpen) dropdown.classList.add('open'); + }); + dropdown.addEventListener('click', function(e) { e.stopPropagation(); }); + dropdown.querySelectorAll('.iface-picker-row').forEach(function(row) { + row.addEventListener('click', function() { + hidden.value = this.dataset.iface; + closeAll(); + hidden.dispatchEvent(new Event('change', {bubbles: true})); + }); + }); + }); + document.addEventListener('click', closeAll); + document.addEventListener('keydown', function(e) { + if (e.key === 'Escape') closeAll(); + }); + document.querySelectorAll('.iface-configure-btn').forEach(function(btn) { + btn.addEventListener('click', function() { + var card = document.getElementById('iface-config-card'); + if (!card) return; + var form = card.querySelector('form'); + if (!form) return; + form.querySelector('[name="iface"]').value = this.dataset.iface; + var minMtu = this.dataset.minMtu !== '' ? parseInt(this.dataset.minMtu) : null; + var maxMtu = this.dataset.maxMtu !== '' ? parseInt(this.dataset.maxMtu) : null; + var mtuSel = form.querySelector('[name="mtu"]'); + var originalMtu = this.dataset.mtu || ''; + if (mtuSel) { + Array.from(mtuSel.options).forEach(function(opt) { + var v = parseInt(opt.value); + var out = !isNaN(v) && ((minMtu !== null && v < minMtu) || (maxMtu !== null && v > maxMtu)); + opt.disabled = out; + opt.hidden = out; + }); + mtuSel.value = originalMtu; + if (!mtuSel.value || mtuSel.selectedOptions[0].disabled) { + var first = Array.from(mtuSel.options).find(function(o) { return !o.disabled; }); + if (first) mtuSel.value = first.value; + originalMtu = mtuSel.value; + } + } + var origMtuField = form.querySelector('[name="original_mtu"]'); + if (origMtuField) origMtuField.value = originalMtu; + var macInput = form.querySelector('[name="mac"]'); + var originalMac = this.dataset.mac || ''; + if (macInput) { + macInput.dataset.permMac = this.dataset.permMac || ''; + macInput.value = originalMac; + if (macInput._triggerValidate) macInput._triggerValidate(); + } + var origMacField = form.querySelector('[name="original_mac"]'); + if (origMacField) origMacField.value = originalMac; + if (form._resetDirtyState) form._resetDirtyState(); + card.style.display = ''; + card.scrollIntoView({behavior: 'smooth', block: 'nearest'}); + }); + }); + document.querySelectorAll('.iface-config-cancel').forEach(function(a) { + a.addEventListener('click', function(ev) { + ev.preventDefault(); + var card = document.getElementById('iface-config-card'); + if (card) card.style.display = 'none'; + }); + }); +})(); +(function() { + var card = document.getElementById('iface-config-card'); + if (!card) return; + var macInput = card.querySelector('input[name="mac"]'); + if (!macInput || !macInput._triggerValidate) return; + macInput._postValidate = function() { + return macInput.dataset.permMac ? 'Factory default: ' + macInput.dataset.permMac : ''; + }; + macInput._triggerValidate(); +})(); """ diff --git a/docker/router-dash/data/page_content.json b/docker/router-dash/data/page_content.json index 74a9f03..bc4783b 100644 --- a/docker/router-dash/data/page_content.json +++ b/docker/router-dash/data/page_content.json @@ -275,7 +275,7 @@ "cells": [ { "type": "grid_label", - "text": "Upstream Servers" + "text": "DNS Providers" }, { "type": "grid_value", @@ -440,6 +440,10 @@ "text": "Add Provider", "action": "/action/add_ddns_provider", "method": "post" + }, + { + "type": "button_cancel", + "text": "Cancel" } ] } @@ -491,41 +495,41 @@ { "type": "card", "label": "Network Interfaces", - "client_requirement": "client_is_viewer+", + "client_requirement": "client_is_administrator+", "items": [ { - "type": "table", - "datasource": "config:interfaces", - "empty_message": "No interfaces configured.", - "columns": [ + "type": "form", + "action": "/action/apply_interface", + "method": "post", + "items": [ { - "label": "Type", - "field": "iface_type", - "class": "col-mono" + "type": "field", + "label": "WAN Interface", + "name": "wan_interface", + "input_type": "interface_picker", + "value": "%GENERAL_WAN_INTERFACE%", + "data": "%NETWORK_INTERFACE_DATA_JSON%" }, { - "label": "Interface", - "field": "interface", - "class": "col-mono" + "type": "field", + "label": "LAN Interface", + "name": "lan_interface", + "input_type": "interface_picker", + "value": "%GENERAL_LAN_INTERFACE%", + "data": "%NETWORK_INTERFACE_DATA_JSON%" }, { - "label": "Status", - "field": "status", - "render": "interface_status" - } - ], - "row_actions": [ - { - "text": "Edit", - "class": "btn-ghost btn-sm", - "action": "/action/apply_interface", - "method": "inline_edit", - "client_requirement": "client_is_administrator+", - "fields": [ + "type": "button_row", + "items": [ { - "col": "interface", - "input_type": "select", - "options": "%NETWORK_INTERFACE_STATUS_OPTIONS%" + "type": "button_primary", + "text": "Save", + "action": "/action/apply_interface", + "method": "post" + }, + { + "type": "button_cancel", + "text": "Cancel" } ] } @@ -535,7 +539,87 @@ }, { "type": "card", - "label": "General", + "id": "iface-config-card", + "label": "Interface Configuration", + "hidden": true, + "client_requirement": "client_is_administrator+", + "items": [ + { + "type": "form", + "action": "/action/apply_iface_config", + "method": "post", + "items": [ + { + "type": "hidden", + "name": "original_mtu", + "value": "" + }, + { + "type": "hidden", + "name": "original_mac", + "value": "" + }, + { + "type": "field_row", + "cols": 3, + "items": [ + { + "type": "field", + "label": "Interface", + "name": "iface", + "input_type": "text", + "readonly": true, + "value": "" + }, + { + "type": "field", + "label": "MTU", + "name": "mtu", + "input_type": "select", + "value": "", + "options": [ + {"value": "576", "label": "576"}, + {"value": "1280", "label": "1280"}, + {"value": "1492", "label": "1492"}, + {"value": "1500", "label": "1500"}, + {"value": "4096", "label": "4096"}, + {"value": "9000", "label": "9000"} + ] + }, + { + "type": "field", + "label": "MAC Address", + "name": "mac", + "input_type": "text", + "validate": "mac", + "value": "" + } + ] + }, + { + "type": "button_row", + "items": [ + { + "type": "button_primary", + "text": "Apply", + "action": "/action/apply_iface_config", + "method": "post" + }, + { + "type": "button_secondary", + "text": "Cancel", + "action": "#", + "class": "iface-config-cancel" + } + ] + } + ] + } + ] + }, + { + "type": "card", + "label": "Logging", "items": [ { "type": "form", @@ -630,7 +714,7 @@ "name": "strict_order", "input_type": "checkbox", "value": "%DNS_STRICT_ORDER%", - "hint": "Query upstream servers in list order rather than in parallel." + "hint": "Query DNS providers in list order rather than in parallel." }, { "type": "field", @@ -643,11 +727,12 @@ }, { "type": "editable_list", - "label": "Upstream Servers", + "label": "DNS Providers", "name": "upstream_servers", "items": "%DNS_UPSTREAM_SERVERS_JSON%", "item_placeholder": "e.g. 1.1.1.1", - "add_label": "Add Server", + "add_label": "Add Provider", + "validate": "ip", "hint": "DNS resolvers queried for external hostnames. Supports IPv4 and IPv6." }, { @@ -660,9 +745,8 @@ "method": "post" }, { - "type": "button_secondary", - "text": "Cancel", - "action": "/view/view_upstream_dns" + "type": "button_cancel", + "text": "Cancel" } ] } @@ -780,6 +864,10 @@ "text": "Add Banned IP", "action": "/action/add_banned_ip", "method": "post" + }, + { + "type": "button_cancel", + "text": "Cancel" } ] } @@ -845,11 +933,13 @@ }, { "col": "host", - "input_type": "text" + "input_type": "text", + "validate": "domainname" }, { "col": "ip", - "input_type": "text" + "input_type": "text", + "validate": "ip" }, { "col": "enabled", @@ -889,6 +979,7 @@ "label": "Hostname", "name": "host", "input_type": "text", + "validate": "domainname", "placeholder": "e.g. server.home.local" }, { @@ -896,6 +987,7 @@ "label": "Resolves To", "name": "ip", "input_type": "text", + "validate": "ip", "placeholder": "e.g. 192.168.1.100" }, { @@ -906,6 +998,10 @@ "text": "Add Host Override", "action": "/action/add_host_override", "method": "post" + }, + { + "type": "button_cancel", + "text": "Cancel" } ] } @@ -977,7 +1073,8 @@ "fields": [ { "col": "name", - "input_type": "text" + "input_type": "text", + "validate": "dashname" }, { "col": "description", @@ -990,7 +1087,8 @@ }, { "col": "url", - "input_type": "text" + "input_type": "text", + "validate": "url" } ] }, @@ -1019,6 +1117,7 @@ "label": "Name", "name": "name", "input_type": "text", + "validate": "dashname", "placeholder": "e.g. steven-black" }, { @@ -1040,6 +1139,7 @@ "label": "Source URL", "name": "url", "input_type": "text", + "validate": "url", "placeholder": "https://..." }, { @@ -1050,6 +1150,10 @@ "text": "Add Blocklist", "action": "/action/add_blocklist", "method": "post" + }, + { + "type": "button_cancel", + "text": "Cancel" } ] } @@ -1089,40 +1193,44 @@ { "label": "VLAN ID", "field": "vlan_id", - "class": "col-mono" + "class": "col-mono col-narrow" }, { "label": "Name", - "field": "name" + "field": "name", + "class": "col-narrow" }, { "label": "Interface", "field": "interface", - "class": "col-mono" + "class": "col-mono col-narrow" }, { "label": "Subnet", "field": "subnet", - "class": "col-mono" + "class": "col-mono col-narrow" }, { "label": "Mask", "field": "subnet_mask", - "class": "col-mono" + "class": "col-mono col-narrow" }, { "label": "Blocklists", "field": "use_blocklists", + "class": "col-expand", "render": "tag_list" }, { "label": "RADIUS Default", "field": "radius_default", + "class": "col-narrow", "render": "badge_enabled_disabled" }, { "label": "mDNS Reflection", "field": "mdns_reflection", + "class": "col-narrow", "render": "badge_enabled_disabled" } ], @@ -1136,7 +1244,8 @@ "fields": [ { "col": "name", - "input_type": "text" + "input_type": "text", + "validate": "dashname" }, { "col": "subnet", @@ -1196,6 +1305,7 @@ "label": "VLAN Name", "name": "name", "input_type": "text", + "validate": "dashname", "hint": "Lowercase letters, digits, hyphens. E.g. iot" }, { @@ -1277,6 +1387,10 @@ "method": "post", "class": "add-vlan-btn", "disabled": true + }, + { + "type": "button_cancel", + "text": "Cancel" } ] } @@ -1412,6 +1526,7 @@ "label": "Source", "name": "src_ip_or_subnet", "input_type": "text", + "validate": "ipv4cidr", "placeholder": "e.g. 192.168.20.0/24" }, { @@ -1419,6 +1534,7 @@ "label": "Destination", "name": "dst_ip_or_subnet", "input_type": "text", + "validate": "ipv4", "placeholder": "e.g. 192.168.10.100" }, { @@ -1426,6 +1542,7 @@ "label": "Dest Port", "name": "dst_port", "input_type": "text", + "validate": "port", "placeholder": "e.g. 8009" }, { @@ -1436,6 +1553,10 @@ "text": "Add Exception", "action": "/action/add_inter_vlan", "method": "post" + }, + { + "type": "button_cancel", + "text": "Cancel" } ] } @@ -1571,6 +1692,7 @@ "label": "Ext Port", "name": "dest_port", "input_type": "text", + "validate": "port", "placeholder": "e.g. 25565" }, { @@ -1578,6 +1700,7 @@ "label": "NAT IP", "name": "nat_ip", "input_type": "text", + "validate": "ipv4", "placeholder": "e.g. 192.168.1.50" }, { @@ -1585,6 +1708,7 @@ "label": "NAT Port", "name": "nat_port", "input_type": "text", + "validate": "port", "placeholder": "e.g. 25565" }, { @@ -1595,6 +1719,10 @@ "text": "Add Rule", "action": "/action/add_port_forward", "method": "post" + }, + { + "type": "button_cancel", + "text": "Cancel" } ] } @@ -1714,15 +1842,18 @@ }, { "col": "hostname", - "input_type": "text" + "input_type": "text", + "validate": "networkname" }, { "col": "mac", - "input_type": "text" + "input_type": "text", + "validate": "mac" }, { "col": "ip", - "input_type": "text" + "input_type": "text", + "validate": "ipv4" }, { "col": "radius_client", @@ -1774,6 +1905,7 @@ "label": "Hostname", "name": "hostname", "input_type": "text", + "validate": "networkname", "placeholder": "e.g. nas" }, { @@ -1781,6 +1913,7 @@ "label": "MAC Address", "name": "mac", "input_type": "text", + "validate": "mac", "placeholder": "e.g. aa:bb:cc:dd:ee:ff" }, { @@ -1788,6 +1921,7 @@ "label": "IP Address", "name": "ip", "input_type": "text", + "validate": "ipv4", "placeholder": "e.g. 192.168.10.50" }, { @@ -1805,6 +1939,10 @@ "text": "Add Reservation", "action": "/action/add_dhcp_reservation", "method": "post" + }, + { + "type": "button_cancel", + "text": "Cancel" } ] } @@ -1841,11 +1979,6 @@ "label": "Peer", "field": "peer_name" }, - { - "label": "Interface", - "field": "interface", - "class": "col-mono" - }, { "label": "Tunnel IP", "field": "tunnel_ip", @@ -1883,7 +2016,12 @@ "field": "name" }, { - "label": "IP", + "label": "Assigned VLAN", + "field": "vlan_display", + "class": "col-mono" + }, + { + "label": "Assigned IP", "field": "ip", "class": "col-mono" }, @@ -1912,7 +2050,8 @@ "fields": [ { "col": "name", - "input_type": "text" + "input_type": "text", + "validate": "dashname" }, { "col": "split_tunnel", @@ -1955,14 +2094,23 @@ "label": "Name", "name": "peer_name", "input_type": "text", + "validate": "dashname", "placeholder": "e.g. laptop", "hint": "Friendly name for this peer." }, { "type": "field", - "label": "IP Address", + "label": "Assigned VLAN", + "name": "peer_vlan", + "input_type": "select", + "options": "%VPN_VLAN_OPTIONS%" + }, + { + "type": "field", + "label": "Assigned IP", "name": "peer_ip", "input_type": "text", + "validate": "ipv4", "placeholder": "e.g. 192.168.40.2", "hint": "Static IP assigned to this peer within the VPN subnet." }, @@ -1973,6 +2121,13 @@ "input_type": "checkbox", "hint": "Route only VPN subnet traffic through the tunnel. When unchecked all traffic is routed through the VPN." }, + { + "type": "field", + "label": "Enabled", + "name": "enabled", + "input_type": "checkbox", + "checked": true + }, { "type": "button_row", "items": [ @@ -2017,6 +2172,7 @@ "label": "Server Endpoint", "name": "vpn_server_endpoint", "input_type": "text", + "validate": "endpoint", "value": "%VPN_SERVER_ENDPOINT%", "placeholder": "e.g. vpn.example.com", "hint": "Publicly reachable hostname or IP of this server, embedded in client config files." @@ -2026,6 +2182,7 @@ "label": "Domain", "name": "vpn_domain", "input_type": "text", + "validate": "dashname", "value": "%VPN_DOMAIN%", "placeholder": "e.g. local", "hint": "DNS search domain pushed to VPN clients." @@ -2035,6 +2192,7 @@ "label": "DNS Override", "name": "vpn_dns_server", "input_type": "text", + "validate": "ipv4", "value": "%VPN_DNS_SERVER%", "placeholder": "Leave blank to use gateway IP (%VPN_GATEWAY%)", "hint": "Explicit DNS server pushed to peers. Defaults to the gateway IP." diff --git a/docker/router-dash/docker-compose.yml b/docker/router-dash/docker-compose.yml index 1caf2dd..abebb05 100644 --- a/docker/router-dash/docker-compose.yml +++ b/docker/router-dash/docker-compose.yml @@ -11,6 +11,7 @@ services: - $HOME/router:/configs - $HOME/router/validation.py:/app/validation.py - /sys/class/net:/sys/class/net:ro + - /sys/devices:/sys/devices:ro environment: - INITIAL_MANAGER_EMAIL=mgrotke@gmail.com - SECRET_KEY=ey8hSQCCYE5kQXV8nOg1CB44LSd3AoUet2ZBc3aZlFrwBbazE7aHcxXWyuT97eAObet5jmOL0CjMg0rB1hE4d2SBVYHPfl8De55EiFv307r1QP3Mf5XgOSSCxD3TuD diff --git a/router/core.py b/router/core.py index 945b4c0..c6bc10d 100644 --- a/router/core.py +++ b/router/core.py @@ -111,9 +111,18 @@ DNSMASQ_CONF_DIR = Path("/etc/dnsmasq-router") LEASES_DIR = Path("/var/lib/misc") NETWORKD_DIR = Path("/etc/systemd/network") SYSTEMD_DIR = Path("/etc/systemd/system") -TIMER_NAME = "dns-blocklists-update" -TIMER_FILE = SYSTEMD_DIR / f"{TIMER_NAME}.timer" -TIMER_SVC_FILE = SYSTEMD_DIR / f"{TIMER_NAME}.service" +BLIST_TIMER_NAME = "dns-blocklists-update" +BLIST_TIMER_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer" +BLIST_TIMER_SVC_FILE = SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service" +DASHB_TIMER_NAME = "router-dashboard-queue" +DASHB_TIMER_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.timer" +DASHB_TIMER_SVC_FILE = SYSTEMD_DIR / f"{DASHB_TIMER_NAME}.service" +DASHB_TIMER_INTERVAL_SEC = 60 +DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue" +DASHB_DONE_FILE = SCRIPT_DIR / ".dashboard-done" +DASHB_LAST_RUN_FILE = SCRIPT_DIR / ".dashboard-last-run" +DASHB_LOCK_FILE = SCRIPT_DIR / ".dashboard-lock" +DASHB_SCRIPT_FILE = SCRIPT_DIR / "do_dashboard_queue.sh" RESOLV_CONF = Path("/etc/resolv.conf") NAT_SERVICE_NAME = "core-nat" NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service" @@ -1729,20 +1738,77 @@ def install_timer(data): "", ]) - for path, content in ((TIMER_FILE, timer_content), (TIMER_SVC_FILE, service_content)): + for path, content in ((BLIST_TIMER_FILE, timer_content), (BLIST_TIMER_SVC_FILE, service_content)): if not path.exists() or path.read_text() != content: path.write_text(content) print(f"Written: {path}") subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) - subprocess.run(["systemctl", "enable", "--now", f"{TIMER_NAME}.timer"], + subprocess.run(["systemctl", "enable", "--now", f"{BLIST_TIMER_NAME}.timer"], capture_output=True, text=True) - print(f"Timer {TIMER_NAME}.timer enabled (runs daily at {execute_time}).") + print(f"Timer {BLIST_TIMER_NAME}.timer enabled (runs daily at {execute_time}).") + +def install_dashboard_timer(): + """Install the 1-minute dashboard-queue timer that processes .dashboard-queue.""" + timer_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + "Description=Router dashboard pending-update processor", + "", + "[Timer]", + f"OnActiveSec={DASHB_TIMER_INTERVAL_SEC}s", + f"OnUnitActiveSec={DASHB_TIMER_INTERVAL_SEC}s", + "AccuracySec=10s", + "", + "[Install]", + "WantedBy=timers.target", + "", + ]) + + service_content = "\n".join([ + "# Generated by core.py -- do not edit manually.", + "", + "[Unit]", + "Description=Router dashboard update processor", + "", + "[Service]", + "Type=oneshot", + f"ExecStart=/bin/bash {DASHB_SCRIPT_FILE}", + "", + ]) + + for path, content in ((DASHB_TIMER_FILE, timer_content), (DASHB_TIMER_SVC_FILE, service_content)): + if not path.exists() or path.read_text() != content: + path.write_text(content) + print(f"Written: {path}") + + subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True) + subprocess.run(["systemctl", "enable", f"{DASHB_TIMER_NAME}.timer"], + capture_output=True, text=True) + active = subprocess.run( + ["systemctl", "is-active", f"{DASHB_TIMER_NAME}.timer"], + capture_output=True, text=True + ).stdout.strip() == "active" + verb = "restart" if active else "start" + subprocess.run(["systemctl", verb, f"{DASHB_TIMER_NAME}.timer"], + capture_output=True, text=True) + print(f"Timer {DASHB_TIMER_NAME}.timer enabled (runs every {DASHB_TIMER_INTERVAL_SEC}s).") + +def remove_dashboard_timer(): + subprocess.run(["systemctl", "disable", "--now", f"{DASHB_TIMER_NAME}.timer"], + capture_output=True, text=True) + for f in (DASHB_TIMER_FILE, DASHB_TIMER_SVC_FILE): + if f.exists(): + f.unlink() + print(f"Removed: {f}") + else: + print(f"Not found, skipping: {f}") def remove_timer(): - subprocess.run(["systemctl", "disable", "--now", f"{TIMER_NAME}.timer"], + subprocess.run(["systemctl", "disable", "--now", f"{BLIST_TIMER_NAME}.timer"], capture_output=True, text=True) - for f in (TIMER_FILE, TIMER_SVC_FILE): + for f in (BLIST_TIMER_FILE, BLIST_TIMER_SVC_FILE): if f.exists(): f.unlink() print(f"Removed: {f}") @@ -2252,12 +2318,12 @@ def apply_nftables(data, dry_run=False): dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "") try: # Single IP -- check if it's in an active subnet - addr = _ipaddress.IPv4Address(dst) + addr = ipaddress.IPv4Address(dst) return any(addr in net for net in active_subnets) except ValueError: try: # Subnet -- check if it overlaps with any active subnet - net = _ipaddress.IPv4Network(dst, strict=False) + net = ipaddress.IPv4Network(dst, strict=False) return any(net.overlaps(s) for s in active_subnets) except ValueError: return True @@ -2636,7 +2702,7 @@ def show_status(data): units.append((vlan_service_name(vlan), "(wg0 not up)", "active")) else: units.append((vlan_service_name(vlan), None, "active")) - units.append((f"{TIMER_NAME}.timer", None, "active")) + units.append((f"{BLIST_TIMER_NAME}.timer", None, "active")) units.append((NAT_SERVICE_NAME, None, "inactive")) # oneshot - exits after running units.append(("freeradius", None, "active")) units.append(("avahi-daemon", None, "active")) @@ -2652,12 +2718,12 @@ def show_status(data): # Timer next trigger r = subprocess.run( - ["systemctl", "show", f"{TIMER_NAME}.timer", "--property=NextElapseUSecRealtime,NextElapseUSecMonotonic"], + ["systemctl", "show", f"{BLIST_TIMER_NAME}.timer", "--property=NextElapseUSecRealtime,NextElapseUSecMonotonic"], capture_output=True, text=True ) # Fall back to human-readable 'Trigger' field from status output r2 = subprocess.run( - ["systemctl", "status", f"{TIMER_NAME}.timer", "--no-pager"], + ["systemctl", "status", f"{BLIST_TIMER_NAME}.timer", "--no-pager"], capture_output=True, text=True ) for line in r2.stdout.splitlines(): @@ -2983,6 +3049,7 @@ def show_metrics(data): def stop_instances(data): """Remove timer and stop all per-VLAN instances (config files preserved).""" remove_timer() + remove_dashboard_timer() print() for vlan in data["vlans"]: svc = vlan_service_name(vlan) @@ -3193,7 +3260,7 @@ def _dry_run_timer(data): print("-- Timer (dry-run) ---------------------------------------------------") general = data.get("general", {}) execute_time = general.get("daily_execute_time_24hr_local", "02:30") - for path, label in [(TIMER_FILE, "timer unit"), (TIMER_SVC_FILE, "service unit")]: + for path, label in [(BLIST_TIMER_FILE, "timer unit"), (BLIST_TIMER_SVC_FILE, "service unit")]: action = "update" if path.exists() else "create and enable" print(f" Would {action}: {path}") print(f" Schedule: daily at {execute_time} local time (Persistent=true - catches up if missed)") @@ -3213,7 +3280,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, print() print("-- Stopping router services (dry-run) --------------------------------") - print(f" Would disable and stop: {TIMER_NAME}.timer") + print(f" Would disable and stop: {BLIST_TIMER_NAME}.timer") for vlan in data["vlans"]: svc = vlan_service_name(vlan) conf = vlan_conf_file(vlan) @@ -3484,6 +3551,13 @@ def cmd_install(data): check_root() check_dependencies() print("All required packages are installed.") + install_dashboard_timer() + # Create blank dotfiles for dashboard updates + for dotfile in (DASHB_QUEUE_FILE, DASHB_DONE_FILE, DASHB_LAST_RUN_FILE, DASHB_LOCK_FILE): + if not dotfile.exists(): + dotfile.touch() + chown_to_script_dir_owner(dotfile) + print(f"Created: {dotfile}") def cmd_apply(data, dry_run=False): @@ -3580,6 +3654,10 @@ def cmd_apply(data, dry_run=False): install_timer(data) print() + print("-- Dashboard timer ---------------------------------------------------") + install_dashboard_timer() + print() + print("-- Boot service ------------------------------------------------------") install_nat_service() print()