Development

This commit is contained in:
Matthew Grotke 2026-05-25 21:49:47 -04:00
parent ac0aa4de22
commit 91d8b950b7
5 changed files with 38 additions and 35 deletions

View file

@ -290,7 +290,7 @@ def queued_msg(cmd=None, description='', action_label='Configuration saved'):
return _build_timing_msg(entry_ts, cmd, action_label) return _build_timing_msg(entry_ts, cmd, action_label)
# ── Snapshot system ─────────────────────────────────────────────────────────── # Snapshot system ===================================================
def _pending_uuid_set(): def _pending_uuid_set():
return {item[0] for item in _read_dashboard_pending()} return {item[0] for item in _read_dashboard_pending()}
@ -437,7 +437,7 @@ def save_config_with_snapshot(new_core, path, key, operation, before, after,
return _build_timing_msg(entry_ts, cmd) return _build_timing_msg(entry_ts, cmd)
# ── Misc ────────────────────────────────────────────────────────────────────── # Misc ==============================================================
def run_apply(): def run_apply():
try: try:

View file

@ -931,7 +931,8 @@ def _render_item(item, tokens, inherited_req=None):
if extra: if extra:
cls = f'{cls} {extra}' cls = f'{cls} {extra}'
text = e(apply_tokens(item.get('text', ''), tokens)) text = e(apply_tokens(item.get('text', ''), tokens))
action = e(apply_tokens(item.get('action', '#'), tokens)) action_raw = item.get('action', '')
action = e(apply_tokens(action_raw, tokens))
disabled_val = apply_tokens(str(item.get('disabled', '')), tokens) disabled_val = apply_tokens(str(item.get('disabled', '')), tokens)
disabled = ' disabled' if disabled_val and disabled_val not in ('false', '0') else '' disabled = ' disabled' if disabled_val and disabled_val not in ('false', '0') else ''
formaction = item.get('formaction', '') formaction = item.get('formaction', '')
@ -941,7 +942,9 @@ def _render_item(item, tokens, inherited_req=None):
if item.get('method', '').lower() == 'post': if item.get('method', '').lower() == 'post':
return (f'<form method="post" action="{action}" class="form-inline">' return (f'<form method="post" action="{action}" class="form-inline">'
f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button></form>') f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button></form>')
if action_raw:
return f'<a href="{action}" class="btn {e(cls)}">{text}</a>' return f'<a href="{action}" class="btn {e(cls)}">{text}</a>'
return f'<button type="submit" class="btn {e(cls)}"{disabled}>{text}</button>'
if t == 'button_cancel': if t == 'button_cancel':
text = e(apply_tokens(item.get('text', 'Cancel'), tokens)) text = e(apply_tokens(item.get('text', 'Cancel'), tokens))

View file

@ -165,19 +165,19 @@ def main():
help="VLAN ID of the WireGuard VLAN to add the peer to (e.g. 40)") help="VLAN ID of the WireGuard VLAN to add the peer to (e.g. 40)")
args = parser.parse_args() args = parser.parse_args()
# -- Validate IP ----------------------------------------------------------- # Validate IP =======================================================
try: try:
peer_ip = str(ipaddress.IPv4Address(args.ip)) peer_ip = str(ipaddress.IPv4Address(args.ip))
except ValueError: except ValueError:
die(f"'{args.ip}' is not a valid IPv4 address.") die(f"'{args.ip}' is not a valid IPv4 address.")
# -- Load config and find WG VLAN ------------------------------------------ # Load config and find WG VLAN ==========================================
data = load_config() data = load_config()
vlan = find_wg_vlan(data, iface=args.iface, vlan_id=args.vlan_id) vlan = find_wg_vlan(data, iface=args.iface, vlan_id=args.vlan_id)
iface = resolve_wg_iface(vlan, data) iface = resolve_wg_iface(vlan, data)
# -- Validate peer IP is within subnet ------------------------------------- # Validate peer IP is within subnet =================================
try: try:
network = ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False) network = ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False)
except (KeyError, ValueError) as e: except (KeyError, ValueError) as e:
@ -186,19 +186,19 @@ def main():
if ipaddress.IPv4Address(peer_ip) not in network: if ipaddress.IPv4Address(peer_ip) not in network:
die(f"IP {peer_ip} is not within the VPN subnet {network}.") die(f"IP {peer_ip} is not within the VPN subnet {network}.")
# -- Check for duplicates -------------------------------------------------- # Check for duplicates ==============================================
peers = vlan.setdefault("peers", []) peers = vlan.setdefault("peers", [])
if any(p.get("name") == args.name for p in peers): if any(p.get("name") == args.name for p in peers):
die(f"A peer named '{args.name}' already exists.") die(f"A peer named '{args.name}' already exists.")
if any(p.get("ip") == peer_ip for p in peers): if any(p.get("ip") == peer_ip for p in peers):
die(f"IP {peer_ip} is already assigned to another peer.") die(f"IP {peer_ip} is already assigned to another peer.")
# -- Generate keypair and read server public key --------------------------- # Generate keypair and read server public key =======================
print(f"Generating keypair for '{args.name}'...") print(f"Generating keypair for '{args.name}'...")
private_key, public_key = generate_keypair() private_key, public_key = generate_keypair()
srv_pub = server_pubkey(iface) srv_pub = server_pubkey(iface)
# -- Update config.json ------------------------------------------------------ # Update config.json ================================================
peers.append({ peers.append({
"name": args.name, "name": args.name,
"ip": peer_ip, "ip": peer_ip,
@ -209,7 +209,7 @@ def main():
save_config(data) save_config(data)
print(f"Added peer '{args.name}' to config.json.") print(f"Added peer '{args.name}' to config.json.")
# -- Write client conf ----------------------------------------------------- # Write client conf =================================================
conf_content = build_client_conf(vlan, peer_ip, private_key, srv_pub, args.split_tunnel) conf_content = build_client_conf(vlan, peer_ip, private_key, srv_pub, args.split_tunnel)
if args.output: if args.output:
out_path = Path(args.output) out_path = Path(args.output)

View file

@ -545,7 +545,7 @@ def main():
print(" This wizard installs required packages and optionally") print(" This wizard installs required packages and optionally")
print(" sets up the web dashboard and external HTTPS access.") print(" sets up the web dashboard and external HTTPS access.")
# -- Package manager ------------------------------------------- # Package manager ===================================================
pm = detect_pm() pm = detect_pm()
if pm is None: if pm is None:
print() print()
@ -557,11 +557,11 @@ def main():
print(f"\n Detected package manager: {pm}") print(f"\n Detected package manager: {pm}")
pm_ok = True pm_ok = True
# -- Core packages --------------------------------------------- # Core packages =====================================================
if pm_ok: if pm_ok:
install_core_packages(pm) install_core_packages(pm)
# -- Dashboard ------------------------------------------------- # Dashboard =========================================================
header("Dashboard (optional)") header("Dashboard (optional)")
print(" The Routlin Dashboard is a web UI for managing the router.") print(" The Routlin Dashboard is a web UI for managing the router.")
print(" It runs as a Docker container. Without it, config.json must") print(" It runs as a Docker container. Without it, config.json must")
@ -596,7 +596,7 @@ def main():
default="y" default="y"
) )
# -- Docker ---------------------------------------------------- # Docker ============================================================
header("Docker") header("Docker")
if pm_ok: if pm_ok:
install_docker(pm) install_docker(pm)
@ -605,15 +605,15 @@ def main():
else: else:
print(" Docker is already installed.") print(" Docker is already installed.")
# -- docker-compose.yml ---------------------------------------- # docker-compose.yml ================================================
setup_docker_compose(reuse_config=reuse_config) setup_docker_compose(reuse_config=reuse_config)
create_dotfiles() create_dotfiles()
# -- Dashboard timer ------------------------------------------- # Dashboard timer ===================================================
header("Dashboard Timer") header("Dashboard Timer")
install_dashboard_timer() install_dashboard_timer()
# -- External access ------------------------------------------- # External access ===================================================
header("External Access (optional)") header("External Access (optional)")
ext_domain = _external_access_domain() ext_domain = _external_access_domain()
if ext_domain: if ext_domain:
@ -643,7 +643,7 @@ def main():
print(next_step) print(next_step)
return return
# -- Caddy ----------------------------------------------------- # Caddy =============================================================
header("Caddy (HTTPS)") header("Caddy (HTTPS)")
if pm_ok: if pm_ok:
install_caddy(pm) install_caddy(pm)

View file

@ -331,11 +331,11 @@ def validate_config(data):
_vid = _derived_ids[i] _vid = _derived_ids[i]
vlan_ifaces.append(_lan if _vid == 1 else f"{_lan}.{_vid}") vlan_ifaces.append(_lan if _vid == 1 else f"{_lan}.{_vid}")
# -- upstream_dns block ---------------------------------------------------- # upstream_dns block ============================================
if not data.get("upstream_dns", {}).get("upstream_servers"): if not data.get("upstream_dns", {}).get("upstream_servers"):
errors.append("upstream_dns.upstream_servers is missing or empty.") errors.append("upstream_dns.upstream_servers is missing or empty.")
# -- WAN / LAN interfaces -------------------------------------------------- # WAN / LAN interfaces ==========================================
gen = data.get("network_interfaces", {}) gen = data.get("network_interfaces", {})
wan = gen.get("wan_interface", "") wan = gen.get("wan_interface", "")
lan = gen.get("lan_interface", "") lan = gen.get("lan_interface", "")
@ -357,7 +357,7 @@ def validate_config(data):
if wan == lan: if wan == lan:
errors.append(f"network_interfaces.wan_interface and network_interfaces.lan_interface must be different (both set to '{wan}').") errors.append(f"network_interfaces.wan_interface and network_interfaces.lan_interface must be different (both set to '{wan}').")
# -- Blocklist library ----------------------------------------------------- # Blocklist library =============================================
blocklists_by_name = {} blocklists_by_name = {}
for idx, bl in enumerate(data.get("dns_blocking", {}).get("blocklists", [])): for idx, bl in enumerate(data.get("dns_blocking", {}).get("blocklists", [])):
name = bl.get("name", "") name = bl.get("name", "")
@ -373,7 +373,7 @@ def validate_config(data):
else: else:
blocklists_by_name[name] = bl blocklists_by_name[name] = bl
# -- Per-VLAN validation --------------------------------------------------- # Per-VLAN validation ===========================================
vlan_networks = {} # iface -> IPv4Network (used for NAT section) vlan_networks = {} # iface -> IPv4Network (used for NAT section)
for i, (vlan, iface) in enumerate(zip(_all_vlans, vlan_ifaces)): for i, (vlan, iface) in enumerate(zip(_all_vlans, vlan_ifaces)):
@ -403,7 +403,7 @@ def validate_config(data):
errors.append(f"{label}: mdns_reflection must be false for WireGuard interfaces.") errors.append(f"{label}: mdns_reflection must be false for WireGuard interfaces.")
if is_wg(vlan): if is_wg(vlan):
# -- vpn_information ----------------------------------------------- # vpn_information =======================================
vpi = vlan.get("vpn_information") vpi = vlan.get("vpn_information")
if not isinstance(vpi, dict): if not isinstance(vpi, dict):
errors.append(f"{label}: vpn_information must be a plain object.") errors.append(f"{label}: vpn_information must be a plain object.")
@ -418,7 +418,7 @@ def validate_config(data):
else: else:
seen_listen_ports[lp] = name seen_listen_ports[lp] = name
# -- subnet/subnet_mask -------------------------------------------- # subnet/subnet_mask ====================================
for field in ("subnet", "subnet_mask"): for field in ("subnet", "subnet_mask"):
if not vlan.get(field): if not vlan.get(field):
errors.append(f"{label}: missing required field '{field}'.") errors.append(f"{label}: missing required field '{field}'.")
@ -430,7 +430,7 @@ def validate_config(data):
except ValueError as e: except ValueError as e:
errors.append(f"{label}: invalid subnet/subnet_mask: {e}") errors.append(f"{label}: invalid subnet/subnet_mask: {e}")
# -- server_identities --------------------------------------------- # server_identities =====================================
if not vlan.get("server_identities"): if not vlan.get("server_identities"):
errors.append(f"{label}: server_identities is empty or missing.") errors.append(f"{label}: server_identities is empty or missing.")
identity_ips = [] identity_ips = []
@ -449,7 +449,7 @@ def validate_config(data):
else: else:
identity_ips.append(ip_addr) identity_ips.append(ip_addr)
# -- vpn_information.explicit_overrides ---------------------------- # vpn_information.explicit_overrides ====================
eo = vpi.get("explicit_overrides", {}) if isinstance(vpi, dict) else {} eo = vpi.get("explicit_overrides", {}) if isinstance(vpi, dict) else {}
if not isinstance(eo, dict): if not isinstance(eo, dict):
errors.append(f"{label}: vpn_information.explicit_overrides must be a plain object.") errors.append(f"{label}: vpn_information.explicit_overrides must be a plain object.")
@ -476,7 +476,7 @@ def validate_config(data):
if domain_val and not domainname(domain_val): if domain_val and not domainname(domain_val):
errors.append(f"{label}: vpn_information.domain '{domain_val}' is not a valid domain name.") errors.append(f"{label}: vpn_information.domain '{domain_val}' is not a valid domain name.")
# -- peers --------------------------------------------------------- # peers =================================================
seen_peer_names = {} seen_peer_names = {}
seen_peer_ips = {} seen_peer_ips = {}
for pidx, peer in enumerate(vlan.get("peers", [])): for pidx, peer in enumerate(vlan.get("peers", [])):
@ -556,7 +556,7 @@ def validate_config(data):
if ip: if ip:
identity_ips.append(ip) identity_ips.append(ip)
# -- Validate explicit_overrides --------------------------------------- # Validate explicit_overrides ===============================
eo = d.get("explicit_overrides", {}) eo = d.get("explicit_overrides", {})
if not isinstance(eo, dict): if not isinstance(eo, dict):
errors.append(f"{label}: explicit_overrides must be a plain object.") errors.append(f"{label}: explicit_overrides must be a plain object.")
@ -642,7 +642,7 @@ def validate_config(data):
if bl_name not in blocklists_by_name: if bl_name not in blocklists_by_name:
errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.") errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.")
# -- NAT / firewall validation --------------------------------------------- # NAT / firewall validation =====================================
valid_protos = VALID_PROTOCOLS valid_protos = VALID_PROTOCOLS
known_interfaces = set(seen_interfaces.keys()) known_interfaces = set(seen_interfaces.keys())
@ -675,7 +675,7 @@ def validate_config(data):
if net: if net:
nat_check_ip_in_network(f"{label} redirect_to", r.get("redirect_to", ""), net) nat_check_ip_in_network(f"{label} redirect_to", r.get("redirect_to", ""), net)
# -- port_forwarding validation (top-level) -------------------------------- # port_forwarding validation (top-level) ========================
for idx, r in enumerate(data.get("port_forwarding", [])): for idx, r in enumerate(data.get("port_forwarding", [])):
desc = r.get("description", "?") desc = r.get("description", "?")
label = f"port_forwarding[{idx}] '{desc}'" label = f"port_forwarding[{idx}] '{desc}'"
@ -709,13 +709,13 @@ def validate_config(data):
if r.get("dst_port") is not None: if r.get("dst_port") is not None:
nat_check_port(f"{label} dst_port", r.get("dst_port")) nat_check_port(f"{label} dst_port", r.get("dst_port"))
# -- radius_default uniqueness check --------------------------------------- # radius_default uniqueness check ===============================
defaults = [v["name"] for v in data.get("vlans", []) if v.get("radius_default") is True] defaults = [v["name"] for v in data.get("vlans", []) if v.get("radius_default") is True]
if len(defaults) > 1: if len(defaults) > 1:
errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). " errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). "
f"Only one VLAN may be the RADIUS default.") f"Only one VLAN may be the RADIUS default.")
# -- RADIUS requires multiple VLANs ---------------------------------------- # RADIUS requires multiple VLANs ================================
non_wg_vlans = [v for v in data.get("vlans", []) if not is_wg(v)] non_wg_vlans = [v for v in data.get("vlans", []) if not is_wg(v)]
has_radius_clients = any( has_radius_clients = any(
r.get("radius_client") r.get("radius_client")
@ -728,7 +728,7 @@ def validate_config(data):
"Dynamic VLAN assignment requires at least two VLANs." "Dynamic VLAN assignment requires at least two VLANs."
) )
# -- host_overrides validation --------------------------------------------- # host_overrides validation =====================================
all_vlan_nets = list(vlan_networks.values()) all_vlan_nets = list(vlan_networks.values())
for idx, entry in enumerate(data.get("host_overrides", [])): for idx, entry in enumerate(data.get("host_overrides", [])):
lbl = f"host_overrides[{idx}] '{entry.get('host', '?')}'" lbl = f"host_overrides[{idx}] '{entry.get('host', '?')}'"
@ -744,7 +744,7 @@ def validate_config(data):
if all_vlan_nets and not any(ip_addr in net for net in all_vlan_nets): if all_vlan_nets and not any(ip_addr in net for net in all_vlan_nets):
errors.append(f"{lbl}: '{ip_str}' does not fall within any configured VLAN subnet.") errors.append(f"{lbl}: '{ip_str}' does not fall within any configured VLAN subnet.")
# -- banned_ips validation ------------------------------------------------- # banned_ips validation =========================================
for idx, entry in enumerate(data.get("banned_ips", [])): for idx, entry in enumerate(data.get("banned_ips", [])):
ip_val = entry.get("ip", "") ip_val = entry.get("ip", "")
lbl = f"banned_ips[{idx}] '{entry.get('description', '')}'" lbl = f"banned_ips[{idx}] '{entry.get('description', '')}'"