Development
This commit is contained in:
parent
205d6889df
commit
58ab569e42
27 changed files with 2894 additions and 2605 deletions
|
|
@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, flash, send_file, abort
|
|||
from auth import require_level
|
||||
from config_utils import load_config, verify_config_hash, record_group, diff_fields, CONFIGS_DIR
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from config_utils import (
|
|||
collect_layout_tokens, load_datasource, CONFIGS_DIR, relative_time,
|
||||
)
|
||||
from factory import load_ddns, load_json, build_table, table_token_key, iter_table_items, PAGES_DIR
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
|
||||
DDNS_LOG_MAX = 50
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, flash, send_file
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash, queued_msg, CONFIGS_DIR
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
DNS_LOG_FILE = Path(CONFIGS_DIR) / 'dns-blocklists.log'
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash, queued_msg, queue_command
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ from flask import Blueprint, request, redirect, flash
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from pathlib import Path
|
|||
from flask import Blueprint, request, redirect, flash, send_file, abort, jsonify
|
||||
from auth import require_level
|
||||
from config_utils import CONFIGS_DIR, load_config, record_group, diff_fields
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from flask import Blueprint, make_response, redirect, flash, request
|
|||
from auth import require_level
|
||||
from config_utils import load_config, record_group, diff_fields, verify_config_hash, CONFIGS_DIR, WEB_APP_DISPLAY_NAME
|
||||
import sanitize
|
||||
import validation as validate
|
||||
import mod_validation as validate
|
||||
|
||||
_PAGE = Path(__file__).parent.name
|
||||
|
||||
|
|
|
|||
2562
routlin/core.py
2562
routlin/core.py
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,6 @@ Public API:
|
|||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import socket
|
||||
|
|
@ -20,66 +19,57 @@ import sys
|
|||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
from validation import derive_interface, is_wg
|
||||
import mod_shared as shared
|
||||
import mod_validation as validation
|
||||
|
||||
# ===================================================================
|
||||
# Constants (mirror core.py - no import to avoid circular dependency)
|
||||
# Constants
|
||||
# ===================================================================
|
||||
|
||||
PRODUCT_NAME = "routlin"
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
HEALTH_FILE = SCRIPT_DIR / ".health"
|
||||
CONFIG_FILE = SCRIPT_DIR / "config.json"
|
||||
BLOCKLIST_DIR = SCRIPT_DIR / "blocklists"
|
||||
DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}")
|
||||
LEASES_DIR = Path("/var/lib/misc")
|
||||
HEALTH_FILE = shared.SCRIPT_DIR / ".health"
|
||||
CONFIG_FILE = shared.SCRIPT_DIR / "config.json"
|
||||
BLOCKLIST_DIR = shared.SCRIPT_DIR / "blocklists"
|
||||
NETWORKD_DIR = Path("/etc/systemd/network")
|
||||
SYSTEMD_DIR = Path("/etc/systemd/system")
|
||||
WG_DIR = Path("/etc/wireguard")
|
||||
RESOLV_CONF = Path("/etc/resolv.conf")
|
||||
AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf")
|
||||
CHRONY_CONF_FILE = Path("/etc/chrony/chrony.conf")
|
||||
RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret"
|
||||
RADIUS_SECRET_FILE = shared.SCRIPT_DIR / ".radius-secret"
|
||||
RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf")
|
||||
RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users")
|
||||
BLIST_TIMER_NAME = f"{PRODUCT_NAME}-dns-blocklist-update"
|
||||
DASHB_TIMER_NAME = f"{PRODUCT_NAME}-dashboard-queue"
|
||||
HEALTH_TIMER_NAME = f"{PRODUCT_NAME}-health-check"
|
||||
MAINT_TIMER_NAME = f"{PRODUCT_NAME}-maintenance"
|
||||
DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue"
|
||||
NAT_SERVICE_NAME = f"{PRODUCT_NAME}-nat"
|
||||
BLIST_TIMER_NAME = f"{shared.PRODUCT_NAME}-dns-blocklist-update"
|
||||
DASHB_TIMER_NAME = f"{shared.PRODUCT_NAME}-dashboard-queue"
|
||||
HEALTH_TIMER_NAME = f"{shared.PRODUCT_NAME}-health-check"
|
||||
MAINT_TIMER_NAME = f"{shared.PRODUCT_NAME}-maintenance"
|
||||
DASHB_QUEUE_FILE = shared.SCRIPT_DIR / ".dashboard-queue"
|
||||
NAT_SERVICE_NAME = f"{shared.PRODUCT_NAME}-nat"
|
||||
BLOCKLIST_STALE_SECS = 36 * 3600
|
||||
DISK_WARN_PCT = 90
|
||||
DHCP_WARN_PCT = 90
|
||||
DNS_TIMEOUT_SECS = 2
|
||||
|
||||
# ===================================================================
|
||||
# Small helpers replicated from core.py (no import)
|
||||
# Small helpers
|
||||
# ===================================================================
|
||||
|
||||
def _vlan_service_name(vlan, iface):
|
||||
if is_wg(vlan):
|
||||
return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}-{iface}"
|
||||
return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}"
|
||||
|
||||
def _radius_enabled(data):
|
||||
def radius_enabled(data):
|
||||
return any(
|
||||
r.get("radius_client") is True
|
||||
for r in data.get("dhcp_reservations", [])
|
||||
)
|
||||
|
||||
def _avahi_enabled(data):
|
||||
def avahi_enabled(data):
|
||||
return any(
|
||||
v.get("mdns_reflection") is True
|
||||
for v in data.get("vlans", [])
|
||||
if not is_wg(v)
|
||||
if not validation.is_wg(v)
|
||||
)
|
||||
|
||||
def _avahi_interfaces(data):
|
||||
return [
|
||||
derive_interface(v, data)
|
||||
validation.derive_interface(v, data)
|
||||
for v in data.get("vlans", [])
|
||||
if v.get("mdns_reflection") is True and not is_wg(v)
|
||||
if v.get("mdns_reflection") is True and not validation.is_wg(v)
|
||||
]
|
||||
|
||||
def _combo_hash(names):
|
||||
|
|
@ -89,25 +79,16 @@ def _combo_hash(names):
|
|||
def _merged_path(h):
|
||||
return BLOCKLIST_DIR / f"merged-{h}.conf"
|
||||
|
||||
def _lowest_quartet_ip(vlan):
|
||||
ips = []
|
||||
for s in vlan.get("server_identities", []):
|
||||
try:
|
||||
ips.append(ipaddress.IPv4Address(s["ip"]))
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
return str(min(ips, key=lambda ip: ip.packed[-1])) if ips else None
|
||||
|
||||
def _gateway_ips(data):
|
||||
"""Return set of all gateway IPs across all VLANs."""
|
||||
gws = set()
|
||||
for vlan in data.get("vlans", []):
|
||||
ip = _lowest_quartet_ip(vlan)
|
||||
ip = shared.lowest_quartet_ip(vlan)
|
||||
if ip:
|
||||
gws.add(ip)
|
||||
return gws
|
||||
|
||||
def _iface_operstate(iface):
|
||||
def iface_operstate(iface):
|
||||
"""Read operstate from sysfs. Returns 'up', 'down', 'unknown', or None."""
|
||||
try:
|
||||
return Path(f"/sys/class/net/{iface}/operstate").read_text().strip()
|
||||
|
|
@ -125,13 +106,13 @@ def _sysctl_query(unit):
|
|||
# Result builders
|
||||
# ===================================================================
|
||||
|
||||
def _ok(id_, name, detail=""):
|
||||
def ok(id_, name, detail=""):
|
||||
r = {"id": id_, "name": name, "status": "ok"}
|
||||
if detail:
|
||||
r["detail"] = detail
|
||||
return r
|
||||
|
||||
def _problem(id_, name, severity, detail, suggestion=""):
|
||||
def problem(id_, name, severity, detail, suggestion=""):
|
||||
r = {"id": id_, "name": name, "status": "problem",
|
||||
"severity": severity, "detail": detail}
|
||||
if suggestion:
|
||||
|
|
@ -148,8 +129,8 @@ def check_services(data):
|
|||
|
||||
units = []
|
||||
for vlan in vlans:
|
||||
iface = derive_interface(vlan, data)
|
||||
name = _vlan_service_name(vlan, iface)
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
name = shared.vlan_service_name(vlan, iface)
|
||||
units.append({"id": name, "name": name,
|
||||
"expected_active": "active", "expected_enabled": "enabled",
|
||||
"severity": "error"})
|
||||
|
|
@ -184,15 +165,15 @@ def check_services(data):
|
|||
"expected_active": exp_ddns_active, "expected_enabled": exp_ddns_enabled,
|
||||
"severity": "warning"})
|
||||
|
||||
exp_fr_active = "active" if _radius_enabled(data) else "inactive"
|
||||
exp_fr_enabled = "enabled" if _radius_enabled(data) else "disabled"
|
||||
exp_fr_active = "active" if radius_enabled(data) else "inactive"
|
||||
exp_fr_enabled = "enabled" if radius_enabled(data) else "disabled"
|
||||
units.append({"id": "freeradius", "name": "freeradius",
|
||||
"expected_active": exp_fr_active,
|
||||
"expected_enabled": exp_fr_enabled,
|
||||
"severity": "error"})
|
||||
|
||||
exp_av_active = "active" if _avahi_enabled(data) else "inactive"
|
||||
exp_av_enabled = "enabled" if _avahi_enabled(data) else "disabled"
|
||||
exp_av_active = "active" if avahi_enabled(data) else "inactive"
|
||||
exp_av_enabled = "enabled" if avahi_enabled(data) else "disabled"
|
||||
units.append({"id": "avahi-daemon", "name": "avahi-daemon",
|
||||
"expected_active": exp_av_active,
|
||||
"expected_enabled": exp_av_enabled,
|
||||
|
|
@ -211,7 +192,7 @@ def check_services(data):
|
|||
exp_enabled = u["expected_enabled"]
|
||||
active_ok = active == exp_active
|
||||
enabled_ok = enabled == exp_enabled
|
||||
status = "ok" if (active_ok and enabled_ok) else "problem"
|
||||
svc_status = "ok" if (active_ok and enabled_ok) else "problem"
|
||||
results.append({
|
||||
"id": u["id"],
|
||||
"name": u["name"],
|
||||
|
|
@ -222,7 +203,7 @@ def check_services(data):
|
|||
"active_ok": active_ok,
|
||||
"enabled_ok": enabled_ok,
|
||||
"severity": u.get("severity", "error"),
|
||||
"status": status,
|
||||
"status": svc_status,
|
||||
})
|
||||
|
||||
return results
|
||||
|
|
@ -234,19 +215,19 @@ def check_services(data):
|
|||
def check_configurations(data):
|
||||
results = []
|
||||
vlans = data.get("vlans", [])
|
||||
non_wg = [v for v in vlans if not is_wg(v)]
|
||||
wg_vlans = [v for v in vlans if is_wg(v)]
|
||||
non_wg = [v for v in vlans if not validation.is_wg(v)]
|
||||
wg_vlans = [v for v in vlans if validation.is_wg(v)]
|
||||
def file_ok(id_, name, path, severity="error", suggestion=""):
|
||||
try:
|
||||
exists = path.exists()
|
||||
except PermissionError:
|
||||
return _problem(id_, name, "warning",
|
||||
f"{path}: permission denied — run with sudo for accurate status.")
|
||||
return problem(id_, name, "warning",
|
||||
f"{path}: permission denied - run with sudo for accurate status.")
|
||||
if not exists:
|
||||
return _problem(id_, name, severity,
|
||||
f"{path} does not exist.",
|
||||
suggestion or f"Run `sudo python3 core.py --apply` to create it.")
|
||||
return _ok(id_, name)
|
||||
return problem(id_, name, severity,
|
||||
f"{path} does not exist.",
|
||||
suggestion or f"Run `sudo python3 core.py --apply` to create it.")
|
||||
return ok(id_, name)
|
||||
|
||||
# --- nftables tables ---
|
||||
try:
|
||||
|
|
@ -255,25 +236,25 @@ def check_configurations(data):
|
|||
).stdout
|
||||
for tbl in ("ip routlin-nat", "ip routlin-filter"):
|
||||
if tbl in tables_out:
|
||||
results.append(_ok(f"nft_{tbl.replace(' ', '_')}",
|
||||
f"nftables table {tbl}"))
|
||||
results.append(ok(f"nft_{tbl.replace(' ', '_')}",
|
||||
f"nftables table {tbl}"))
|
||||
else:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
f"nft_{tbl.replace(' ', '_')}",
|
||||
f"nftables table {tbl}",
|
||||
"error",
|
||||
f"nftables table '{tbl}' is missing.",
|
||||
"Run `sudo python3 core.py --apply` to rebuild firewall rules."))
|
||||
except Exception:
|
||||
results.append(_problem("nft_tables", "nftables tables", "error",
|
||||
"Could not query nftables (nft not available or failed)."))
|
||||
results.append(problem("nft_tables", "nftables tables", "error",
|
||||
"Could not query nftables (nft not available or failed)."))
|
||||
|
||||
# --- Docker bridge rules ---
|
||||
try:
|
||||
bridges = [
|
||||
p.parent.name
|
||||
for p in Path("/sys/class/net").glob("*/bridge")
|
||||
if _iface_operstate(p.parent.name) == "up"
|
||||
if iface_operstate(p.parent.name) == "up"
|
||||
]
|
||||
if bridges:
|
||||
fwd_out = subprocess.run(
|
||||
|
|
@ -282,49 +263,48 @@ def check_configurations(data):
|
|||
).stdout
|
||||
missing = [b for b in bridges if b not in fwd_out]
|
||||
if missing:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"nft_docker_bridges", "nftables Docker bridge rules", "warning",
|
||||
f"Container bridge(s) {', '.join(missing)} have no nftables forward rules.",
|
||||
"Run `sudo python3 core.py --apply` to add the missing rules."))
|
||||
else:
|
||||
results.append(_ok("nft_docker_bridges", "nftables Docker bridge rules"))
|
||||
results.append(ok("nft_docker_bridges", "nftables Docker bridge rules"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# --- VLAN sub-interfaces ---
|
||||
for vlan in non_wg:
|
||||
iface = derive_interface(vlan, data)
|
||||
vid = vlan.get("vlan_id")
|
||||
state = _iface_operstate(iface)
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
state = iface_operstate(iface)
|
||||
id_ = f"iface_{vlan['name']}"
|
||||
name = f"interface {iface}"
|
||||
if state is None:
|
||||
results.append(_problem(id_, name, "error",
|
||||
f"Interface {iface} does not exist in /sys/class/net/.",
|
||||
"Run `sudo python3 core.py --apply` to configure network interfaces."))
|
||||
results.append(problem(id_, name, "error",
|
||||
f"Interface {iface} does not exist in /sys/class/net/.",
|
||||
"Run `sudo python3 core.py --apply` to configure network interfaces."))
|
||||
elif state != "up":
|
||||
results.append(_problem(id_, name, "error",
|
||||
f"Interface {iface} operstate is '{state}' (expected 'up').",
|
||||
"Check systemd-networkd: `sudo systemctl status systemd-networkd`"))
|
||||
results.append(problem(id_, name, "error",
|
||||
f"Interface {iface} operstate is '{state}' (expected 'up').",
|
||||
"Check systemd-networkd: `sudo systemctl status systemd-networkd`"))
|
||||
else:
|
||||
results.append(_ok(id_, name))
|
||||
results.append(ok(id_, name))
|
||||
|
||||
# --- WireGuard interfaces ---
|
||||
for vlan in wg_vlans:
|
||||
iface = derive_interface(vlan, data)
|
||||
state = _iface_operstate(iface)
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
state = iface_operstate(iface)
|
||||
id_ = f"iface_wg_{vlan['name']}"
|
||||
name = f"WireGuard interface {iface}"
|
||||
if state is None:
|
||||
results.append(_problem(id_, name, "error",
|
||||
f"WireGuard interface {iface} does not exist.",
|
||||
"Run `sudo python3 core.py --apply` to bring up WireGuard."))
|
||||
results.append(problem(id_, name, "error",
|
||||
f"WireGuard interface {iface} does not exist.",
|
||||
"Run `sudo python3 core.py --apply` to bring up WireGuard."))
|
||||
elif state in ("up", "unknown"): # WireGuard interfaces normally report 'unknown'
|
||||
results.append(_ok(id_, name))
|
||||
results.append(ok(id_, name))
|
||||
else:
|
||||
results.append(_problem(id_, name, "error",
|
||||
f"WireGuard interface {iface} operstate is '{state}'.",
|
||||
f"Try: sudo wg-quick up {iface}"))
|
||||
results.append(problem(id_, name, "error",
|
||||
f"WireGuard interface {iface} operstate is '{state}'.",
|
||||
f"Try: sudo wg-quick up {iface}"))
|
||||
|
||||
# --- Stale WG interfaces when no WG VLANs configured ---
|
||||
if not wg_vlans:
|
||||
|
|
@ -333,41 +313,41 @@ def check_configurations(data):
|
|||
if p.name.startswith("wg") and re.match(r"^wg\d+$", p.name)
|
||||
]
|
||||
if stale_wg:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"stale_wg_ifaces", "Stale WireGuard interfaces", "warning",
|
||||
f"WireGuard interface(s) {', '.join(stale_wg)} exist but no VPN VLANs are configured.",
|
||||
f"Bring them down manually: sudo wg-quick down {stale_wg[0]}"))
|
||||
|
||||
# --- dnsmasq config files ---
|
||||
for vlan in vlans:
|
||||
path = DNSMASQ_CONF_DIR / f"{vlan['name']}.conf"
|
||||
path = shared.DNSMASQ_CONF_DIR / f"{vlan['name']}.conf"
|
||||
results.append(file_ok(f"dnsmasq_conf_{vlan['name']}",
|
||||
f"dnsmasq config {path.name}", path))
|
||||
|
||||
# --- systemd-networkd files ---
|
||||
for vlan in non_wg:
|
||||
iface = derive_interface(vlan, data)
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
vid = vlan.get("vlan_id")
|
||||
net = NETWORKD_DIR / f"10-{PRODUCT_NAME}-{vlan['name']}.network"
|
||||
net = NETWORKD_DIR / f"10-{shared.PRODUCT_NAME}-{vlan['name']}.network"
|
||||
results.append(file_ok(f"networkd_net_{vlan['name']}",
|
||||
f"networkd {net.name}", net))
|
||||
if vid != 1: # non-physical VLANs have a .netdev too
|
||||
netdev = NETWORKD_DIR / f"10-{PRODUCT_NAME}-{vlan['name']}.netdev"
|
||||
netdev = NETWORKD_DIR / f"10-{shared.PRODUCT_NAME}-{vlan['name']}.netdev"
|
||||
results.append(file_ok(f"networkd_netdev_{vlan['name']}",
|
||||
f"networkd {netdev.name}", netdev))
|
||||
|
||||
# --- systemd unit files ---
|
||||
for path in (SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service",
|
||||
SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer",
|
||||
SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"):
|
||||
for path in (shared.SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service",
|
||||
shared.SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer",
|
||||
shared.SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"):
|
||||
results.append(file_ok(f"unit_{path.stem}", f"systemd unit {path.name}", path))
|
||||
|
||||
# --- WireGuard config and key files ---
|
||||
for vlan in wg_vlans:
|
||||
iface = derive_interface(vlan, data)
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
conf = WG_DIR / f"{iface}.conf"
|
||||
key = WG_DIR / f"{iface}.key"
|
||||
pub = SCRIPT_DIR / f".{iface}.pub"
|
||||
pub = shared.SCRIPT_DIR / f".{iface}.pub"
|
||||
results.append(file_ok(f"wg_conf_{iface}", f"WireGuard {conf.name}", conf))
|
||||
results.append(file_ok(f"wg_key_{iface}", f"WireGuard {key.name}", key))
|
||||
results.append(file_ok(f"wg_pubkey_{iface}", f"WireGuard {pub.name}", pub))
|
||||
|
|
@ -379,13 +359,13 @@ def check_configurations(data):
|
|||
if p.read_text().startswith("# Generated by")
|
||||
]
|
||||
if stale:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"stale_wg_conf", "Stale WireGuard config files", "warning",
|
||||
f"{', '.join(p.name for p in stale)} exist but no VPN VLANs are configured.",
|
||||
"Remove with: sudo rm " + " ".join(str(p) for p in stale)))
|
||||
|
||||
# --- RADIUS files and secret check ---
|
||||
if _radius_enabled(data):
|
||||
if radius_enabled(data):
|
||||
results.append(file_ok("radius_secret_file", ".radius-secret file",
|
||||
RADIUS_SECRET_FILE, "error"))
|
||||
results.append(file_ok("radius_clients_conf", "FreeRADIUS clients.conf",
|
||||
|
|
@ -403,9 +383,9 @@ def check_configurations(data):
|
|||
if "secret" in line and not line.strip().startswith("#")
|
||||
)
|
||||
if secret_ok:
|
||||
results.append(_ok("radius_secret_match", "FreeRADIUS shared secret"))
|
||||
results.append(ok("radius_secret_match", "FreeRADIUS shared secret"))
|
||||
else:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"radius_secret_match", "FreeRADIUS shared secret", "error",
|
||||
"clients.conf secret does not match .radius-secret. "
|
||||
"Access points will reject all authentication requests.",
|
||||
|
|
@ -418,7 +398,7 @@ def check_configurations(data):
|
|||
if RADIUS_CLIENTS_CONF.exists():
|
||||
try:
|
||||
if "# Generated by" in RADIUS_CLIENTS_CONF.read_text():
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"radius_conf_orphan", "FreeRADIUS config", "warning",
|
||||
"FreeRADIUS clients.conf contains routlin-generated content "
|
||||
"but RADIUS is not enabled.",
|
||||
|
|
@ -428,7 +408,7 @@ def check_configurations(data):
|
|||
pass
|
||||
|
||||
# --- Avahi config ---
|
||||
if _avahi_enabled(data):
|
||||
if avahi_enabled(data):
|
||||
results.append(file_ok("avahi_conf", "avahi-daemon.conf",
|
||||
AVAHI_CONF_FILE, "warning"))
|
||||
if AVAHI_CONF_FILE.exists():
|
||||
|
|
@ -441,14 +421,14 @@ def check_configurations(data):
|
|||
missing = expected_ifaces - actual_ifaces
|
||||
extra = actual_ifaces - expected_ifaces
|
||||
if missing or extra:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"avahi_ifaces", "avahi-daemon interface list", "warning",
|
||||
f"avahi-daemon.conf interface list does not match config "
|
||||
f"(missing: {missing or 'none'}, extra: {extra or 'none'}).",
|
||||
"Run `sudo python3 core.py --apply` to update."))
|
||||
else:
|
||||
results.append(_ok("avahi_ifaces",
|
||||
"avahi-daemon interface list"))
|
||||
results.append(ok("avahi_ifaces",
|
||||
"avahi-daemon interface list"))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
|
@ -462,17 +442,17 @@ def check_configurations(data):
|
|||
if line.startswith("nameserver") and len(line.split()) >= 2
|
||||
}
|
||||
if ns_ips & gateway_ips:
|
||||
results.append(_ok("resolv_conf", "/etc/resolv.conf"))
|
||||
results.append(ok("resolv_conf", "/etc/resolv.conf"))
|
||||
else:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"resolv_conf", "/etc/resolv.conf", "warning",
|
||||
f"/etc/resolv.conf nameserver(s) {ns_ips} do not include any VLAN gateway. "
|
||||
f"Expected one of: {gateway_ips}.",
|
||||
"Run `sudo python3 core.py --apply` to update /etc/resolv.conf."))
|
||||
except OSError:
|
||||
results.append(_problem("resolv_conf", "/etc/resolv.conf", "warning",
|
||||
"/etc/resolv.conf is not readable.",
|
||||
"Run `sudo python3 core.py --apply`."))
|
||||
results.append(problem("resolv_conf", "/etc/resolv.conf", "warning",
|
||||
"/etc/resolv.conf is not readable.",
|
||||
"Run `sudo python3 core.py --apply`."))
|
||||
|
||||
# --- chrony.conf ---
|
||||
if CHRONY_CONF_FILE.exists():
|
||||
|
|
@ -489,21 +469,19 @@ def check_configurations(data):
|
|||
except Exception:
|
||||
pass
|
||||
if missing_subnets:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"chrony_conf", "/etc/chrony/chrony.conf", "warning",
|
||||
f"chrony.conf is missing allow directives for: {', '.join(missing_subnets)}.",
|
||||
"Run `sudo python3 core.py --apply` to update chrony.conf."))
|
||||
else:
|
||||
results.append(_ok("chrony_conf", "/etc/chrony/chrony.conf"))
|
||||
results.append(ok("chrony_conf", "/etc/chrony/chrony.conf"))
|
||||
except OSError:
|
||||
results.append(_problem("chrony_conf", "/etc/chrony/chrony.conf", "warning",
|
||||
"/etc/chrony/chrony.conf is not readable."))
|
||||
results.append(problem("chrony_conf", "/etc/chrony/chrony.conf", "warning",
|
||||
"/etc/chrony/chrony.conf is not readable."))
|
||||
else:
|
||||
results.append(_problem("chrony_conf", "/etc/chrony/chrony.conf", "warning",
|
||||
"/etc/chrony/chrony.conf does not exist.",
|
||||
"Install chrony: sudo apt-get install chrony"))
|
||||
|
||||
# --- Stale WG conf when no WG VLANs (already handled above) ---
|
||||
results.append(problem("chrony_conf", "/etc/chrony/chrony.conf", "warning",
|
||||
"/etc/chrony/chrony.conf does not exist.",
|
||||
"Install chrony: sudo apt-get install chrony"))
|
||||
|
||||
# --- DHCP pool utilization ---
|
||||
for vlan in non_wg:
|
||||
|
|
@ -517,7 +495,7 @@ def check_configurations(data):
|
|||
- int(ipaddress.IPv4Address(start)) + 1)
|
||||
if pool_size <= 0:
|
||||
continue
|
||||
lease_file = LEASES_DIR / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.leases"
|
||||
lease_file = shared.LEASES_DIR / f"dnsmasq-{shared.PRODUCT_NAME}-{vlan['name']}.leases"
|
||||
if not lease_file.exists():
|
||||
continue
|
||||
leases = [
|
||||
|
|
@ -526,7 +504,7 @@ def check_configurations(data):
|
|||
]
|
||||
pct = len(leases) * 100 // pool_size
|
||||
if pct >= DHCP_WARN_PCT:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
f"dhcp_pool_{vlan['name']}",
|
||||
f"DHCP pool ({vlan['name']})", "warning",
|
||||
f"DHCP pool for VLAN '{vlan['name']}' is {pct}% full "
|
||||
|
|
@ -534,9 +512,9 @@ def check_configurations(data):
|
|||
"Expand the pool range in config.json or clean up stale leases "
|
||||
f"with: `sudo python3 core.py --reset-leases {vlan['name']}`"))
|
||||
else:
|
||||
results.append(_ok(f"dhcp_pool_{vlan['name']}",
|
||||
f"DHCP pool ({vlan['name']})",
|
||||
f"{pct}% used ({len(leases)}/{pool_size})"))
|
||||
results.append(ok(f"dhcp_pool_{vlan['name']}",
|
||||
f"DHCP pool ({vlan['name']})",
|
||||
f"{pct}% used ({len(leases)}/{pool_size})"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -550,35 +528,35 @@ def check_configurations(data):
|
|||
combos[_combo_hash(names)] = names
|
||||
now = datetime.now(timezone.utc).timestamp()
|
||||
for h, names in combos.items():
|
||||
path = _merged_path(h)
|
||||
path = _merged_path(h)
|
||||
label = ", ".join(names)
|
||||
if not path.exists():
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
f"blocklist_{h}", f"blocklist ({label})", "warning",
|
||||
f"Merged blocklist file for '{label}' does not exist.",
|
||||
"Run `sudo python3 dns-blocklists.py` to download blocklists."))
|
||||
elif now - path.stat().st_mtime > BLOCKLIST_STALE_SECS:
|
||||
age_h = int((now - path.stat().st_mtime) / 3600)
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
f"blocklist_{h}", f"blocklist ({label})", "warning",
|
||||
f"Merged blocklist for '{label}' is {age_h}h old (threshold 36h).",
|
||||
"Run `sudo python3 dns-blocklists.py` to refresh."))
|
||||
else:
|
||||
results.append(_ok(f"blocklist_{h}", f"blocklist ({label})"))
|
||||
results.append(ok(f"blocklist_{h}", f"blocklist ({label})"))
|
||||
|
||||
# --- Disk space ---
|
||||
try:
|
||||
usage = shutil.disk_usage("/")
|
||||
pct = usage.used * 100 // usage.total
|
||||
if pct >= DISK_WARN_PCT:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"disk_space", "Disk space", "warning",
|
||||
f"Root filesystem is {pct}% full "
|
||||
f"({usage.used // 1_073_741_824}G of {usage.total // 1_073_741_824}G used).",
|
||||
"Free up disk space to avoid service disruption."))
|
||||
else:
|
||||
results.append(_ok("disk_space", "Disk space",
|
||||
f"{pct}% used"))
|
||||
results.append(ok("disk_space", "Disk space",
|
||||
f"{pct}% used"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -592,12 +570,12 @@ def check_configurations(data):
|
|||
except OSError:
|
||||
unreachable.append(srv)
|
||||
if unreachable:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"upstream_dns", "Upstream DNS reachability", "warning",
|
||||
f"Upstream DNS server(s) unreachable on port 53: {', '.join(unreachable)}.",
|
||||
"Check WAN connectivity and upstream DNS server addresses in config.json."))
|
||||
elif servers:
|
||||
results.append(_ok("upstream_dns", "Upstream DNS reachability"))
|
||||
results.append(ok("upstream_dns", "Upstream DNS reachability"))
|
||||
|
||||
return results
|
||||
|
||||
|
|
@ -639,7 +617,7 @@ def check_logs(data):
|
|||
aps = sorted({m.group(1) for l in failures
|
||||
for m in ap_re.finditer(l)})
|
||||
ap_str = ", ".join(aps) if aps else f"{len(failures)} request(s)"
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"freeradius_auth_failures",
|
||||
"FreeRADIUS auth failures", "error",
|
||||
f"FreeRADIUS is rejecting requests from {ap_str} with "
|
||||
|
|
@ -647,20 +625,20 @@ def check_logs(data):
|
|||
"Restore .radius-secret from backup and run `sudo python3 core.py --apply`, "
|
||||
"or update the shared secret in your AP controller to match .radius-secret."))
|
||||
else:
|
||||
results.append(_ok("freeradius_auth_failures",
|
||||
"FreeRADIUS auth failures"))
|
||||
results.append(ok("freeradius_auth_failures",
|
||||
"FreeRADIUS auth failures"))
|
||||
|
||||
# High rejection rate (>50% of recent activity is failures)
|
||||
if recent and len(failures) > len(recent) * 0.5 and not failures:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"freeradius_high_reject_rate",
|
||||
"FreeRADIUS rejection rate", "warning",
|
||||
f"Over half of recent FreeRADIUS activity ({len(failures)}/{len(recent)}) "
|
||||
f"are auth failures.",
|
||||
"Investigate FreeRADIUS config and shared secrets."))
|
||||
elif recent:
|
||||
results.append(_ok("freeradius_high_reject_rate",
|
||||
"FreeRADIUS rejection rate"))
|
||||
results.append(ok("freeradius_high_reject_rate",
|
||||
"FreeRADIUS rejection rate"))
|
||||
|
||||
except OSError:
|
||||
pass
|
||||
|
|
@ -668,19 +646,19 @@ def check_logs(data):
|
|||
# --- dnsmasq errors ---
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["journalctl", f"-u", f"dnsmasq-{PRODUCT_NAME}-*",
|
||||
["journalctl", f"-u", f"dnsmasq-{shared.PRODUCT_NAME}-*",
|
||||
"--since", "-1h", "--priority=err", "--no-pager", "-q"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
err_lines = [l for l in r.stdout.splitlines() if l.strip()]
|
||||
if err_lines:
|
||||
results.append(_problem(
|
||||
results.append(problem(
|
||||
"dnsmasq_errors", "dnsmasq errors", "error",
|
||||
f"{len(err_lines)} dnsmasq error(s) in the last hour: "
|
||||
f"{err_lines[0][:120]}{'...' if len(err_lines) > 1 else ''}",
|
||||
"Check dnsmasq logs: `sudo journalctl -u 'dnsmasq-routlin-*' --since -1h`"))
|
||||
else:
|
||||
results.append(_ok("dnsmasq_errors", "dnsmasq errors"))
|
||||
results.append(ok("dnsmasq_errors", "dnsmasq errors"))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
|
@ -691,6 +669,7 @@ def check_logs(data):
|
|||
# ===================================================================
|
||||
|
||||
def _next_blocklist_update():
|
||||
"""Return the next scheduled trigger time for the blocklist timer as a string, or None if unavailable."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["systemctl", "status", f"{BLIST_TIMER_NAME}.timer", "--no-pager"],
|
||||
|
|
@ -781,8 +760,8 @@ def print_table(status):
|
|||
if problems:
|
||||
print(f"\n Problems {'=' * (col - 12)}")
|
||||
for p in problems:
|
||||
sev = p.get("severity", "error")
|
||||
tag = f"[{sev}]"
|
||||
sev = p.get("severity", "error")
|
||||
tag = f"[{sev}]"
|
||||
detail = p.get("detail", p.get("name", ""))
|
||||
print(f" {tag:<10} {detail}")
|
||||
tip = p.get("suggestion", "")
|
||||
|
|
|
|||
136
routlin/mod_avahi.py
Normal file
136
routlin/mod_avahi.py
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"""
|
||||
mod_avahi.py -- Avahi mDNS reflector configuration and management.
|
||||
|
||||
Patches avahi-daemon.conf with the correct interface list and manages
|
||||
the avahi-daemon service lifecycle.
|
||||
"""
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import mod_shared as shared
|
||||
import mod_validation as validation
|
||||
|
||||
AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# State helpers
|
||||
# ===================================================================
|
||||
|
||||
def avahi_enabled(data):
|
||||
"""Return True if at least one non-WireGuard VLAN has mdns_reflection enabled."""
|
||||
return any(v.get("mdns_reflection") is True
|
||||
for v in data.get("vlans", [])
|
||||
if not validation.is_wg(v))
|
||||
|
||||
def avahi_interfaces(data):
|
||||
"""Return list of interface names for VLANs with mdns_reflection enabled."""
|
||||
return [
|
||||
validation.derive_interface(v, data)
|
||||
for v in data.get("vlans", [])
|
||||
if v.get("mdns_reflection") is True and not validation.is_wg(v)
|
||||
]
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Config generation
|
||||
# ===================================================================
|
||||
|
||||
def build_avahi_conf(data):
|
||||
"""Patch avahi-daemon.conf directives needed for cross-VLAN mDNS reflection.
|
||||
Reads the existing file (default or previously patched) and modifies only
|
||||
the specific directives we need, leaving everything else untouched.
|
||||
Returns the patched config as a string. Caller is responsible for ensuring
|
||||
the file exists before calling (apply_avahi guards this).
|
||||
"""
|
||||
ifaces = avahi_interfaces(data)
|
||||
content = AVAHI_CONF_FILE.read_text()
|
||||
|
||||
def set_directive(text, directive, value):
|
||||
"""Enable and set a directive, whether it is commented out or already set."""
|
||||
pattern = re.compile(
|
||||
rf"^#?\s*{re.escape(directive)}\s*=.*$", re.MULTILINE
|
||||
)
|
||||
replacement = f"{directive}={value}"
|
||||
if pattern.search(text):
|
||||
return pattern.sub(replacement, text)
|
||||
return text + f"\n{replacement}\n"
|
||||
|
||||
content = set_directive(content, "use-ipv6", "no")
|
||||
content = set_directive(content, "disallow-other-stacks", "yes")
|
||||
content = set_directive(content, "allow-interfaces", ",".join(ifaces))
|
||||
content = set_directive(content, "enable-reflector", "yes")
|
||||
content = set_directive(content, "disable-publishing", "yes")
|
||||
|
||||
return content
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Apply
|
||||
# ===================================================================
|
||||
|
||||
def apply_avahi(data):
|
||||
"""Write avahi-daemon.conf and ensure service is running."""
|
||||
if not shutil.which("avahi-daemon"):
|
||||
print("avahi-daemon is not installed.")
|
||||
print(" -> Run: sudo python3 install.py")
|
||||
return
|
||||
|
||||
ifaces = avahi_interfaces(data)
|
||||
|
||||
if len(ifaces) < 2:
|
||||
print("mDNS reflection requires at least two VLANs in reflect_vlans. Skipping.")
|
||||
return
|
||||
|
||||
if not AVAHI_CONF_FILE.exists():
|
||||
print(f"WARNING: {AVAHI_CONF_FILE} not found. Run: sudo python3 install.py")
|
||||
return
|
||||
|
||||
content = build_avahi_conf(data)
|
||||
existing = AVAHI_CONF_FILE.read_text()
|
||||
changed = existing != content
|
||||
if changed:
|
||||
AVAHI_CONF_FILE.write_text(content)
|
||||
print(f"Written: {AVAHI_CONF_FILE}")
|
||||
print(f" Reflecting mDNS across: {', '.join(ifaces)}")
|
||||
else:
|
||||
print(f"Unchanged: {AVAHI_CONF_FILE}")
|
||||
|
||||
svc = "avahi-daemon"
|
||||
state = subprocess.run(
|
||||
["systemctl", "is-active", svc], capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
|
||||
if state == "active":
|
||||
if changed:
|
||||
result = subprocess.run(["systemctl", "restart", svc],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print("avahi-daemon restarted.")
|
||||
else:
|
||||
shared.service_warning("restart", "avahi-daemon", result.stderr)
|
||||
else:
|
||||
print("avahi-daemon: running, config unchanged.")
|
||||
else:
|
||||
subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True)
|
||||
result = subprocess.run(["systemctl", "start", svc],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print("avahi-daemon started.")
|
||||
else:
|
||||
shared.service_warning("start", "avahi-daemon", result.stderr)
|
||||
|
||||
def disable_avahi():
|
||||
"""Stop and disable avahi-daemon."""
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", "avahi-daemon"], capture_output=True, text=True
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
subprocess.run(["systemctl", "disable", "--now", "avahi-daemon"],
|
||||
capture_output=True, text=True)
|
||||
print("avahi-daemon stopped and disabled.")
|
||||
else:
|
||||
print("avahi-daemon: not running, skipping.")
|
||||
626
routlin/mod_dnsmasq.py
Normal file
626
routlin/mod_dnsmasq.py
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
"""
|
||||
mod_dnsmasq.py -- dnsmasq instance configuration, service management, and leases.
|
||||
|
||||
Handles blocklist merging, per-VLAN dnsmasq config and systemd service unit
|
||||
generation, applying/reloading instances, system service conflict resolution
|
||||
(systemd-resolved, dnsmasq, chrony, ufw), and DHCP lease display.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import subprocess
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import mod_shared as shared
|
||||
import mod_wireguard as wireguard
|
||||
import mod_validation as validation
|
||||
|
||||
BLOCKLIST_DIR = shared.SCRIPT_DIR / "blocklists"
|
||||
RESOLV_CONF = Path("/etc/resolv.conf")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Blocklist management
|
||||
# ===================================================================
|
||||
|
||||
def combo_hash(names):
|
||||
"""Return a stable 8-char hex hash for a list/set of blocklist names."""
|
||||
key = ",".join(sorted(names))
|
||||
return hashlib.sha256(key.encode()).hexdigest()[:8]
|
||||
|
||||
def merged_path(h):
|
||||
return BLOCKLIST_DIR / f"merged-{h}.conf"
|
||||
|
||||
def blocklists_available(data):
|
||||
"""Return True if at least one merged blocklist file exists on disk."""
|
||||
combos = set()
|
||||
for vlan in data.get("vlans", []):
|
||||
names = vlan.get("use_blocklists", [])
|
||||
if names:
|
||||
combos.add(combo_hash(names))
|
||||
return any(merged_path(h).exists() for h in combos)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Build per-VLAN dnsmasq config
|
||||
# ===================================================================
|
||||
|
||||
def _wan_has_ipv6(iface):
|
||||
"""Return True if the WAN interface has a non-link-local IPv6 address."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "-6", "addr", "show", iface, "scope", "global"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return bool(result.stdout.strip())
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def get_container_bridge_ips():
|
||||
"""Return {ifname: ip} for all active container bridge interfaces.
|
||||
Used to add listen-address directives to the physical VLAN's dnsmasq
|
||||
instance so containers can reach the local DNS resolver.
|
||||
Works universally for Docker, Podman, LXC, libvirt, etc.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "-j", "addr", "show", "type", "bridge"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return {}
|
||||
links = json.loads(result.stdout)
|
||||
out = {}
|
||||
for l in links:
|
||||
if l.get("operstate") != "UP":
|
||||
continue
|
||||
for addr in l.get("addr_info", []):
|
||||
if addr.get("family") == "inet":
|
||||
out[l["ifname"]] = addr["local"]
|
||||
break
|
||||
return out
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def build_vlan_dnsmasq_conf(vlan, data, iface):
|
||||
"""Generate the complete dnsmasq config for one VLAN instance."""
|
||||
dns_cfg = data.get("upstream_dns", {})
|
||||
general = data.get("network_interfaces", {})
|
||||
overrides = [o for o in data.get("host_overrides", []) if o.get("enabled") is True]
|
||||
name = vlan["name"]
|
||||
d = vlan.get("dhcp_information", {})
|
||||
opts = shared.resolve_vlan_options(vlan)
|
||||
gateway = opts["gateway"]
|
||||
|
||||
bl_names = vlan.get("use_blocklists", [])
|
||||
bl_file = None
|
||||
if bl_names:
|
||||
p = merged_path(combo_hash(bl_names))
|
||||
if p.exists():
|
||||
bl_file = p
|
||||
|
||||
L = [
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"# Edit config.json and re-run: sudo python3 core.py --apply",
|
||||
f"# VLAN: {name} (vlan_id={vlan.get('vlan_id')})",
|
||||
"",
|
||||
]
|
||||
|
||||
L.append(f"pid-file={shared.vlan_pid_file(vlan)}")
|
||||
if not validation.is_wg(vlan):
|
||||
L.append(f"dhcp-leasefile={shared.vlan_leases_file(vlan)}")
|
||||
L += [
|
||||
"except-interface=lo",
|
||||
"bind-interfaces",
|
||||
f"listen-address={gateway}",
|
||||
f"interface={iface}",
|
||||
]
|
||||
if shared.is_physical(vlan):
|
||||
bridge_ips = get_container_bridge_ips()
|
||||
for bridge, ip in bridge_ips.items():
|
||||
L += [f"interface={bridge}", f"listen-address={ip}"]
|
||||
L.append("")
|
||||
|
||||
if not validation.is_wg(vlan):
|
||||
dotted_mask = shared.prefix_to_dotted(vlan['subnet_mask'])
|
||||
L += [
|
||||
"# -- DHCP -----------------------------------------------------------",
|
||||
f"dhcp-range=set:{name},{d['dynamic_pool_start']},{d['dynamic_pool_end']},{dotted_mask},{d['lease_time']}",
|
||||
f"domain={d.get('domain', 'local')}",
|
||||
"",
|
||||
f"dhcp-option=tag:{name},option:router,{gateway}",
|
||||
f"dhcp-option=tag:{name},option:dns-server,{opts['dns_servers']}",
|
||||
f"dhcp-option=tag:{name},option:ntp-server,{opts['ntp_servers']}",
|
||||
"",
|
||||
]
|
||||
|
||||
identity_hosts = [s for s in vlan.get("server_identities", []) if s.get("hostname")]
|
||||
if identity_hosts:
|
||||
L.append("# -- Server identity hostnames ----------------------------------")
|
||||
for s in identity_hosts:
|
||||
L += [f"# {s['description']}", f"dhcp-host={s['ip']},{s['hostname']}", ""]
|
||||
|
||||
vlan_res = [r for r in data.get("dhcp_reservations", []) if r.get("vlan") == name]
|
||||
active_res = [r for r in vlan_res if r.get("enabled") is True]
|
||||
inactive_res = [r for r in vlan_res if r.get("enabled") is not True]
|
||||
|
||||
if active_res:
|
||||
L.append("# -- Reservations -----------------------------------------------")
|
||||
|
||||
# Group reservations sharing a static IP into single dhcp-host lines
|
||||
# (multiple MACs for the same device e.g. wired + WiFi interfaces)
|
||||
# Dynamic reservations are always emitted individually.
|
||||
seen_ips = {} # ip -> list of reservations
|
||||
ordered = [] # preserves insertion order for output
|
||||
for r in active_res:
|
||||
if validation.is_dynamic_ip(r):
|
||||
ordered.append([r]) # always individual
|
||||
else:
|
||||
ip = r.get("ip", "")
|
||||
if ip in seen_ips:
|
||||
seen_ips[ip].append(r)
|
||||
else:
|
||||
seen_ips[ip] = [r]
|
||||
ordered.append(seen_ips[ip])
|
||||
|
||||
for group in ordered:
|
||||
if len(group) == 1:
|
||||
r = group[0]
|
||||
h = r.get('hostname', '')
|
||||
L.append(f"# {r['description']}")
|
||||
if validation.is_dynamic_ip(r):
|
||||
L.append(f"dhcp-host=set:{name},{r['mac']},{h},{d['lease_time']}" if h else
|
||||
f"dhcp-host=set:{name},{r['mac']},{d['lease_time']}")
|
||||
else:
|
||||
L.append(f"dhcp-host=set:{name},{r['mac']},{r['ip']},{h},{d['lease_time']}" if h else
|
||||
f"dhcp-host=set:{name},{r['mac']},{r['ip']},{d['lease_time']}")
|
||||
else:
|
||||
# Multiple MACs share the same IP -- combine into one dhcp-host line
|
||||
descs = ", ".join(r['description'] for r in group)
|
||||
macs = ",".join(r['mac'] for r in group)
|
||||
ip = group[0]['ip']
|
||||
hostname = group[0].get('hostname', '')
|
||||
L.append(f"# {descs}")
|
||||
L.append(f"dhcp-host=set:{name},{macs},{ip},{hostname},{d['lease_time']}" if hostname else
|
||||
f"dhcp-host=set:{name},{macs},{ip},{d['lease_time']}")
|
||||
L.append("")
|
||||
|
||||
if inactive_res:
|
||||
L.append("# -- Skipped reservations (enabled: false) ----------------------")
|
||||
for r in inactive_res:
|
||||
L.append(f"# SKIPPED: {r['description']} ({r.get('mac', '?')} -> {r.get('ip', '?')})")
|
||||
L.append("")
|
||||
|
||||
L += [
|
||||
"# -- DNS ------------------------------------------------------------",
|
||||
"no-resolv",
|
||||
]
|
||||
if dns_cfg.get("strict_order"):
|
||||
L.append("strict-order")
|
||||
wan = data["network_interfaces"]["wan_interface"]
|
||||
wan_has_ipv6 = _wan_has_ipv6(wan)
|
||||
for srv in dns_cfg.get("upstream_servers", []):
|
||||
if ":" in srv and not wan_has_ipv6:
|
||||
continue # skip IPv6 upstream -- WAN has no IPv6 address
|
||||
L.append(f"server={srv}")
|
||||
L.append(f"cache-size={dns_cfg.get('cache_size', 1000)}")
|
||||
if vlan.get("dnsmasq_log_queries", False):
|
||||
L.append("log-queries")
|
||||
L.append("")
|
||||
|
||||
if overrides:
|
||||
L.append("# -- Host overrides -------------------------------------------------")
|
||||
for o in overrides:
|
||||
L += [f"# {o['description']}", f"address=/{o['host']}/{o['ip']}", ""]
|
||||
|
||||
if bl_file:
|
||||
L += [
|
||||
"# -- Blocklist ------------------------------------------------------",
|
||||
f"conf-file={bl_file}",
|
||||
"",
|
||||
]
|
||||
elif bl_names:
|
||||
L += ["# Blocklist not yet downloaded -- run: sudo python3 dns-blocklists.py", ""]
|
||||
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Build per-VLAN systemd service unit
|
||||
# ===================================================================
|
||||
|
||||
def build_vlan_service(vlan, iface):
|
||||
name = vlan["name"]
|
||||
conf = shared.vlan_conf_file(vlan)
|
||||
|
||||
if validation.is_wg(vlan):
|
||||
after = f"network-online.target wg-quick@{iface}.service"
|
||||
wants = "network-online.target"
|
||||
bindsto = f"wg-quick@{iface}.service"
|
||||
else:
|
||||
after = "network-online.target"
|
||||
wants = "network-online.target"
|
||||
bindsto = None
|
||||
|
||||
lines = [
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
f"Description=dnsmasq for VLAN {name}",
|
||||
f"After={after}",
|
||||
f"Wants={wants}",
|
||||
]
|
||||
if bindsto:
|
||||
lines.append(f"BindsTo={bindsto}")
|
||||
|
||||
lines += [
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=forking",
|
||||
f"PIDFile={shared.vlan_pid_file(vlan)}",
|
||||
f"ExecStart=/usr/sbin/dnsmasq --conf-file={conf}",
|
||||
"ExecReload=/bin/kill -HUP $MAINPID",
|
||||
"Restart=on-failure",
|
||||
"RestartSec=5s",
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=multi-user.target",
|
||||
"",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# System service helpers
|
||||
# ===================================================================
|
||||
|
||||
def ensure_resolv_conf(data):
|
||||
"""Ensure /etc/resolv.conf points to the physical VLAN gateway (vlan_id=1)."""
|
||||
physical = next((v for v in data["vlans"] if shared.is_physical(v)), None)
|
||||
if physical is None:
|
||||
return
|
||||
nameserver = shared.resolve_vlan_options(physical)["gateway"]
|
||||
wanted = f"nameserver {nameserver}\n"
|
||||
# A symlink (e.g. to systemd-resolved stub) must be replaced with a plain file.
|
||||
if RESOLV_CONF.is_symlink():
|
||||
RESOLV_CONF.unlink()
|
||||
print("Removed /etc/resolv.conf symlink (was pointing to systemd-resolved stub).")
|
||||
current = RESOLV_CONF.read_text() if RESOLV_CONF.exists() else ""
|
||||
if wanted in current:
|
||||
print(f"/etc/resolv.conf already points to {nameserver}. Good.")
|
||||
return
|
||||
RESOLV_CONF.write_text(wanted)
|
||||
print(f"Updated /etc/resolv.conf: nameserver {nameserver}")
|
||||
|
||||
|
||||
def disable_systemd_resolved():
|
||||
"""Stop and disable systemd-resolved if it is active."""
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", "systemd-resolved"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
subprocess.run(["systemctl", "disable", "--now", "systemd-resolved"],
|
||||
capture_output=True, text=True)
|
||||
print("Disabled systemd-resolved.")
|
||||
else:
|
||||
print("systemd-resolved is not active. Good.")
|
||||
|
||||
def disable_systemd_timesyncd():
|
||||
"""Stop and disable systemd-timesyncd if it is active."""
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", "systemd-timesyncd"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
subprocess.run(["systemctl", "disable", "--now", "systemd-timesyncd"],
|
||||
capture_output=True, text=True)
|
||||
print("Disabled systemd-timesyncd.")
|
||||
else:
|
||||
print("systemd-timesyncd is not active. Good.")
|
||||
|
||||
def ensure_chrony(data):
|
||||
"""Add VLAN allow directives to chrony.conf and start the service."""
|
||||
chrony_conf = Path("/etc/chrony/chrony.conf")
|
||||
if chrony_conf.exists():
|
||||
content = chrony_conf.read_text()
|
||||
subnets = []
|
||||
for v in data["vlans"]:
|
||||
subnets.append(str(shared.network_for(v)))
|
||||
added = []
|
||||
for subnet in subnets:
|
||||
line = f"allow {subnet}"
|
||||
if line not in content:
|
||||
content += f"\n{line}"
|
||||
added.append(subnet)
|
||||
if added:
|
||||
chrony_conf.write_text(content)
|
||||
print(f"Updated /etc/chrony/chrony.conf: added allow for {', '.join(added)}")
|
||||
else:
|
||||
print("chrony.conf already has required allow directives. Good.")
|
||||
|
||||
subprocess.run(["systemctl", "enable", "--now", "chrony"],
|
||||
capture_output=True, text=True)
|
||||
subprocess.run(["systemctl", "restart", "chrony"],
|
||||
capture_output=True, text=True)
|
||||
print("chrony enabled and running. Good.")
|
||||
|
||||
def disable_ufw():
|
||||
"""Disable ufw (without removing it) if it is installed."""
|
||||
if subprocess.run(["which", "ufw"], capture_output=True, text=True).returncode != 0:
|
||||
print("ufw is not installed. Good.")
|
||||
return
|
||||
status = subprocess.run(["ufw", "status"], capture_output=True, text=True)
|
||||
if "Status: active" in status.stdout:
|
||||
subprocess.run(["ufw", "disable"], capture_output=True, text=True)
|
||||
print("ufw rules cleared.")
|
||||
else:
|
||||
print("ufw is not active. Good.")
|
||||
svc = subprocess.run(["systemctl", "is-enabled", "ufw"],
|
||||
capture_output=True, text=True)
|
||||
if svc.stdout.strip() in ("enabled", "enabled-runtime"):
|
||||
subprocess.run(["systemctl", "disable", "ufw"], capture_output=True, text=True)
|
||||
print("Disabled ufw.service.")
|
||||
|
||||
def disable_system_dnsmasq(data):
|
||||
"""Stop and disable the system dnsmasq.service if it is enabled."""
|
||||
disable_systemd_resolved()
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-enabled", "dnsmasq"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.stdout.strip() in ("enabled", "enabled-runtime"):
|
||||
subprocess.run(["systemctl", "disable", "--now", "dnsmasq"],
|
||||
capture_output=True, text=True)
|
||||
print("Disabled system dnsmasq.service.")
|
||||
else:
|
||||
print("System dnsmasq.service is already disabled. Good.")
|
||||
ensure_resolv_conf(data)
|
||||
|
||||
def restore_ntp():
|
||||
"""Disable chrony and re-enable systemd-timesyncd for plain client NTP."""
|
||||
result = subprocess.run(
|
||||
["systemctl", "is-active", "chrony"], capture_output=True, text=True
|
||||
)
|
||||
if result.stdout.strip() == "active":
|
||||
subprocess.run(["systemctl", "disable", "--now", "chrony"],
|
||||
capture_output=True, text=True)
|
||||
print("Disabled chrony.")
|
||||
else:
|
||||
print("chrony is not active.")
|
||||
|
||||
result = subprocess.run(
|
||||
["systemctl", "cat", "systemd-timesyncd"], capture_output=True, text=True
|
||||
)
|
||||
if result.returncode == 0:
|
||||
subprocess.run(["systemctl", "enable", "--now", "systemd-timesyncd"],
|
||||
capture_output=True, text=True)
|
||||
print("Enabled systemd-timesyncd.")
|
||||
else:
|
||||
print("systemd-timesyncd is not available on this system.")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Apply dnsmasq instances
|
||||
# ===================================================================
|
||||
|
||||
def apply_dnsmasq_instances(data, dry_run=False, start_if_needed=True):
|
||||
"""Write per-VLAN dnsmasq configs and service units.
|
||||
|
||||
start_if_needed=True: enable and start all instances.
|
||||
start_if_needed=False (--apply): only restart instances already running;
|
||||
skip with a warning if not running.
|
||||
"""
|
||||
active_service_stems = {shared.vlan_service_name(vlan, validation.derive_interface(vlan, data)) for vlan in data["vlans"]}
|
||||
|
||||
if not dry_run:
|
||||
shared.DNSMASQ_CONF_DIR.mkdir(exist_ok=True)
|
||||
disable_system_dnsmasq(data)
|
||||
print()
|
||||
|
||||
for vlan in data["vlans"]:
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
if validation.is_wg(vlan) and not dry_run and not wireguard.wg_interface_up(iface):
|
||||
print(f"Skipped VLAN '{vlan['name']}': {iface} is not up. Run --apply again after WireGuard is up.")
|
||||
continue
|
||||
|
||||
conf_content = build_vlan_dnsmasq_conf(vlan, data, iface)
|
||||
svc_content = build_vlan_service(vlan, iface)
|
||||
conf_path = shared.vlan_conf_file(vlan)
|
||||
svc_path = shared.vlan_service_file(vlan, iface)
|
||||
|
||||
if dry_run:
|
||||
print(f"# -- {conf_path} (dry-run) --")
|
||||
print(conf_content)
|
||||
print(f"# -- {svc_path} (dry-run) --")
|
||||
print(svc_content)
|
||||
continue
|
||||
|
||||
conf_path.write_text(conf_content)
|
||||
print(f"Written: {conf_path}")
|
||||
|
||||
if not svc_path.exists() or svc_path.read_text() != svc_content:
|
||||
svc_path.write_text(svc_content)
|
||||
print(f"Written: {svc_path}")
|
||||
|
||||
if dry_run:
|
||||
return
|
||||
|
||||
print()
|
||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
||||
|
||||
# Remove stale service units (VLANs removed from config)
|
||||
for f in shared.SYSTEMD_DIR.glob(f"dnsmasq-{shared.PRODUCT_NAME}-*.service"):
|
||||
if f.stem not in active_service_stems:
|
||||
subprocess.run(["systemctl", "disable", "--now", f.stem],
|
||||
capture_output=True, text=True)
|
||||
f.unlink()
|
||||
n = f.stem.removeprefix(f"dnsmasq-{shared.PRODUCT_NAME}-")
|
||||
stale_conf = shared.DNSMASQ_CONF_DIR / f"{n}.conf"
|
||||
if stale_conf.exists():
|
||||
stale_conf.unlink()
|
||||
print(f"Removed stale VLAN: {f.stem}")
|
||||
|
||||
if start_if_needed:
|
||||
print("Starting dnsmasq instances...")
|
||||
for vlan in data["vlans"]:
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
if validation.is_wg(vlan) and not wireguard.wg_interface_up(iface):
|
||||
continue
|
||||
svc = shared.vlan_service_name(vlan, iface)
|
||||
subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True)
|
||||
result = subprocess.run(["systemctl", "restart", svc],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print(f" Started: {svc}")
|
||||
else:
|
||||
shared.service_warning("start", svc, result.stderr)
|
||||
else:
|
||||
print("Reloading dnsmasq instances...")
|
||||
for vlan in data["vlans"]:
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
if validation.is_wg(vlan) and not wireguard.wg_interface_up(iface):
|
||||
continue
|
||||
svc = shared.vlan_service_name(vlan, iface)
|
||||
state = subprocess.run(
|
||||
["systemctl", "is-active", svc],
|
||||
capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
if state == "active":
|
||||
result = subprocess.run(["systemctl", "restart", svc],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print(f" Restarted: {svc}")
|
||||
else:
|
||||
shared.service_warning("restart", svc, result.stderr)
|
||||
elif validation.is_wg(vlan):
|
||||
# WG interface is up but dnsmasq isn't running -- start it now
|
||||
subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True)
|
||||
result = subprocess.run(["systemctl", "start", svc],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print(f" Started: {svc}")
|
||||
else:
|
||||
shared.service_warning("start", svc, result.stderr)
|
||||
else:
|
||||
print(f" WARNING: {svc} is not running -- skipping (run --apply to start it)")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Leases
|
||||
# ===================================================================
|
||||
|
||||
def reset_leases(data, vlan_name=None):
|
||||
"""Stop dnsmasq instances, delete lease files, restart instances.
|
||||
If vlan_name is given, only reset that VLAN. Otherwise reset all.
|
||||
"""
|
||||
vlans = [v for v in data["vlans"] if not validation.is_wg(v)]
|
||||
if vlan_name:
|
||||
vlans = [v for v in vlans if v["name"] == vlan_name]
|
||||
if not vlans:
|
||||
valid = ', '.join(v['name'] for v in data['vlans'] if not validation.is_wg(v))
|
||||
return f"Unknown VLAN name '{vlan_name}'. Valid names: {valid}"
|
||||
|
||||
print(f"Resetting leases for: {', '.join(v['name'] for v in vlans)}")
|
||||
print()
|
||||
|
||||
# Stop
|
||||
for vlan in vlans:
|
||||
svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data))
|
||||
result = subprocess.run(["systemctl", "stop", svc],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print(f" Stopped: {svc}")
|
||||
else:
|
||||
print(f" WARNING: Could not stop {svc}: {result.stderr.strip()}")
|
||||
|
||||
# Delete lease files
|
||||
print()
|
||||
for vlan in vlans:
|
||||
lf = shared.vlan_leases_file(vlan)
|
||||
if lf.exists():
|
||||
lf.unlink()
|
||||
print(f" Deleted: {lf}")
|
||||
else:
|
||||
print(f" No lease file: {lf}")
|
||||
|
||||
# Restart
|
||||
print()
|
||||
for vlan in vlans:
|
||||
svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data))
|
||||
result = subprocess.run(["systemctl", "start", svc],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print(f" Started: {svc}")
|
||||
else:
|
||||
print(f" WARNING: Could not start {svc}: {result.stderr.strip()}")
|
||||
|
||||
print()
|
||||
print("Done. Devices will get fresh leases on their next DHCP request.")
|
||||
|
||||
|
||||
def show_leases(data):
|
||||
# Build MAC -> reservation lookup across all VLANs
|
||||
vlan_by_name = {v["name"]: v for v in data.get("vlans", [])}
|
||||
res_by_mac = {}
|
||||
for r in data.get("dhcp_reservations", []):
|
||||
if r.get("enabled") is True:
|
||||
mac = r.get("mac", "").lower().strip()
|
||||
if mac:
|
||||
res_by_mac[mac] = (r, vlan_by_name.get(r.get("vlan", ""), {}))
|
||||
|
||||
now = int(datetime.now().timestamp())
|
||||
any_leases = False
|
||||
|
||||
for vlan in data["vlans"]:
|
||||
if validation.is_wg(vlan):
|
||||
continue
|
||||
lf = shared.vlan_leases_file(vlan)
|
||||
if not lf.exists():
|
||||
continue
|
||||
lines = lf.read_text().strip().splitlines()
|
||||
if not lines:
|
||||
continue
|
||||
|
||||
if not any_leases:
|
||||
print(f"{'IP':<18} {'MAC':<20} {'HOSTNAME':<26} {'VLAN':<8} {'EXPIRES':<22} {'TIME LEFT':<18} {'TYPE':<8} {'DESCRIPTION'}")
|
||||
print("-" * 145)
|
||||
any_leases = True
|
||||
|
||||
for entry in lines:
|
||||
parts = entry.split()
|
||||
if len(parts) < 4:
|
||||
continue
|
||||
expire_ts = parts[0]
|
||||
mac = parts[1]
|
||||
ip = parts[2]
|
||||
hostname = parts[3] if parts[3] != "*" else "(unknown)"
|
||||
hostname = hostname[:26]
|
||||
|
||||
if expire_ts == "0":
|
||||
expires_str = "permanent"
|
||||
time_left = ""
|
||||
else:
|
||||
expire_int = int(expire_ts)
|
||||
seconds = expire_int - now
|
||||
if seconds <= 0:
|
||||
expires_str = "expired"
|
||||
time_left = ""
|
||||
else:
|
||||
hours, rem = divmod(seconds, 3600)
|
||||
mins, _ = divmod(rem, 60)
|
||||
expire_dt = datetime.fromtimestamp(expire_int)
|
||||
expires_str = expire_dt.strftime("%Y-%m-%d %H:%M:%S")
|
||||
time_left = f"{hours}h {mins}m"
|
||||
|
||||
match = res_by_mac.get(mac.lower())
|
||||
lease_type = "static" if (match and not validation.is_dynamic_ip(match[0])) else "dynamic"
|
||||
description = match[0].get("description", "") if match else ""
|
||||
print(f"{ip:<18} {mac:<20} {hostname:<26} {vlan['name']:<8} {expires_str:<22} {time_left:<18} {lease_type:<8} {description}")
|
||||
|
||||
if not any_leases:
|
||||
print("No active leases found.")
|
||||
206
routlin/mod_metrics.py
Normal file
206
routlin/mod_metrics.py
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
"""
|
||||
mod_metrics.py -- DNS metrics collection and display.
|
||||
|
||||
Sends SIGUSR1 to running dnsmasq instances, parses stats from journalctl,
|
||||
and accumulates lifetime totals in a JSON file.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import mod_shared as shared
|
||||
import mod_validation as validation
|
||||
|
||||
METRICS_FILE = shared.SCRIPT_DIR / ".dns-metrics"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Collect and store
|
||||
# ===================================================================
|
||||
|
||||
def collect_metrics(data):
|
||||
"""
|
||||
Send SIGUSR1 to each running dnsmasq instance and parse stats from
|
||||
journalctl. Returns a combined metrics dict, or None if unavailable.
|
||||
"""
|
||||
metrics = {
|
||||
"queries_forwarded": 0,
|
||||
"queries_answered_locally": 0,
|
||||
"queries_authoritative": 0,
|
||||
"cache_reused": 0,
|
||||
"tcp_hwm": 0,
|
||||
"tcp_max_allowed": 0,
|
||||
"pool_memory_max": 0,
|
||||
"dnssec_subqueries_hwm": 0,
|
||||
"dnssec_crypto_hwm": 0,
|
||||
"dnssec_sig_fails_hwm": 0,
|
||||
"servers": []
|
||||
}
|
||||
|
||||
any_running = False
|
||||
for vlan in data["vlans"]:
|
||||
svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data))
|
||||
result = subprocess.run(
|
||||
["systemctl", "kill", "--signal=SIGUSR1", svc],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
continue
|
||||
any_running = True
|
||||
|
||||
if not any_running:
|
||||
print("No dnsmasq instances are running.")
|
||||
return None
|
||||
|
||||
time.sleep(1)
|
||||
|
||||
server_map = {}
|
||||
for vlan in data["vlans"]:
|
||||
svc = shared.vlan_service_name(vlan, validation.derive_interface(vlan, data))
|
||||
result = subprocess.run(
|
||||
["journalctl", "-u", svc, "--since", "5 seconds ago",
|
||||
"--no-pager", "-o", "cat"],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
for line in result.stdout.splitlines():
|
||||
m = re.search(r"cache size \d+, (\d+)/\d+ cache insertions re-used", line)
|
||||
if m:
|
||||
metrics["cache_reused"] += int(m.group(1))
|
||||
|
||||
m = re.search(r"queries forwarded (\d+), queries answered locally (\d+)", line)
|
||||
if m:
|
||||
metrics["queries_forwarded"] += int(m.group(1))
|
||||
metrics["queries_answered_locally"] += int(m.group(2))
|
||||
|
||||
m = re.search(r"queries for authoritative zones (\d+)", line)
|
||||
if m:
|
||||
metrics["queries_authoritative"] += int(m.group(1))
|
||||
|
||||
m = re.search(r"highest since last SIGUSR1 (\d+), max allowed (\d+)", line)
|
||||
if m:
|
||||
metrics["tcp_hwm"] = max(metrics["tcp_hwm"], int(m.group(1)))
|
||||
metrics["tcp_max_allowed"] = max(metrics["tcp_max_allowed"], int(m.group(2)))
|
||||
|
||||
m = re.search(r"pool memory in use \d+, max (\d+)", line)
|
||||
if m:
|
||||
metrics["pool_memory_max"] = max(metrics["pool_memory_max"], int(m.group(1)))
|
||||
|
||||
m = re.search(
|
||||
r"server (\S+): queries sent (\d+), retried (\d+), failed (\d+), "
|
||||
r"nxdomain replies (\d+), avg\. latency (\d+)ms",
|
||||
line
|
||||
)
|
||||
if m:
|
||||
addr = m.group(1)
|
||||
if addr not in server_map:
|
||||
server_map[addr] = {
|
||||
"address": addr, "queries_sent": 0, "retried": 0,
|
||||
"failed": 0, "nxdomain": 0, "avg_latency_ms": 0
|
||||
}
|
||||
server_map[addr]["queries_sent"] += int(m.group(2))
|
||||
server_map[addr]["retried"] += int(m.group(3))
|
||||
server_map[addr]["failed"] += int(m.group(4))
|
||||
server_map[addr]["nxdomain"] += int(m.group(5))
|
||||
server_map[addr]["avg_latency_ms"] = int(m.group(6))
|
||||
|
||||
metrics["servers"] = list(server_map.values())
|
||||
return metrics
|
||||
|
||||
|
||||
def update_metrics_file(new_metrics):
|
||||
now_str = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
|
||||
if METRICS_FILE.exists():
|
||||
with open(METRICS_FILE) as f:
|
||||
stored = json.load(f)
|
||||
else:
|
||||
stored = {
|
||||
"metadata": {"first_recorded": now_str, "last_recorded": now_str, "total_updates": 0},
|
||||
"totals": {
|
||||
"queries_forwarded": 0, "queries_answered_locally": 0,
|
||||
"queries_authoritative": 0, "cache_reused": 0,
|
||||
"tcp_hwm": 0, "tcp_max_allowed": 0, "pool_memory_max": 0,
|
||||
"dnssec_subqueries_hwm": 0, "dnssec_crypto_hwm": 0,
|
||||
"dnssec_sig_fails_hwm": 0, "servers": []
|
||||
}
|
||||
}
|
||||
|
||||
t = stored["totals"]
|
||||
t["queries_forwarded"] += new_metrics["queries_forwarded"]
|
||||
t["queries_answered_locally"] += new_metrics["queries_answered_locally"]
|
||||
t["queries_authoritative"] += new_metrics["queries_authoritative"]
|
||||
t["cache_reused"] += new_metrics["cache_reused"]
|
||||
t["tcp_hwm"] = max(t["tcp_hwm"], new_metrics["tcp_hwm"])
|
||||
t["pool_memory_max"] = max(t["pool_memory_max"], new_metrics["pool_memory_max"])
|
||||
t["dnssec_subqueries_hwm"] = max(t["dnssec_subqueries_hwm"], new_metrics["dnssec_subqueries_hwm"])
|
||||
t["dnssec_crypto_hwm"] = max(t["dnssec_crypto_hwm"], new_metrics["dnssec_crypto_hwm"])
|
||||
t["dnssec_sig_fails_hwm"] = max(t["dnssec_sig_fails_hwm"], new_metrics["dnssec_sig_fails_hwm"])
|
||||
if new_metrics["tcp_max_allowed"]:
|
||||
t["tcp_max_allowed"] = new_metrics["tcp_max_allowed"]
|
||||
|
||||
existing = {s["address"]: s for s in t["servers"]}
|
||||
for srv in new_metrics["servers"]:
|
||||
addr = srv["address"]
|
||||
if addr in existing:
|
||||
existing[addr]["queries_sent"] += srv["queries_sent"]
|
||||
existing[addr]["retried"] += srv["retried"]
|
||||
existing[addr]["failed"] += srv["failed"]
|
||||
existing[addr]["nxdomain"] += srv["nxdomain"]
|
||||
existing[addr]["avg_latency_ms"] = srv["avg_latency_ms"]
|
||||
else:
|
||||
existing[addr] = srv.copy()
|
||||
t["servers"] = list(existing.values())
|
||||
|
||||
stored["metadata"]["last_recorded"] = now_str
|
||||
stored["metadata"]["total_updates"] += 1
|
||||
|
||||
with open(METRICS_FILE, "w") as f:
|
||||
json.dump(stored, f, indent=2)
|
||||
shared.chown_to_script_dir_owner(METRICS_FILE)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Display
|
||||
# ===================================================================
|
||||
|
||||
def show_metrics(data):
|
||||
new = collect_metrics(data)
|
||||
if new is None:
|
||||
return
|
||||
update_metrics_file(new)
|
||||
|
||||
with open(METRICS_FILE) as f:
|
||||
data_m = json.load(f)
|
||||
|
||||
m = data_m["metadata"]
|
||||
t = data_m["totals"]
|
||||
|
||||
print("DNS Metrics (lifetime totals across all VLAN instances)")
|
||||
print(f" First recorded : {m['first_recorded']}")
|
||||
print(f" Last recorded : {m['last_recorded']}")
|
||||
print(f" Total updates : {m['total_updates']}")
|
||||
print()
|
||||
print("Queries")
|
||||
print(f" Forwarded to upstream : {t['queries_forwarded']:,}")
|
||||
print(f" Answered from cache : {t['queries_answered_locally']:,}")
|
||||
print(f" Authoritative : {t['queries_authoritative']:,}")
|
||||
print(f" Cache reused : {t['cache_reused']:,}")
|
||||
print()
|
||||
print("TCP")
|
||||
print(f" Peak concurrent (HWM) : {t['tcp_hwm']}")
|
||||
print(f" Max allowed : {t['tcp_max_allowed']}")
|
||||
print()
|
||||
print(f"Pool memory peak : {t['pool_memory_max']} bytes")
|
||||
if t["servers"]:
|
||||
print()
|
||||
print("Upstream servers")
|
||||
for s in t["servers"]:
|
||||
print(f" {s['address']}")
|
||||
print(f" Sent : {s['queries_sent']:,}")
|
||||
print(f" Retried : {s['retried']:,}")
|
||||
print(f" Failed : {s['failed']:,}")
|
||||
print(f" NXDOMAIN : {s['nxdomain']:,}")
|
||||
print(f" Latency : {s['avg_latency_ms']}ms (last recorded)")
|
||||
149
routlin/mod_networkd.py
Normal file
149
routlin/mod_networkd.py
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
"""
|
||||
mod_networkd.py -- systemd-networkd configuration for VLAN interfaces.
|
||||
|
||||
Generates .netdev and .network files for each VLAN, removes legacy
|
||||
conflicting files, and triggers a networkd reload when content changes.
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import mod_shared as shared
|
||||
import mod_validation as validation
|
||||
|
||||
NETWORKD_DIR = Path("/etc/systemd/network")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Build
|
||||
# ===================================================================
|
||||
|
||||
def build_netdev(vlan, vid, iface):
|
||||
return "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"# Edit config.json and re-run: sudo python3 core.py --apply",
|
||||
"",
|
||||
"[NetDev]",
|
||||
f"Name={iface}",
|
||||
"Kind=vlan",
|
||||
"",
|
||||
"[VLAN]",
|
||||
f"Id={vid}",
|
||||
"",
|
||||
])
|
||||
|
||||
def build_network(vlan, vid, iface, all_vlan_ids):
|
||||
network = shared.network_for(vlan)
|
||||
prefix = network.prefixlen
|
||||
lines = [
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"# Edit config.json and re-run: sudo python3 core.py --apply",
|
||||
"",
|
||||
"[Match]",
|
||||
f"Name={iface}",
|
||||
"",
|
||||
"[Network]",
|
||||
"DHCP=no",
|
||||
"LinkLocalAddressing=no",
|
||||
]
|
||||
for ident in vlan["server_identities"]:
|
||||
lines.append(f"# {ident['description']}")
|
||||
lines.append(f"Address={ident['ip']}/{prefix}")
|
||||
|
||||
if shared.is_physical(vlan):
|
||||
lines.append("")
|
||||
for v in all_vlan_ids:
|
||||
if v != 1:
|
||||
lines.append(f"VLAN={iface}.{v}")
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Apply
|
||||
# ===================================================================
|
||||
|
||||
def find_legacy_files(managed_interfaces):
|
||||
to_remove = []
|
||||
for pattern in ("*.network", "*.netdev"):
|
||||
for f in NETWORKD_DIR.glob(pattern):
|
||||
if f.name.startswith(f"10-{shared.PRODUCT_NAME}-"):
|
||||
continue
|
||||
try:
|
||||
content = f.read_text()
|
||||
except OSError:
|
||||
continue
|
||||
for iface in managed_interfaces:
|
||||
if f"Name={iface}" in content:
|
||||
to_remove.append(f)
|
||||
break
|
||||
return to_remove
|
||||
|
||||
def apply_networkd(data, dry_run=False, only_if_changed=False):
|
||||
"""Write systemd-networkd files and reload.
|
||||
If only_if_changed=True, write files only when content differs from disk
|
||||
and skip the networkd reload if nothing changed. Used by --apply mode.
|
||||
"""
|
||||
all_vlan_ids = [v.get('vlan_id') for v in data["vlans"] if not validation.is_wg(v)]
|
||||
managed_ifaces = [validation.derive_interface(v, data) for v in data["vlans"]]
|
||||
changed = False
|
||||
|
||||
legacy = find_legacy_files(managed_ifaces)
|
||||
if legacy:
|
||||
print("Removing legacy networkd files:")
|
||||
for f in legacy:
|
||||
if not dry_run:
|
||||
f.unlink()
|
||||
changed = True
|
||||
print(f" {'[dry-run] would remove' if dry_run else 'Removed'}: {f}")
|
||||
print()
|
||||
|
||||
for vlan in data["vlans"]:
|
||||
if validation.is_wg(vlan):
|
||||
continue
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
vid = vlan.get('vlan_id')
|
||||
stem = shared.networkd_stem(vlan)
|
||||
|
||||
if not shared.is_physical(vlan):
|
||||
netdev_path = NETWORKD_DIR / f"{stem}.netdev"
|
||||
netdev_content = build_netdev(vlan, vid, iface)
|
||||
if dry_run:
|
||||
print(f"# -- {netdev_path} (dry-run) --")
|
||||
print(netdev_content)
|
||||
else:
|
||||
existing = netdev_path.read_text() if netdev_path.exists() else None
|
||||
if existing != netdev_content:
|
||||
netdev_path.write_text(netdev_content)
|
||||
print(f"Written: {netdev_path}")
|
||||
changed = True
|
||||
elif not only_if_changed:
|
||||
print(f"Unchanged: {netdev_path}")
|
||||
|
||||
network_path = NETWORKD_DIR / f"{stem}.network"
|
||||
network_content = build_network(vlan, vid, iface, all_vlan_ids)
|
||||
if dry_run:
|
||||
print(f"# -- {network_path} (dry-run) --")
|
||||
print(network_content)
|
||||
else:
|
||||
existing = network_path.read_text() if network_path.exists() else None
|
||||
if existing != network_content:
|
||||
network_path.write_text(network_content)
|
||||
print(f"Written: {network_path}")
|
||||
changed = True
|
||||
elif not only_if_changed:
|
||||
print(f"Unchanged: {network_path}")
|
||||
|
||||
if not dry_run:
|
||||
if changed:
|
||||
print("Reloading systemd-networkd...")
|
||||
result = subprocess.run(
|
||||
["networkctl", "reload"], capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"WARNING: networkctl reload returned non-zero:\n{result.stderr.strip()}")
|
||||
else:
|
||||
print("systemd-networkd reloaded.")
|
||||
elif only_if_changed:
|
||||
print("systemd-networkd: no changes. Good.")
|
||||
696
routlin/mod_nftables.py
Normal file
696
routlin/mod_nftables.py
Normal file
|
|
@ -0,0 +1,696 @@
|
|||
"""
|
||||
mod_nftables.py -- nftables config generation and management.
|
||||
|
||||
Generates and applies the routlin-nat and routlin-filter tables, manages the
|
||||
NAT boot service, and handles the banned_ips IP expansion logic.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import mod_avahi as avahi
|
||||
import mod_radius as radius
|
||||
import mod_shared as shared
|
||||
import mod_wireguard as wireguard
|
||||
import mod_validation as validation
|
||||
|
||||
NAT_SERVICE_NAME = f"{shared.PRODUCT_NAME}-nat"
|
||||
NAT_SERVICE_FILE = shared.SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service"
|
||||
|
||||
# ===================================================================
|
||||
# Rule list helpers
|
||||
# ===================================================================
|
||||
|
||||
def rule_enabled(rules):
|
||||
return [r for r in rules if r.get("enabled") is True]
|
||||
|
||||
def rule_disabled(rules):
|
||||
return [r for r in rules if r.get("enabled") is not True]
|
||||
|
||||
def expand_protocols(rule):
|
||||
"""Return list of (protocol, rule, comment_suffix) tuples.
|
||||
When protocol is 'both', expands into tcp and udp with suffixes
|
||||
' (tcp)' and ' (udp)' so generated comments are unambiguous.
|
||||
"""
|
||||
proto = rule["protocol"]
|
||||
if proto == "both":
|
||||
return [("tcp", rule, " (tcp)"), ("udp", rule, " (udp)")]
|
||||
return [(proto, rule, "")]
|
||||
|
||||
# ===================================================================
|
||||
# Container bridge detection
|
||||
# ===================================================================
|
||||
|
||||
def get_container_bridges():
|
||||
"""Return all active bridge interfaces.
|
||||
Works universally for Docker, Podman, LXC, libvirt, etc.
|
||||
"""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["ip", "-j", "link", "show", "type", "bridge"],
|
||||
capture_output=True, text=True, timeout=5
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return []
|
||||
links = json.loads(result.stdout)
|
||||
return [l["ifname"] for l in links if l.get("operstate") == "UP"]
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
# ===================================================================
|
||||
# banned_ips expansion
|
||||
# ===================================================================
|
||||
|
||||
def _expand_banned_ipv4(ip_str):
|
||||
"""Convert an IPv4 pattern (CIDR, wildcard, range) to nftables set elements."""
|
||||
if '/' in ip_str:
|
||||
ipaddress.IPv4Network(ip_str, strict=False) # validate
|
||||
return [ip_str]
|
||||
|
||||
parts = ip_str.split('.')
|
||||
if len(parts) != 4:
|
||||
raise ValueError(f"Invalid IPv4 pattern: {ip_str!r} - expected 4 octets")
|
||||
|
||||
def parse_octet(s, pos):
|
||||
if s == '*':
|
||||
return (0, 255)
|
||||
if '-' in s:
|
||||
a, b = s.split('-', 1)
|
||||
lo, hi = int(a), int(b)
|
||||
if not (0 <= lo <= hi <= 255):
|
||||
raise ValueError(f"Invalid octet range {s!r} in {ip_str!r}")
|
||||
return (lo, hi)
|
||||
v = int(s)
|
||||
if not 0 <= v <= 255:
|
||||
raise ValueError(f"Octet value {v} out of range in {ip_str!r}")
|
||||
return (v, v)
|
||||
|
||||
ranges = [parse_octet(p, i) for i, p in enumerate(parts)]
|
||||
|
||||
# Count trailing full-wildcard octets to determine CIDR suffix length
|
||||
trailing = 0
|
||||
for lo, hi in reversed(ranges):
|
||||
if lo == 0 and hi == 255:
|
||||
trailing += 1
|
||||
else:
|
||||
break
|
||||
|
||||
prefix_len = 32 - 8 * trailing
|
||||
prefix_ranges = ranges[:4 - trailing]
|
||||
|
||||
# Guard against combinatorial explosion
|
||||
total = 1
|
||||
for lo, hi in prefix_ranges:
|
||||
total *= (hi - lo + 1)
|
||||
if total > 1024:
|
||||
raise ValueError(
|
||||
f"Pattern {ip_str!r} would expand to {total} entries (limit 1024). "
|
||||
f"Use CIDR notation instead."
|
||||
)
|
||||
|
||||
results = []
|
||||
|
||||
if trailing > 0:
|
||||
def _enum_cidr(idx, chosen):
|
||||
if idx == len(prefix_ranges):
|
||||
base = '.'.join(str(v) for v in chosen) + '.0' * trailing
|
||||
if prefix_len == 32:
|
||||
results.append(base)
|
||||
else:
|
||||
results.append(f"{base}/{prefix_len}")
|
||||
return
|
||||
lo, hi = prefix_ranges[idx]
|
||||
for v in range(lo, hi + 1):
|
||||
_enum_cidr(idx + 1, chosen + [v])
|
||||
_enum_cidr(0, [])
|
||||
else:
|
||||
outer_ranges = ranges[:3]
|
||||
lo4, hi4 = ranges[3]
|
||||
|
||||
def _enum_range(idx, chosen):
|
||||
if idx == 3:
|
||||
base = '.'.join(str(v) for v in chosen)
|
||||
if lo4 == hi4:
|
||||
results.append(f"{base}.{lo4}")
|
||||
else:
|
||||
results.append(f"{base}.{lo4}-{base}.{hi4}")
|
||||
return
|
||||
lo, hi = outer_ranges[idx]
|
||||
for v in range(lo, hi + 1):
|
||||
_enum_range(idx + 1, chosen + [v])
|
||||
_enum_range(0, [])
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _expand_banned_ipv6(ip_str):
|
||||
"""Convert an IPv6 pattern (CIDR, single IP, or trailing-wildcard) to nftables set elements.
|
||||
|
||||
Supported formats:
|
||||
Single address : "2a01:4f8:c17:b0f::2" -- passed through as-is
|
||||
CIDR : "2a01:4f8::/32" -- passed through as-is
|
||||
Wildcard : "2a01:4f8:c17:*" -- prefix:* expands to a CIDR
|
||||
"2a01:4f8:c17:b00::*" -- :: compression is supported
|
||||
|
||||
Range notation (e.g. "b00-bff") is not supported for IPv6. Use CIDR instead.
|
||||
"""
|
||||
if '/' in ip_str:
|
||||
ipaddress.IPv6Network(ip_str, strict=False) # validate
|
||||
return [ip_str]
|
||||
|
||||
if '*' not in ip_str:
|
||||
ipaddress.IPv6Address(ip_str) # validate single address
|
||||
return [ip_str]
|
||||
|
||||
if not ip_str.endswith(':*'):
|
||||
raise ValueError(
|
||||
f"Unsupported IPv6 wildcard pattern {ip_str!r}. "
|
||||
f"Use 'prefix:*' (e.g. '2a01:4f8:c17:*') or CIDR notation. "
|
||||
f"Range notation (e.g. 'b00-bff') is not supported for IPv6."
|
||||
)
|
||||
|
||||
prefix_part = ip_str[:-2] # strip trailing ':*'
|
||||
|
||||
if '::' in prefix_part:
|
||||
left, right = prefix_part.split('::', 1)
|
||||
left_groups = [g for g in left.split(':') if g] if left else []
|
||||
right_groups = [g for g in right.split(':') if g] if right else []
|
||||
zero_count = 8 - len(left_groups) - len(right_groups) - 1
|
||||
if zero_count < 0:
|
||||
raise ValueError(f"IPv6 wildcard pattern {ip_str!r} has too many groups.")
|
||||
groups = left_groups + ['0000'] * zero_count + right_groups
|
||||
else:
|
||||
groups = [g for g in prefix_part.split(':') if g]
|
||||
|
||||
num_groups = len(groups)
|
||||
prefix_bits = num_groups * 16
|
||||
if num_groups < 1 or num_groups > 7:
|
||||
raise ValueError(
|
||||
f"IPv6 wildcard pattern {ip_str!r} must have between 1 and 7 "
|
||||
f"prefix groups before the wildcard."
|
||||
)
|
||||
base = ':'.join(groups) + ':' + ':'.join(['0000'] * (8 - num_groups))
|
||||
addr = ipaddress.IPv6Address(base)
|
||||
return [f"{addr}/{prefix_bits}"]
|
||||
|
||||
|
||||
def expand_banned_ip(ip_str):
|
||||
"""Return (family, [nftables_elements]) for a banned_ips entry.
|
||||
family is 'ipv4' or 'ipv6'."""
|
||||
if ':' in ip_str:
|
||||
return ('ipv6', _expand_banned_ipv6(ip_str))
|
||||
return ('ipv4', _expand_banned_ipv4(ip_str))
|
||||
|
||||
|
||||
def banned_ip_sets(data):
|
||||
"""Return (v4_elements, v6_elements) as flat lists of nftables set element strings."""
|
||||
v4, v6 = [], []
|
||||
for entry in rule_enabled(data.get("banned_ips", [])):
|
||||
family, elements = expand_banned_ip(entry["ip"])
|
||||
if family == 'ipv4':
|
||||
v4.extend(elements)
|
||||
else:
|
||||
v6.extend(elements)
|
||||
return v4, v6
|
||||
|
||||
# ===================================================================
|
||||
# nftables config generation
|
||||
# ===================================================================
|
||||
|
||||
def build_nft_config(data, dry_run=False):
|
||||
wan = data["network_interfaces"]["wan_interface"]
|
||||
vlans = [v for v in data["vlans"]
|
||||
if not validation.is_wg(v) or dry_run or wireguard.wg_interface_up(validation.derive_interface(v, data))]
|
||||
all_fwd = list(rule_enabled(data.get("port_forwarding", [])))
|
||||
wrngl_vlan_by_name = {v["name"]: v for v in vlans}
|
||||
all_wrngl = [(wrngl_vlan_by_name[r["vlan"]], r)
|
||||
for r in rule_enabled(data.get("port_wrangling", []))
|
||||
if r.get("vlan") in wrngl_vlan_by_name]
|
||||
active_ifaces = {validation.derive_interface(v, data) for v in vlans}
|
||||
|
||||
vlan_networks = {}
|
||||
for v in vlans:
|
||||
try:
|
||||
net = shared.network_for(v)
|
||||
vlan_networks[validation.derive_interface(v, data)] = net
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
all_except = rule_enabled(data.get("inter_vlan_exceptions", []))
|
||||
banned_v4, banned_v6 = banned_ip_sets(data)
|
||||
container_bridges = get_container_bridges()
|
||||
|
||||
L = [
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"# Edit config.json and re-run: sudo python3 core.py --apply",
|
||||
"",
|
||||
]
|
||||
|
||||
# ==========================================================================
|
||||
# routlin-nat table
|
||||
# ==========================================================================
|
||||
|
||||
L += [
|
||||
f"table ip {shared.PRODUCT_NAME}-nat {{",
|
||||
"",
|
||||
" chain prerouting {",
|
||||
" type nat hook prerouting priority dstnat - 10; policy accept;",
|
||||
"",
|
||||
]
|
||||
|
||||
if all_fwd:
|
||||
L += [" # -- Port forwarding (inbound WAN -> LAN host) ---------------", ""]
|
||||
for rule in all_fwd:
|
||||
for proto, r, suffix in expand_protocols(rule):
|
||||
L += [
|
||||
f" # {r['description']}{suffix}",
|
||||
f" iif \"{wan}\" {proto} dport {r['dest_port']} dnat to {r['nat_ip']}:{r['nat_port']}",
|
||||
"",
|
||||
]
|
||||
|
||||
if all_wrngl:
|
||||
L += [" # -- Port wrangling (redirect VLAN traffic to local host) ----", ""]
|
||||
for vlan, rule in all_wrngl:
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
for proto, r, suffix in expand_protocols(rule):
|
||||
L += [
|
||||
f" # {r['description']}{suffix}",
|
||||
f" iif \"{iface}\" {proto} dport {r['dest_port']} ip daddr != {r['redirect_to']} dnat to {r['redirect_to']}",
|
||||
"",
|
||||
]
|
||||
|
||||
L += [
|
||||
" }",
|
||||
"",
|
||||
" chain postrouting {",
|
||||
" type nat hook postrouting priority srcnat; policy accept;",
|
||||
"",
|
||||
" # Masquerade all outbound traffic through WAN",
|
||||
f" oif \"{wan}\" masquerade",
|
||||
"",
|
||||
" }",
|
||||
"",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
|
||||
# ==========================================================================
|
||||
# routlin-filter table
|
||||
# ==========================================================================
|
||||
|
||||
L += [f"table ip {shared.PRODUCT_NAME}-filter {{", ""]
|
||||
|
||||
if banned_v4:
|
||||
elements = ", ".join(banned_v4)
|
||||
L += [
|
||||
" set banned_ipv4 {",
|
||||
" type ipv4_addr",
|
||||
" flags interval",
|
||||
f" elements = {{ {elements} }}",
|
||||
" }",
|
||||
"",
|
||||
]
|
||||
|
||||
# INPUT chain
|
||||
L += [
|
||||
" # INPUT -- traffic destined for this machine itself",
|
||||
" chain input {",
|
||||
" type filter hook input priority filter; policy drop;",
|
||||
"",
|
||||
]
|
||||
if banned_v4:
|
||||
L += [
|
||||
" # Drop banned IPs on WAN inbound",
|
||||
f" iif \"{wan}\" ip saddr @banned_ipv4 drop",
|
||||
"",
|
||||
]
|
||||
L += [
|
||||
" # Allow loopback",
|
||||
" iif \"lo\" accept",
|
||||
"",
|
||||
" # Allow established/related return traffic",
|
||||
" ct state established,related accept",
|
||||
"",
|
||||
" # Allow ICMP (ping) from anywhere",
|
||||
" ip protocol icmp accept",
|
||||
"",
|
||||
]
|
||||
|
||||
if avahi.avahi_enabled(data):
|
||||
mdns_ifaces = avahi.avahi_interfaces(data)
|
||||
if mdns_ifaces:
|
||||
iface_set = ", ".join(f'"{i}"' for i in mdns_ifaces)
|
||||
L += [
|
||||
" # mDNS (port 5353) -- allow on reflection interfaces for avahi",
|
||||
f" iif {{ {iface_set} }} udp dport 5353 accept",
|
||||
"",
|
||||
]
|
||||
|
||||
# RADIUS -- must come BEFORE the broad VLAN accept rules below
|
||||
r_clients = radius.radius_clients(data)
|
||||
if r_clients:
|
||||
allowed_ips = ", ".join(r["ip"] for r, _ in r_clients)
|
||||
L += [
|
||||
" # RADIUS (port 1812) -- allow only designated authenticators",
|
||||
f" ip saddr {{ {allowed_ips} }} udp dport 1812 accept",
|
||||
" udp dport 1812 drop",
|
||||
"",
|
||||
]
|
||||
|
||||
if container_bridges:
|
||||
iface_set = ", ".join(f'"{b}"' for b in container_bridges)
|
||||
L += [
|
||||
" # Allow DNS from container bridge networks (Docker, Podman, etc.)",
|
||||
f" iif {{ {iface_set} }} meta l4proto {{ tcp, udp }} th dport 53 accept",
|
||||
"",
|
||||
]
|
||||
|
||||
L.append(" # Allow all traffic inbound from any VLAN interface")
|
||||
for vlan in vlans:
|
||||
L.append(f" iif \"{validation.derive_interface(vlan, data)}\" accept # {vlan['name']}")
|
||||
L.append("")
|
||||
|
||||
if all_fwd:
|
||||
L += [" # Allow inbound WAN access for port-forwarded services", ""]
|
||||
for rule in all_fwd:
|
||||
for proto, r, suffix in expand_protocols(rule):
|
||||
L += [
|
||||
f" # {r['description']}{suffix}",
|
||||
f" iif \"{wan}\" {proto} dport {r['dest_port']} accept",
|
||||
"",
|
||||
]
|
||||
|
||||
L += [" # Drop all other inbound WAN traffic", " }", ""]
|
||||
|
||||
# FORWARD chain
|
||||
L += [
|
||||
" # FORWARD -- traffic being routed through this machine",
|
||||
" chain forward {",
|
||||
" type filter hook forward priority filter; policy drop;",
|
||||
"",
|
||||
]
|
||||
if banned_v4:
|
||||
L += [
|
||||
" # Drop banned IPs on WAN inbound",
|
||||
f" iif \"{wan}\" ip saddr @banned_ipv4 drop",
|
||||
"",
|
||||
]
|
||||
L += [
|
||||
" # Allow established/related return traffic",
|
||||
" ct state established,related accept",
|
||||
"",
|
||||
]
|
||||
|
||||
L.append(" # Allow each VLAN -> WAN (outbound internet)")
|
||||
for vlan in vlans:
|
||||
L.append(f" iif \"{validation.derive_interface(vlan, data)}\" oif \"{wan}\" accept # {vlan['name']} -> WAN")
|
||||
L.append("")
|
||||
|
||||
if container_bridges:
|
||||
L.append(" # Allow VLAN -> Docker bridge forwarding")
|
||||
for vlan in vlans:
|
||||
for bridge in container_bridges:
|
||||
L.append(f" iif \"{validation.derive_interface(vlan, data)}\" oif \"{bridge}\" ct state new accept"
|
||||
f" # {vlan['name']} -> {bridge}")
|
||||
L.append("")
|
||||
|
||||
L += [
|
||||
" # Allow Docker containers -> WAN (outbound internet access)",
|
||||
f" iif != \"{wan}\" oif \"{wan}\" ct state new accept",
|
||||
"",
|
||||
]
|
||||
|
||||
if avahi.avahi_enabled(data):
|
||||
mdns_ifaces = avahi.avahi_interfaces(data)
|
||||
if len(mdns_ifaces) > 1:
|
||||
iface_set = ", ".join(f'"{i}"' for i in mdns_ifaces)
|
||||
L += [
|
||||
" # mDNS forwarding between reflection interfaces for avahi",
|
||||
f" iif {{ {iface_set} }} oif {{ {iface_set} }} udp dport 5353 accept",
|
||||
"",
|
||||
]
|
||||
|
||||
all_except = rule_enabled(data.get("inter_vlan_exceptions", []))
|
||||
if all_except:
|
||||
L += [" # -- Inter-VLAN exceptions ------------------------------------------", ""]
|
||||
for r in all_except:
|
||||
src = r["src_ip_or_subnet"]
|
||||
dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "")
|
||||
min_p = r.get("dest_port_start") or r.get("dst_port")
|
||||
max_p = r.get("dest_port_end")
|
||||
if min_p and max_p and str(min_p) != str(max_p):
|
||||
port_spec = f"{min_p}-{max_p}"
|
||||
elif min_p:
|
||||
port_spec = str(min_p)
|
||||
else:
|
||||
port_spec = None
|
||||
for proto, _, suffix in expand_protocols(r):
|
||||
L.append(f" # {r['description']}{suffix}")
|
||||
if port_spec is not None:
|
||||
L.append(f" ip saddr {src} ip daddr {dst} {proto} dport {port_spec} ct state new accept")
|
||||
else:
|
||||
L.append(f" ip saddr {src} ip daddr {dst} ip protocol {proto} ct state new accept")
|
||||
L.append("")
|
||||
|
||||
if all_fwd:
|
||||
L += [" # Allow inbound WAN -> VLAN for active port forwarding rules", ""]
|
||||
for rule in all_fwd:
|
||||
try:
|
||||
nat_addr = ipaddress.IPv4Address(rule["nat_ip"])
|
||||
iface = wan # fallback
|
||||
for iface_key, net in vlan_networks.items():
|
||||
if nat_addr in net:
|
||||
iface = iface_key
|
||||
break
|
||||
except ValueError:
|
||||
iface = wan
|
||||
for proto, r, suffix in expand_protocols(rule):
|
||||
L += [
|
||||
f" # {r['description']}{suffix}",
|
||||
f" iif \"{wan}\" oif \"{iface}\" {proto} dport {r['nat_port']} ip daddr {r['nat_ip']} ct state new accept",
|
||||
"",
|
||||
]
|
||||
|
||||
L += [
|
||||
" }",
|
||||
"",
|
||||
" chain output {",
|
||||
" type filter hook output priority filter; policy accept;",
|
||||
" }",
|
||||
"",
|
||||
"}",
|
||||
]
|
||||
|
||||
if banned_v6:
|
||||
elements = ", ".join(banned_v6)
|
||||
L += [
|
||||
"",
|
||||
f"table ip6 {shared.PRODUCT_NAME}-ban {{",
|
||||
"",
|
||||
" set banned_ipv6 {",
|
||||
" type ipv6_addr",
|
||||
" flags interval",
|
||||
f" elements = {{ {elements} }}",
|
||||
" }",
|
||||
"",
|
||||
" chain input {",
|
||||
" type filter hook input priority filter; policy accept;",
|
||||
f" iif \"{wan}\" ip6 saddr @banned_ipv6 drop",
|
||||
" }",
|
||||
"",
|
||||
" chain forward {",
|
||||
" type filter hook forward priority filter; policy accept;",
|
||||
f" iif \"{wan}\" ip6 saddr @banned_ipv6 drop",
|
||||
" }",
|
||||
"",
|
||||
"}",
|
||||
]
|
||||
|
||||
return "\n".join(L)
|
||||
|
||||
# ===================================================================
|
||||
# nftables apply / disable / status
|
||||
# ===================================================================
|
||||
|
||||
def table_exists(family, name):
|
||||
result = subprocess.run(
|
||||
["nft", "list", "table", family, name],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
def delete_our_tables():
|
||||
"""Delete all routlin-owned nftables tables. Returns error string on failure, None on success."""
|
||||
for family, table in [("ip", f"{shared.PRODUCT_NAME}-nat"),
|
||||
("ip", f"{shared.PRODUCT_NAME}-filter"),
|
||||
("ip6", f"{shared.PRODUCT_NAME}-ban")]:
|
||||
if table_exists(family, table):
|
||||
result = subprocess.run(
|
||||
["nft", "delete", "table", family, table],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return f"Failed to delete table {family} {table}: {result.stderr.strip()}"
|
||||
print(f"Removed existing table: {family} {table}")
|
||||
else:
|
||||
print(f"Table not present, skipping delete: {family} {table}")
|
||||
return None
|
||||
|
||||
def apply_nft_config(config_text):
|
||||
result = subprocess.run(
|
||||
["nft", "-f", "-"],
|
||||
input=config_text,
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("ERROR: nft rejected the ruleset:", file=sys.stderr)
|
||||
print(result.stderr, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def apply_nftables(data, dry_run=False):
|
||||
config = build_nft_config(data, dry_run=dry_run)
|
||||
if dry_run:
|
||||
print(config)
|
||||
return
|
||||
|
||||
active_ifaces = {validation.derive_interface(v, data) for v in data["vlans"]
|
||||
if not validation.is_wg(v) or wireguard.wg_interface_up(validation.derive_interface(v, data))}
|
||||
active_vlans = [v for v in data["vlans"] if validation.derive_interface(v, data) in active_ifaces]
|
||||
|
||||
all_fwd = list(rule_enabled(data.get("port_forwarding", [])))
|
||||
all_dis_fwd = list(rule_disabled(data.get("port_forwarding", [])))
|
||||
active_vlan_by_name = {v["name"]: v for v in active_vlans}
|
||||
all_wrngl = [(active_vlan_by_name[r["vlan"]], r)
|
||||
for r in rule_enabled(data.get("port_wrangling", []))
|
||||
if r.get("vlan") in active_vlan_by_name]
|
||||
all_dis_wrngl = rule_disabled(data.get("port_wrangling", []))
|
||||
all_except = rule_enabled(data.get("inter_vlan_exceptions", []))
|
||||
|
||||
print(f"Applying {len(all_fwd)} port forwarding rule(s), {len(all_dis_fwd)} skipped.")
|
||||
print(f"Applying {len(all_wrngl)} port wrangling rule(s), {len(all_dis_wrngl)} skipped.")
|
||||
print(f"Applying {len(all_except)} inter-VLAN exception(s).")
|
||||
container_bridges = get_container_bridges()
|
||||
if container_bridges:
|
||||
print(f"Container bridges: {', '.join(container_bridges)}")
|
||||
print()
|
||||
|
||||
delete_our_tables()
|
||||
apply_nft_config(config)
|
||||
print("nftables rules applied successfully.")
|
||||
|
||||
active_subnets = []
|
||||
for v in data["vlans"]:
|
||||
if validation.is_wg(v) and not wireguard.wg_interface_up(validation.derive_interface(v, data)):
|
||||
continue
|
||||
try:
|
||||
active_subnets.append(shared.network_for(v))
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
|
||||
def dst_is_active(r):
|
||||
dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "")
|
||||
try:
|
||||
addr = ipaddress.IPv4Address(dst)
|
||||
return any(addr in net for net in active_subnets)
|
||||
except ValueError:
|
||||
try:
|
||||
net = ipaddress.IPv4Network(dst, strict=False)
|
||||
return any(net.overlaps(s) for s in active_subnets)
|
||||
except ValueError:
|
||||
return True
|
||||
|
||||
if all_fwd:
|
||||
print()
|
||||
print("Active port forwarding:")
|
||||
for r in all_fwd:
|
||||
print(f" [{r['protocol'].upper():<4}] :{r['dest_port']} -> {r['nat_ip']}:{r['nat_port']} ({r['description']})")
|
||||
|
||||
if all_dis_fwd:
|
||||
print()
|
||||
print("Skipped port forwarding (disabled):")
|
||||
for r in all_dis_fwd:
|
||||
print(f" [{r['protocol'].upper():<4}] :{r['dest_port']} -> {r['nat_ip']}:{r['nat_port']} ({r['description']})")
|
||||
|
||||
if all_wrngl:
|
||||
print()
|
||||
print("Active port wrangling:")
|
||||
for vlan, r in all_wrngl:
|
||||
print(f" [{r['protocol'].upper():<4}] :{r['dest_port']} -> {r['redirect_to']} ({r['description']}) [{vlan['name']}]")
|
||||
|
||||
active_except = [r for r in all_except if dst_is_active(r)]
|
||||
if active_except:
|
||||
print()
|
||||
print("Active inter-VLAN exceptions:")
|
||||
for r in active_except:
|
||||
src = r["src_ip_or_subnet"]
|
||||
dst = r.get("dst_ip_or_subnet") or r.get("dst_ip", "")
|
||||
min_p = r.get("dest_port_start") or r.get("dst_port")
|
||||
max_p = r.get("dest_port_end")
|
||||
if min_p and max_p and str(min_p) != str(max_p):
|
||||
port_str = f":{min_p}-{max_p}"
|
||||
elif min_p:
|
||||
port_str = f":{min_p}"
|
||||
else:
|
||||
port_str = ""
|
||||
dst_str = f"{dst}{port_str}"
|
||||
print(f" [{r['protocol'].upper():<4}] {src} -> {dst_str} ({r['description']})")
|
||||
|
||||
def show_rules():
|
||||
for table in (f"{shared.PRODUCT_NAME}-nat", f"{shared.PRODUCT_NAME}-filter"):
|
||||
result = subprocess.run(
|
||||
["nft", "list", "table", "ip", table],
|
||||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print(f"[{table}] not found (not yet applied)")
|
||||
else:
|
||||
print(result.stdout)
|
||||
|
||||
# ===================================================================
|
||||
# NAT boot service
|
||||
# ===================================================================
|
||||
|
||||
def install_nat_service():
|
||||
script_path = shared.SCRIPT_DIR / "core.py"
|
||||
|
||||
service_content = f"""[Unit]
|
||||
Description=Apply {shared.PRODUCT_NAME} NAT and firewall rules
|
||||
After=network-online.target docker.service
|
||||
Wants=network-online.target docker.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/python3 {script_path} --apply
|
||||
RemainAfterExit=yes
|
||||
Restart=on-failure
|
||||
RestartSec=5s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
"""
|
||||
|
||||
existing = NAT_SERVICE_FILE.read_text() if NAT_SERVICE_FILE.exists() else None
|
||||
if existing == service_content:
|
||||
print(f"Boot service already up to date: {NAT_SERVICE_FILE}")
|
||||
return
|
||||
|
||||
NAT_SERVICE_FILE.write_text(service_content)
|
||||
subprocess.run(["systemctl", "daemon-reload"], check=True)
|
||||
subprocess.run(["systemctl", "enable", NAT_SERVICE_NAME], check=True)
|
||||
if existing is None:
|
||||
print(f"Boot service installed and enabled: {NAT_SERVICE_FILE}")
|
||||
else:
|
||||
print(f"Boot service updated: {NAT_SERVICE_FILE}")
|
||||
|
||||
def remove_nat_service():
|
||||
if NAT_SERVICE_FILE.exists():
|
||||
subprocess.run(["systemctl", "disable", "--now", NAT_SERVICE_NAME],
|
||||
capture_output=True, text=True)
|
||||
NAT_SERVICE_FILE.unlink()
|
||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
||||
print(f"Removed boot service: {NAT_SERVICE_NAME}.service")
|
||||
else:
|
||||
print(f"Boot service not found, skipping: {NAT_SERVICE_NAME}.service")
|
||||
321
routlin/mod_radius.py
Normal file
321
routlin/mod_radius.py
Normal file
|
|
@ -0,0 +1,321 @@
|
|||
"""
|
||||
mod_radius.py -- FreeRADIUS configuration management.
|
||||
|
||||
Generates clients.conf, users, and huntgroups files from config.json, and
|
||||
manages EAP settings (use_tunneled_reply, md5 block) in the freeradius EAP
|
||||
module config.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
import mod_shared as shared
|
||||
|
||||
RADIUS_SECRET_FILE = shared.SCRIPT_DIR / ".radius-secret"
|
||||
RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf")
|
||||
RADIUS_USERS_FILE = Path("/etc/freeradius/3.0/users")
|
||||
RADIUS_CONF_FILE = Path("/etc/freeradius/3.0/radiusd.conf")
|
||||
RADIUS_EAP_FILE = Path("/etc/freeradius/3.0/mods-available/eap")
|
||||
RADIUS_HUNTGROUPS = Path("/etc/freeradius/3.0/huntgroups")
|
||||
RADIUS_LOG_FILE = Path("/var/log/freeradius/radius.log")
|
||||
RADIUS_HUNTGROUP_NAME = "routlin-aps"
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Data helpers
|
||||
# ===================================================================
|
||||
|
||||
def radius_clients(data):
|
||||
"""Return list of (reservation, vlan) tuples where radius_client is True."""
|
||||
vlan_by_name = {v["name"]: v for v in data.get("vlans", [])}
|
||||
return [
|
||||
(r, vlan_by_name[r["vlan"]])
|
||||
for r in data.get("dhcp_reservations", [])
|
||||
if r.get("radius_client") is True and r.get("vlan") in vlan_by_name
|
||||
]
|
||||
|
||||
|
||||
def radius_enabled(data):
|
||||
"""Return True if any reservation has radius_client: true."""
|
||||
return len(radius_clients(data)) > 0
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Secret
|
||||
# ===================================================================
|
||||
|
||||
def ensure_radius_secret():
|
||||
"""Generate a random RADIUS shared secret if .radius-secret does not exist."""
|
||||
if RADIUS_SECRET_FILE.exists():
|
||||
return RADIUS_SECRET_FILE.read_text().strip()
|
||||
import secrets as _secrets
|
||||
secret = _secrets.token_urlsafe(32)
|
||||
RADIUS_SECRET_FILE.write_text(secret + "\n")
|
||||
RADIUS_SECRET_FILE.chmod(0o644)
|
||||
print(f"Generated RADIUS shared secret: {RADIUS_SECRET_FILE}")
|
||||
print(f" ACTION REQUIRED: enter this shared secret into your managed switch's RADIUS configuration:")
|
||||
print(f" {secret}")
|
||||
return secret
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Config file builders
|
||||
# ===================================================================
|
||||
|
||||
def build_radius_clients_conf(data, secret):
|
||||
"""Generate freeradius clients.conf from reservations with radius_client: true."""
|
||||
lines = [
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"# Edit config.json and re-run: sudo python3 core.py --apply",
|
||||
"",
|
||||
"# localhost (required)",
|
||||
"client localhost {",
|
||||
" ipaddr = 127.0.0.1",
|
||||
f" secret = {secret}",
|
||||
" shortname = localhost",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
for r, vlan in radius_clients(data):
|
||||
name = r.get("hostname") or r.get("description", "unknown").replace(" ", "-").lower()
|
||||
lines += [
|
||||
f"# {r['description']}",
|
||||
f"client {name} {{",
|
||||
f" ipaddr = {r['ip']}",
|
||||
f" secret = {secret}",
|
||||
f" shortname = {name}",
|
||||
"}",
|
||||
"",
|
||||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def fmt_mac(raw, fmt):
|
||||
c = raw.replace(':', '').replace('-', '').lower()
|
||||
pairs = [c[i:i+2] for i in range(0, 12, 2)]
|
||||
upper = fmt[0].isupper()
|
||||
if fmt in ('aabbccddeeff', 'AABBCCDDEEFF'):
|
||||
sep = ''
|
||||
elif fmt in ('aa-bb-cc-dd-ee-ff', 'AA-BB-CC-DD-EE-FF'):
|
||||
sep = '-'
|
||||
else:
|
||||
sep = ':'
|
||||
joined = sep.join(pairs)
|
||||
return joined.upper() if upper else joined
|
||||
|
||||
|
||||
def build_radius_users(data):
|
||||
"""
|
||||
Generate freeradius users file content.
|
||||
Each MAC reservation across all VLANs gets an entry mapping it to its VLAN ID.
|
||||
Unknown MACs fall through to DEFAULT which returns the radius_default VLAN.
|
||||
MAC format and DEFAULT rule scope are read from radius.options in config.
|
||||
Returns the file content as a string, or None if no VLAN has radius_default: true.
|
||||
"""
|
||||
default_vlan = next(
|
||||
(v for v in data["vlans"] if v.get("radius_default") is True), None
|
||||
)
|
||||
if default_vlan is None:
|
||||
return None
|
||||
|
||||
fr_opts = data.get('radius', {}).get('options', {})
|
||||
mac_fmt = fr_opts.get('mac_format', 'aabbccddeeff')
|
||||
apply_to = fr_opts.get('apply_to', 'all')
|
||||
|
||||
lines = [
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"# Edit config.json and re-run: sudo python3 core.py --apply",
|
||||
"",
|
||||
]
|
||||
|
||||
vlan_by_name = {v["name"]: v for v in data.get("vlans", [])}
|
||||
for r in data.get("dhcp_reservations", []):
|
||||
if r.get("enabled") is not True:
|
||||
continue
|
||||
raw_mac = r.get("mac", "")
|
||||
if not raw_mac:
|
||||
continue
|
||||
vlan = vlan_by_name.get(r.get("vlan", ""))
|
||||
if not vlan:
|
||||
continue
|
||||
mac = fmt_mac(raw_mac, mac_fmt)
|
||||
vlan_id = vlan.get('vlan_id')
|
||||
lines += [
|
||||
f"# {r['description']} -> VLAN {vlan_id} ({vlan['name']})",
|
||||
f"{mac} Cleartext-Password := \"{mac}\"",
|
||||
f" Tunnel-Type = VLAN,",
|
||||
f" Tunnel-Medium-Type = IEEE-802,",
|
||||
f" Tunnel-Private-Group-Id = \"{vlan_id}\"",
|
||||
"",
|
||||
]
|
||||
|
||||
default_id = default_vlan.get('vlan_id')
|
||||
ap_ips = fr_opts.get('ap_ips', [])
|
||||
if apply_to == 'wireless':
|
||||
default_check = "DEFAULT NAS-Port-Type = Wireless-802.11, Auth-Type := Accept"
|
||||
elif apply_to == 'huntgroup' and ap_ips:
|
||||
default_check = f'DEFAULT Huntgroup-Name == "{RADIUS_HUNTGROUP_NAME}", Auth-Type := Accept'
|
||||
else:
|
||||
default_check = "DEFAULT Auth-Type := Accept"
|
||||
lines += [
|
||||
f"# Default -- unknown MACs land on VLAN {default_id} ({default_vlan['name']})",
|
||||
default_check,
|
||||
f" Tunnel-Type = VLAN,",
|
||||
f" Tunnel-Medium-Type = IEEE-802,",
|
||||
f" Tunnel-Private-Group-Id = \"{default_id}\"",
|
||||
"",
|
||||
]
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# freeradius config file patching
|
||||
# ===================================================================
|
||||
|
||||
def set_freeradius_log(enabled):
|
||||
"""Enable or disable auth logging lines in radiusd.conf. Returns True if the file was modified."""
|
||||
if not RADIUS_CONF_FILE.exists():
|
||||
return False
|
||||
value = 'yes' if enabled else 'no'
|
||||
content = RADIUS_CONF_FILE.read_text()
|
||||
updated = re.sub(r'(?m)^(\s*auth\s*=\s*)(yes|no)', rf'\g<1>{value}', content)
|
||||
updated = re.sub(r'(?m)^(\s*auth_accept\s*=\s*)(yes|no)', rf'\g<1>{value}', updated)
|
||||
updated = re.sub(r'(?m)^(\s*auth_reject\s*=\s*)(yes|no)', rf'\g<1>{value}', updated)
|
||||
if updated == content:
|
||||
print(f"radiusd.conf: auth logging already {'enabled' if enabled else 'disabled'}.")
|
||||
return False
|
||||
RADIUS_CONF_FILE.write_text(updated)
|
||||
print(f"radiusd.conf: auth logging {'enabled' if enabled else 'disabled'}.")
|
||||
return True
|
||||
|
||||
|
||||
def write_huntgroups(data):
|
||||
"""Write the huntgroups file if apply_to=huntgroup and ap_ips are configured.
|
||||
Returns True if the file was written, False if not applicable or already up to date.
|
||||
"""
|
||||
opts = data.get('radius', {}).get('options', {})
|
||||
apply_to = opts.get('apply_to', 'all')
|
||||
ap_ips = opts.get('ap_ips', [])
|
||||
if apply_to != 'huntgroup' or not ap_ips:
|
||||
return False
|
||||
lines = [
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"# Edit config.json and re-run: sudo python3 core.py --apply",
|
||||
"",
|
||||
]
|
||||
for ip in ap_ips:
|
||||
lines.append(f"{RADIUS_HUNTGROUP_NAME} NAS-IP-Address == {ip}")
|
||||
content = "\n".join(lines) + "\n"
|
||||
existing = RADIUS_HUNTGROUPS.read_text() if RADIUS_HUNTGROUPS.exists() else None
|
||||
if existing == content:
|
||||
return False
|
||||
RADIUS_HUNTGROUPS.write_text(content)
|
||||
print(f"Written: {RADIUS_HUNTGROUPS}")
|
||||
return True
|
||||
|
||||
|
||||
def toggle_freeradius_block(content, block_name, enable):
|
||||
"""Comment out or uncomment a named brace block in a freeradius config file.
|
||||
|
||||
Finds the block by name (whether currently commented or not), locates its
|
||||
matching closing brace via depth counting, then comments or uncomments the
|
||||
entire range as a unit.
|
||||
"""
|
||||
lines = content.splitlines(keepends=True)
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
effective = re.sub(r'^(\s*)#', r'\1', lines[i])
|
||||
if re.match(r'\s*' + re.escape(block_name) + r'\s*\{', effective):
|
||||
depth = 0
|
||||
j = i
|
||||
while j < len(lines):
|
||||
eff = re.sub(r'^(\s*)#', r'\1', lines[j])
|
||||
depth += eff.count('{') - eff.count('}')
|
||||
if depth == 0:
|
||||
break
|
||||
j += 1
|
||||
for k in range(i, j + 1):
|
||||
line = lines[k]
|
||||
if enable:
|
||||
lines[k] = re.sub(r'^(\s*)#', r'\1', line, count=1)
|
||||
else:
|
||||
if line.strip() and not re.match(r'\s*#', line):
|
||||
lines[k] = re.sub(r'^(\s*)', r'\1#', line, count=1)
|
||||
return ''.join(lines)
|
||||
i += 1
|
||||
return content
|
||||
|
||||
|
||||
def set_freeradius_eap(data):
|
||||
"""Patch EAP config for tunneled_reply and allow_weak_eap settings.
|
||||
Returns True if the file was modified, False if unchanged or not found.
|
||||
"""
|
||||
if not RADIUS_EAP_FILE.exists():
|
||||
return False
|
||||
eap_cfg = data.get('radius', {}).get('eap', {})
|
||||
tunneled_reply = eap_cfg.get('tunneled_reply', False)
|
||||
allow_weak_eap = eap_cfg.get('allow_weak_eap', False)
|
||||
content = RADIUS_EAP_FILE.read_text()
|
||||
tr_val = 'yes' if tunneled_reply else 'no'
|
||||
content2 = re.sub(r'(?m)^(\s*use_tunneled_reply\s*=\s*)(yes|no)', rf'\g<1>{tr_val}', content)
|
||||
content3 = toggle_freeradius_block(content2, 'md5', allow_weak_eap)
|
||||
if content3 == content:
|
||||
return False
|
||||
RADIUS_EAP_FILE.write_text(content3)
|
||||
print(f"EAP: tunneled_reply={tr_val}, allow_weak_eap={allow_weak_eap}")
|
||||
return True
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Apply
|
||||
# ===================================================================
|
||||
|
||||
def apply_radius(data):
|
||||
"""Write FreeRADIUS config files and restart the service.
|
||||
Returns error string on failure, None on success.
|
||||
"""
|
||||
secret = ensure_radius_secret()
|
||||
|
||||
clients_content = build_radius_clients_conf(data, secret)
|
||||
users_content = build_radius_users(data)
|
||||
if users_content is None:
|
||||
return "No VLAN has radius_default: true. Cannot generate RADIUS users file."
|
||||
|
||||
logging = data.get('radius', {}).get('general', {}).get('logging', False)
|
||||
|
||||
changed = set_freeradius_log(logging)
|
||||
changed |= write_huntgroups(data)
|
||||
changed |= set_freeradius_eap(data)
|
||||
for path, content in [(RADIUS_CLIENTS_CONF, clients_content),
|
||||
(RADIUS_USERS_FILE, users_content)]:
|
||||
existing = path.read_text() if path.exists() else None
|
||||
if existing != content:
|
||||
path.write_text(content)
|
||||
print(f"Written: {path}")
|
||||
changed = True
|
||||
else:
|
||||
print(f"Unchanged: {path}")
|
||||
|
||||
svc = "freeradius"
|
||||
state = subprocess.run(
|
||||
["systemctl", "is-active", svc], capture_output=True, text=True
|
||||
).stdout.strip()
|
||||
if state == "active":
|
||||
if changed:
|
||||
result = subprocess.run(["systemctl", "restart", svc],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print("freeradius restarted.")
|
||||
else:
|
||||
shared.service_warning("restart", "freeradius", result.stderr)
|
||||
else:
|
||||
print("freeradius: running, config unchanged.")
|
||||
else:
|
||||
subprocess.run(["systemctl", "enable", svc], capture_output=True, text=True)
|
||||
result = subprocess.run(["systemctl", "start", svc],
|
||||
capture_output=True, text=True)
|
||||
if result.returncode == 0:
|
||||
print("freeradius started.")
|
||||
else:
|
||||
shared.service_warning("start", "freeradius", result.stderr)
|
||||
137
routlin/mod_shared.py
Normal file
137
routlin/mod_shared.py
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
"""
|
||||
mod_shared.py -- Constants and helpers shared across all routlin modules.
|
||||
"""
|
||||
|
||||
import ipaddress
|
||||
import os
|
||||
from pathlib import Path
|
||||
import mod_validation as validation
|
||||
|
||||
PRODUCT_NAME = "routlin"
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
DNSMASQ_CONF_DIR = Path(f"/etc/dnsmasq-{PRODUCT_NAME}")
|
||||
LEASES_DIR = Path("/var/lib/misc")
|
||||
SYSTEMD_DIR = Path("/etc/systemd/system")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Process / error helpers
|
||||
# ===================================================================
|
||||
|
||||
def is_root():
|
||||
return os.geteuid() == 0
|
||||
|
||||
|
||||
def service_warning(action, svc, stderr):
|
||||
msg = stderr.strip()
|
||||
print(f"WARNING: Failed to {action} {svc}: {msg}")
|
||||
if "not found" in msg.lower() or "not-found" in msg.lower():
|
||||
print(f" -> Package may not be installed. Run: sudo python3 install.py")
|
||||
|
||||
|
||||
def chown_to_script_dir_owner(path):
|
||||
try:
|
||||
stat = SCRIPT_DIR.stat()
|
||||
os.chown(path, stat.st_uid, stat.st_gid)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Network / VLAN utilities
|
||||
# ===================================================================
|
||||
|
||||
def prefix_to_dotted(n):
|
||||
mask = (0xFFFFFFFF << (32 - int(n))) & 0xFFFFFFFF
|
||||
return '.'.join(str((mask >> (8 * i)) & 0xFF) for i in (3, 2, 1, 0))
|
||||
|
||||
|
||||
def network_for(vlan):
|
||||
return ipaddress.IPv4Network(f"{vlan['subnet']}/{vlan['subnet_mask']}", strict=False)
|
||||
|
||||
|
||||
def lowest_quartet_ip(vlan):
|
||||
"""Return the server_identity IP with the lowest last-octet value, or None if there are no valid identities."""
|
||||
identities = vlan.get("server_identities", [])
|
||||
ips = []
|
||||
for s in identities:
|
||||
try:
|
||||
ips.append(ipaddress.IPv4Address(s["ip"]))
|
||||
except (KeyError, ValueError):
|
||||
pass
|
||||
if not ips:
|
||||
return None
|
||||
return str(min(ips, key=lambda ip: ip.packed[-1]))
|
||||
|
||||
|
||||
def resolve_vlan_options(vlan):
|
||||
"""
|
||||
Resolve gateway, dns_server, and ntp_server for a VLAN.
|
||||
|
||||
For both WG and non-WG VLANs: gateway defaults to the lowest-last-octet
|
||||
server_identity IP unless overridden in explicit_overrides. The gateway
|
||||
override must be one of the server_identity IPs.
|
||||
|
||||
WG VLANs: ntp_server is None (WireGuard has no DHCP so NTP cannot be
|
||||
advertised to peers). Overrides live in vpn_information.explicit_overrides.
|
||||
|
||||
Non-WG VLANs: overrides live in dhcp_information.explicit_overrides.
|
||||
Returns a dict with keys: gateway, dns_server, ntp_server.
|
||||
"""
|
||||
if validation.is_wg(vlan):
|
||||
vpi = vlan["vpn_information"]
|
||||
overrides = vpi.get("explicit_overrides", {})
|
||||
default = lowest_quartet_ip(vlan) or str(next(network_for(vlan).hosts()))
|
||||
gateway = overrides.get("gateway", "") or default
|
||||
dns = overrides.get("dns_servers", "") or gateway
|
||||
return {
|
||||
"gateway": gateway,
|
||||
"dns_servers": dns,
|
||||
"ntp_servers": None,
|
||||
}
|
||||
overrides = vlan.get("dhcp_information", {}).get("explicit_overrides", {})
|
||||
default = lowest_quartet_ip(vlan)
|
||||
def _resolve(key):
|
||||
v = overrides.get(key, "")
|
||||
if isinstance(v, list):
|
||||
return ','.join(v) if v else default
|
||||
return v or default
|
||||
return {
|
||||
"gateway": overrides.get("gateway", "") or default,
|
||||
"dns_servers": _resolve("dns_servers"),
|
||||
"ntp_servers": _resolve("ntp_servers"),
|
||||
}
|
||||
|
||||
|
||||
def is_physical(vlan):
|
||||
return vlan.get("vlan_id") == 1
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# VLAN naming / path helpers
|
||||
# ===================================================================
|
||||
|
||||
def networkd_stem(vlan):
|
||||
return f"10-{PRODUCT_NAME}-{vlan['name']}"
|
||||
|
||||
|
||||
def vlan_service_name(vlan, iface):
|
||||
if validation.is_wg(vlan):
|
||||
return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}-{iface}"
|
||||
return f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}"
|
||||
|
||||
|
||||
def vlan_service_file(vlan, iface):
|
||||
return SYSTEMD_DIR / f"{vlan_service_name(vlan, iface)}.service"
|
||||
|
||||
|
||||
def vlan_conf_file(vlan):
|
||||
return DNSMASQ_CONF_DIR / f"{vlan['name']}.conf"
|
||||
|
||||
|
||||
def vlan_leases_file(vlan):
|
||||
return LEASES_DIR / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.leases"
|
||||
|
||||
|
||||
def vlan_pid_file(vlan):
|
||||
return Path("/run") / f"dnsmasq-{PRODUCT_NAME}-{vlan['name']}.pid"
|
||||
214
routlin/mod_timers.py
Normal file
214
routlin/mod_timers.py
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
"""
|
||||
mod_timers.py -- systemd timer installation and removal.
|
||||
|
||||
Manages the blocklist refresh timer, DDNS maintenance timer, and
|
||||
generic interval timers (health check, etc.).
|
||||
"""
|
||||
|
||||
import subprocess
|
||||
|
||||
import mod_shared as shared
|
||||
|
||||
BLIST_TIMER_NAME = f"{shared.PRODUCT_NAME}-dns-blocklist-update"
|
||||
BLIST_TIMER_FILE = shared.SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.timer"
|
||||
BLIST_TIMER_SVC_FILE = shared.SYSTEMD_DIR / f"{BLIST_TIMER_NAME}.service"
|
||||
|
||||
MAINT_TIMER_NAME = f"{shared.PRODUCT_NAME}-maintenance"
|
||||
MAINT_TIMER_FILE = shared.SYSTEMD_DIR / f"{MAINT_TIMER_NAME}.timer"
|
||||
MAINT_TIMER_SVC_FILE = shared.SYSTEMD_DIR / f"{MAINT_TIMER_NAME}.service"
|
||||
|
||||
HEALTH_TIMER_NAME = f"{shared.PRODUCT_NAME}-health-check"
|
||||
HEALTH_TIMER_FILE = shared.SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.timer"
|
||||
HEALTH_TIMER_SVC_FILE = shared.SYSTEMD_DIR / f"{HEALTH_TIMER_NAME}.service"
|
||||
HEALTH_TIMER_INTERVAL_SEC = 300
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Blocklist timer
|
||||
# ===================================================================
|
||||
|
||||
def _parse_time_to_calendar(time_str):
|
||||
"""Convert HH:MM string to a systemd OnCalendar value. Raises ValueError on bad format."""
|
||||
parts = time_str.strip().split(":")
|
||||
if len(parts) != 2:
|
||||
raise ValueError(f"Invalid daily_execute_time_24hr_local: '{time_str}'. Expected HH:MM.")
|
||||
hh, mm = parts
|
||||
return f"*-*-* {hh.zfill(2)}:{mm.zfill(2)}:00"
|
||||
|
||||
def install_timer(data):
|
||||
"""Install the daily blocklist refresh timer. Returns error string on failure, None on success."""
|
||||
general = data.get("dns_blocking", {}).get("general", {})
|
||||
execute_time = general.get("daily_execute_time_24hr_local", "02:30")
|
||||
try:
|
||||
on_calendar = _parse_time_to_calendar(execute_time)
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
|
||||
timer_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
"Description=Daily blocklist refresh",
|
||||
"",
|
||||
"[Timer]",
|
||||
f"OnCalendar={on_calendar}",
|
||||
"Persistent=true",
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=timers.target",
|
||||
"",
|
||||
])
|
||||
|
||||
blocklist_script = shared.SCRIPT_DIR / "dns-blocklists.py"
|
||||
service_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
"Description=Daily blocklist refresh",
|
||||
"After=network-online.target",
|
||||
"Wants=network-online.target",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=oneshot",
|
||||
f"ExecStart=/usr/bin/python3 {blocklist_script}",
|
||||
"",
|
||||
])
|
||||
|
||||
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"{BLIST_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True)
|
||||
print(f"Timer {BLIST_TIMER_NAME}.timer enabled (runs daily at {execute_time}).")
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Interval timers
|
||||
# ===================================================================
|
||||
|
||||
def install_interval_timers(names, timer_files, svc_files, descriptions, exec_starts, interval_secs):
|
||||
for name, timer_file, svc_file, description, exec_start, interval_sec in zip(
|
||||
names, timer_files, svc_files, descriptions, exec_starts, interval_secs):
|
||||
timer_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
f"Description={description}",
|
||||
"",
|
||||
"[Timer]",
|
||||
f"OnActiveSec={interval_sec}s",
|
||||
f"OnUnitActiveSec={interval_sec}s",
|
||||
"AccuracySec=10s",
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=timers.target",
|
||||
"",
|
||||
])
|
||||
service_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
f"Description={description}",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=oneshot",
|
||||
f"ExecStart={exec_start}",
|
||||
"",
|
||||
])
|
||||
for path, content in ((timer_file, timer_content), (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)
|
||||
for name, interval_sec in zip(names, interval_secs):
|
||||
subprocess.run(["systemctl", "enable", f"{name}.timer"], capture_output=True, text=True)
|
||||
active = subprocess.run(
|
||||
["systemctl", "is-active", f"{name}.timer"],
|
||||
capture_output=True, text=True
|
||||
).stdout.strip() == "active"
|
||||
verb = "restart" if active else "start"
|
||||
subprocess.run(["systemctl", verb, f"{name}.timer"], capture_output=True, text=True)
|
||||
print(f"Timer {name}.timer enabled (runs every {interval_sec}s).")
|
||||
|
||||
def remove_timers(names, timer_files, svc_files, daemon_reload=False):
|
||||
for name, timer_file, svc_file in zip(names, timer_files, svc_files):
|
||||
subprocess.run(["systemctl", "disable", "--now", f"{name}.timer"],
|
||||
capture_output=True, text=True)
|
||||
for f in (timer_file, svc_file):
|
||||
if f.exists():
|
||||
f.unlink()
|
||||
print(f"Removed: {f}")
|
||||
else:
|
||||
print(f"Not found, skipping: {f}")
|
||||
if daemon_reload:
|
||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# DDNS maintenance timer
|
||||
# ===================================================================
|
||||
|
||||
def _parse_ddns_interval(interval_str):
|
||||
"""Convert interval string (e.g. 5m, 2h, 1d) to systemd OnUnitActiveSec value."""
|
||||
s = interval_str.strip()
|
||||
if s.endswith("m"): return f"{s[:-1]}min"
|
||||
if s.endswith("h"): return f"{s[:-1]}h"
|
||||
if s.endswith("d"): return f"{s[:-1]}day"
|
||||
raise ValueError(f"Invalid timer_interval format: '{s}'. Use e.g. 5m, 2h, 1d.")
|
||||
|
||||
def install_maint_timer(data):
|
||||
ddns = data.get("ddns", {})
|
||||
interval = ddns.get("general", {}).get("timer_interval", "10m")
|
||||
script_path = shared.SCRIPT_DIR / "maintenance.py"
|
||||
try:
|
||||
systemd_unit = _parse_ddns_interval(interval)
|
||||
except ValueError as e:
|
||||
print(f"DDNS timer: {e}")
|
||||
return
|
||||
|
||||
service_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
"Description=DDNS IP update",
|
||||
"After=network-online.target",
|
||||
"Wants=network-online.target",
|
||||
"",
|
||||
"[Service]",
|
||||
"Type=oneshot",
|
||||
f"ExecStart=/usr/bin/python3 {script_path} --update",
|
||||
"",
|
||||
])
|
||||
timer_content = "\n".join([
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"",
|
||||
"[Unit]",
|
||||
"Description=DDNS IP update timer",
|
||||
"",
|
||||
"[Timer]",
|
||||
f"OnActiveSec={systemd_unit}",
|
||||
f"OnUnitActiveSec={systemd_unit}",
|
||||
"OnBootSec=1min",
|
||||
"AccuracySec=10s",
|
||||
"",
|
||||
"[Install]",
|
||||
"WantedBy=timers.target",
|
||||
"",
|
||||
])
|
||||
for path, content in ((MAINT_TIMER_SVC_FILE, service_content), (MAINT_TIMER_FILE, timer_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)
|
||||
active = subprocess.run(
|
||||
["systemctl", "is-active", f"{MAINT_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True
|
||||
).stdout.strip() == "active"
|
||||
verb = "restart" if active else "enable --now"
|
||||
subprocess.run(["systemctl"] + verb.split() + [f"{MAINT_TIMER_NAME}.timer"],
|
||||
capture_output=True, text=True)
|
||||
print(f"Timer {MAINT_TIMER_NAME}.timer enabled (runs every {interval}).")
|
||||
157
routlin/mod_wireguard.py
Normal file
157
routlin/mod_wireguard.py
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
"""
|
||||
mod_wireguard.py -- WireGuard server configuration and interface management.
|
||||
|
||||
Generates /etc/wireguard/<iface>.conf from config.json peers, manages server
|
||||
key generation, and brings WireGuard interfaces up or syncs peer changes.
|
||||
"""
|
||||
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import mod_shared as shared
|
||||
import mod_validation as validation
|
||||
|
||||
WG_DIR = Path("/etc/wireguard")
|
||||
WG_KEEPALIVE = 25
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Interface state
|
||||
# ===================================================================
|
||||
|
||||
def wg_interface_up(iface):
|
||||
"""Return True if the WireGuard interface exists and is up."""
|
||||
result = subprocess.run(["ip", "link", "show", iface],
|
||||
capture_output=True, text=True)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Key and path helpers
|
||||
# ===================================================================
|
||||
|
||||
def wg_server_key_path(iface):
|
||||
return WG_DIR / f"{iface}.key"
|
||||
|
||||
def wg_server_pubkey_path(iface):
|
||||
"""Public key written to the configs dir so the Flask app can read it."""
|
||||
return shared.SCRIPT_DIR / f".{iface}.pub"
|
||||
|
||||
def wg_conf_path_for(iface):
|
||||
return WG_DIR / f"{iface}.conf"
|
||||
|
||||
def generate_wg_server_key(iface):
|
||||
WG_DIR.mkdir(exist_ok=True)
|
||||
result = subprocess.run(["wg", "genkey"], capture_output=True, text=True, check=True)
|
||||
private = result.stdout.strip()
|
||||
kf = wg_server_key_path(iface)
|
||||
kf.write_text(private + "\n")
|
||||
kf.chmod(0o600)
|
||||
return private
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Config generation
|
||||
# ===================================================================
|
||||
|
||||
def build_wg_server_conf(vlan, server_private_key, iface):
|
||||
"""Build the /etc/wireguard/<iface>.conf content from config.json peers."""
|
||||
info = vlan["vpn_information"]
|
||||
gateway = shared.resolve_vlan_options(vlan)["gateway"]
|
||||
network = shared.network_for(vlan)
|
||||
server_ip = f"{gateway}/{network.prefixlen}"
|
||||
listen_port = info["listen_port"]
|
||||
|
||||
L = [
|
||||
"# Generated by core.py -- do not edit manually.",
|
||||
"# Run: sudo python3 core.py --apply",
|
||||
"",
|
||||
"[Interface]",
|
||||
f"PrivateKey = {server_private_key}",
|
||||
f"Address = {server_ip}",
|
||||
f"ListenPort = {listen_port}",
|
||||
"",
|
||||
]
|
||||
|
||||
for peer in vlan.get("peers", []):
|
||||
if not peer.get("enabled", True):
|
||||
L += [f"# DISABLED: {peer['name']}", ""]
|
||||
continue
|
||||
L += [
|
||||
f"# {peer['name']}",
|
||||
"[Peer]",
|
||||
f"PublicKey = {peer['public_key']}",
|
||||
f"AllowedIPs = {peer['ip']}/32",
|
||||
f"PersistentKeepalive = {WG_KEEPALIVE}",
|
||||
"",
|
||||
]
|
||||
|
||||
return "\n".join(L)
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Apply
|
||||
# ===================================================================
|
||||
|
||||
def ensure_wg_interfaces(data):
|
||||
"""Generate WireGuard server confs and bring up / sync all WG interfaces."""
|
||||
wg_vlans = [v for v in data.get("vlans", []) if validation.is_wg(v)]
|
||||
if not wg_vlans:
|
||||
return
|
||||
|
||||
for vlan in wg_vlans:
|
||||
iface = validation.derive_interface(vlan, data)
|
||||
print(f" [{iface}]")
|
||||
|
||||
kf = wg_server_key_path(iface)
|
||||
if not kf.exists():
|
||||
print(f" Generating server private key...")
|
||||
private = generate_wg_server_key(iface)
|
||||
else:
|
||||
private = kf.read_text().strip()
|
||||
|
||||
pub_result = subprocess.run(
|
||||
["wg", "pubkey"], input=private, capture_output=True, text=True, check=True
|
||||
)
|
||||
public = pub_result.stdout.strip()
|
||||
pubkey_file = wg_server_pubkey_path(iface)
|
||||
pubkey_file.write_text(public + "\n")
|
||||
shared.chown_to_script_dir_owner(pubkey_file)
|
||||
print(f" Server public key: {public[:20]}...")
|
||||
|
||||
WG_DIR.mkdir(exist_ok=True)
|
||||
conf_file = wg_conf_path_for(iface)
|
||||
new_conf = build_wg_server_conf(vlan, private, iface)
|
||||
listen_port = vlan["vpn_information"]["listen_port"]
|
||||
|
||||
port_changed = False
|
||||
if conf_file.exists():
|
||||
m = re.search(r'ListenPort\s*=\s*(\d+)', conf_file.read_text())
|
||||
if m and int(m.group(1)) != listen_port:
|
||||
port_changed = True
|
||||
|
||||
conf_file.write_text(new_conf)
|
||||
conf_file.chmod(0o600)
|
||||
|
||||
peer_count = len([p for p in vlan.get("peers", []) if p.get("enabled", True)])
|
||||
print(f" Wrote {conf_file} ({peer_count} enabled peer(s))")
|
||||
|
||||
if not wg_interface_up(iface):
|
||||
print(f" Bringing up {iface}...")
|
||||
r = subprocess.run(["wg-quick", "up", iface], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(f" WARNING: wg-quick up {iface} failed: {r.stderr.strip()}")
|
||||
else:
|
||||
print(f" {iface} is up.")
|
||||
elif port_changed:
|
||||
print(f" Listen port changed - restarting {iface}...")
|
||||
subprocess.run(["wg-quick", "down", iface], capture_output=True, text=True)
|
||||
r = subprocess.run(["wg-quick", "up", iface], capture_output=True, text=True)
|
||||
if r.returncode != 0:
|
||||
print(f" WARNING: wg-quick up {iface} failed: {r.stderr.strip()}")
|
||||
else:
|
||||
print(f" {iface} restarted.")
|
||||
else:
|
||||
print(f" Syncing peers to live {iface}...")
|
||||
subprocess.run(["wg", "syncconf", iface, str(conf_file)], capture_output=True, text=True)
|
||||
Loading…
Add table
Add a link
Reference in a new issue