diff --git a/docker/routlin-dash/app/pages/actions/action.py b/docker/routlin-dash/app/pages/actions/action.py index 5a22ad0..7c9b5b5 100644 --- a/docker/routlin-dash/app/pages/actions/action.py +++ b/docker/routlin-dash/app/pages/actions/action.py @@ -1,8 +1,10 @@ +import os from pathlib import Path from flask import Blueprint, request, redirect, flash, session from auth import require_level from config_utils import (flush_pending_to_queue, get_dashboard_pending, - revert_snapshot_to_config, queued_msg) + revert_snapshot_to_config, queued_msg, + SNAPSHOTS_DIR, DASHBOARD_PENDING) _PAGE = Path(__file__).parent.name @@ -48,3 +50,28 @@ def history_revert(): plural = 's' if succeeded != 1 else '' flash(f'{succeeded} change{plural} reverted.', 'success') return redirect(f'/{_PAGE}') + + +@bp.route('/action/actions/history_clear', methods=['POST']) +@require_level('manager') +def history_clear(): + count = 0 + for fname in os.listdir(SNAPSHOTS_DIR): + fpath = os.path.join(SNAPSHOTS_DIR, fname) + if os.path.isfile(fpath): + os.remove(fpath) + count += 1 + plural = 's' if count != 1 else '' + flash(f'History cleared ({count} record{plural} removed).', 'success') + return redirect(f'/{_PAGE}') + + +@bp.route('/action/actions/pending_dismiss', methods=['POST']) +@require_level('manager') +def pending_dismiss(): + if not get_dashboard_pending(): + flash('No pending changes to dismiss.', 'info') + return redirect(f'/{_PAGE}') + open(DASHBOARD_PENDING, 'w').close() + flash('Pending changes dismissed.', 'success') + return redirect(f'/{_PAGE}') diff --git a/docker/routlin-dash/app/pages/actions/content.json b/docker/routlin-dash/app/pages/actions/content.json index cb6253a..cb8feca 100644 --- a/docker/routlin-dash/app/pages/actions/content.json +++ b/docker/routlin-dash/app/pages/actions/content.json @@ -39,6 +39,18 @@ { "type": "raw_html", "html": "%APPLY_WARNING%" + }, + { + "type": "raw_html", + "html": "" + }, + { + "type": "button_danger", + "text": "Dismiss All", + "action": "/action/actions/pending_dismiss", + "method": "post", + "disabled": "%NO_PENDING%", + "client_requirement": "client_is_manager=" } ] } @@ -95,11 +107,20 @@ }, { "type": "button_row", + "justify": "space-between", "items": [ { - "type": "button_danger", + "type": "button_secondary", "text": "Revert Selected", "disabled": "%NO_HISTORY%" + }, + { + "type": "button_danger", + "text": "Clear History", + "action": "/action/actions/history_clear", + "method": "post", + "disabled": "%NO_HISTORY%", + "client_requirement": "client_is_manager=" } ] } diff --git a/docker/routlin-dash/app/pages/networklayout/action.py b/docker/routlin-dash/app/pages/networklayout/action.py index 90a8204..a5ed70d 100644 --- a/docker/routlin-dash/app/pages/networklayout/action.py +++ b/docker/routlin-dash/app/pages/networklayout/action.py @@ -59,6 +59,14 @@ def addvlan_add(): if subnet_mask is None: flash('Invalid subnet prefix (must be 1-30).', 'error') return redirect(f'/{_PAGE}') + if is_vpn and mdns_reflection: + flash('mDNS reflection is not supported on VPN VLANs.', 'error') + return redirect(f'/{_PAGE}') + + _vlan_net = ipaddress.IPv4Network(f'{subnet}/{subnet_mask}', strict=False) + if ipaddress.IPv4Address(subnet) != _vlan_net.network_address: + flash(f"Subnet IP must be a network address (expected {_vlan_net.network_address}).", 'error') + return redirect(f'/{_PAGE}') if not _hash_ok(): return redirect(f'/{_PAGE}') @@ -73,16 +81,18 @@ def addvlan_add(): return redirect(f'/{_PAGE}') new_identities = [] - if raw_identities: - _vlan_net = ipaddress.IPv4Network(f'{subnet}/{subnet_mask}', strict=False) - for raw in raw_identities: + for raw in raw_identities: ip_clean = sanitize.ip(str(raw.get('ip', ''))) if not ip_clean: flash('Invalid IP address in identity.', 'error') return redirect(f'/{_PAGE}') - if ipaddress.IPv4Address(ip_clean) not in _vlan_net: + _addr = ipaddress.IPv4Address(ip_clean) + if _addr not in _vlan_net: flash(f"Identity IP '{ip_clean}' is not in the VLAN subnet ({subnet}/{subnet_mask}).", 'error') return redirect(f'/{_PAGE}') + if _addr == _vlan_net.network_address or _addr == _vlan_net.broadcast_address: + flash(f"Identity IP '{ip_clean}' cannot be the network or broadcast address.", 'error') + return redirect(f'/{_PAGE}') ident = {'ip': ip_clean} desc = str(raw.get('description', '')).strip() if desc: @@ -116,6 +126,11 @@ def addvlan_add(): flash(f"'{_line}' is not a valid DNS server IP.", 'error') return redirect(f'/{_PAGE}') dns_ips.append(_clean) + if dns_override and dns_ips: + for _ip in dns_ips: + if ipaddress.IPv4Address(_ip) not in _vlan_net: + flash(f"DNS server '{_ip}' is not in the VLAN subnet ({subnet}/{subnet_mask}).", 'error') + return redirect(f'/{_PAGE}') new_stored_dns = dns_ips if dns_override else [] ntp_override = 'ntp_server_override' in request.form @@ -129,6 +144,11 @@ def addvlan_add(): flash(f"'{_line}' is not a valid NTP server IP.", 'error') return redirect(f'/{_PAGE}') ntp_ips.append(_clean) + if ntp_override and ntp_ips: + for _ip in ntp_ips: + if ipaddress.IPv4Address(_ip) not in _vlan_net: + flash(f"NTP server '{_ip}' is not in the VLAN subnet ({subnet}/{subnet_mask}).", 'error') + return redirect(f'/{_PAGE}') new_stored_ntp = ntp_ips if ntp_override else [] dhcp_domain_raw = request.form.get('dhcp_domain', '').strip() @@ -254,12 +274,20 @@ def vlans_edit(): is_vpn = existing.get('is_vpn', False) final_mask = subnet_mask if subnet_mask is not None else existing.get('subnet_mask', 24) + if is_vpn and mdns_reflection: + flash('mDNS reflection is not supported on VPN VLANs.', 'error') + return redirect(f'/{_PAGE}') + if identity_ips: _vlan_net = ipaddress.IPv4Network(f'{subnet}/{final_mask}', strict=False) for _ip in identity_ips: - if ipaddress.IPv4Address(_ip) not in _vlan_net: + _addr = ipaddress.IPv4Address(_ip) + if _addr not in _vlan_net: flash(f"Server identity IP '{_ip}' is not in the VLAN subnet ({subnet}/{final_mask}).", 'error') return redirect(f'/{_PAGE}') + if _addr == _vlan_net.network_address or _addr == _vlan_net.broadcast_address: + flash(f"Server identity IP '{_ip}' cannot be the network or broadcast address.", 'error') + return redirect(f'/{_PAGE}') current_id = existing.get('vlan_id') if current_id == 1 and vlan_id != 1: diff --git a/docker/routlin-dash/app/pages/physicalinterfaces/content.json b/docker/routlin-dash/app/pages/physicalinterfaces/content.json index c3328cd..cf8a3be 100644 --- a/docker/routlin-dash/app/pages/physicalinterfaces/content.json +++ b/docker/routlin-dash/app/pages/physicalinterfaces/content.json @@ -132,7 +132,8 @@ "name": "mac", "input_type": "text", "validate": "mac", - "value": "" + "value": "", + "hint": "Factory default: none" } ] },