Development
This commit is contained in:
parent
ac0aa4de22
commit
91d8b950b7
5 changed files with 38 additions and 35 deletions
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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>')
|
||||||
return f'<a href="{action}" class="btn {e(cls)}">{text}</a>'
|
if action_raw:
|
||||||
|
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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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', '')}'"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue