Flask app progress
This commit is contained in:
parent
c4fe022d42
commit
b0994069ad
38 changed files with 6631 additions and 220 deletions
|
|
@ -333,6 +333,7 @@ sudo python3 ddns.py --disable # Stop updates and remove systemd ti
|
|||
python3 ddns.py --apply # Run one immediate DDNS update (used by timer)
|
||||
python3 ddns.py --force # Force update regardless of cached IP
|
||||
python3 ddns.py --status # Timer/service status
|
||||
python3 ddns.py --getip # Print current public IP and exit
|
||||
```
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@
|
|||
"name": "trusted",
|
||||
"interface": "enp6s0",
|
||||
"radius_default": false,
|
||||
"mdns_reflection": false,
|
||||
"use_blocklists": ["oisd-big", "hagezi-light"],
|
||||
"server_identities": [
|
||||
{ "description": "Router/Gateway", "ip": "192.168.1.1" },
|
||||
|
|
@ -134,6 +135,7 @@
|
|||
"name": "iot",
|
||||
"interface": "enp6s0.10",
|
||||
"radius_default": false,
|
||||
"mdns_reflection": true,
|
||||
"use_blocklists": ["oisd-big", "hagezi-light"],
|
||||
"server_identities": [
|
||||
{ "description": "Router/Gateway", "ip": "192.168.10.1" }
|
||||
|
|
@ -168,6 +170,7 @@
|
|||
"name": "guest",
|
||||
"interface": "enp6s0.20",
|
||||
"radius_default": true,
|
||||
"mdns_reflection": true,
|
||||
"use_blocklists": ["oisd-big", "hagezi-light"],
|
||||
"server_identities": [
|
||||
{ "description": "Router/Gateway", "ip": "192.168.20.1" }
|
||||
|
|
@ -196,6 +199,7 @@
|
|||
"name": "kids",
|
||||
"interface": "enp6s0.30",
|
||||
"radius_default": false,
|
||||
"mdns_reflection": true,
|
||||
"use_blocklists": ["oisd-big", "hagezi-light", "hagezi-pro-plus"],
|
||||
"server_identities": [
|
||||
{ "description": "Router/Gateway", "ip": "192.168.30.1" }
|
||||
|
|
@ -226,6 +230,7 @@
|
|||
"name": "vpn",
|
||||
"interface": "wg0",
|
||||
"radius_default": false,
|
||||
"mdns_reflection": false,
|
||||
"use_blocklists": ["oisd-big", "hagezi-light"],
|
||||
"vpn_information": {
|
||||
"listen_port": 51820,
|
||||
|
|
@ -240,11 +245,6 @@
|
|||
]
|
||||
}
|
||||
|
||||
],
|
||||
|
||||
"mdns_reflection": {
|
||||
"enabled": true,
|
||||
"reflect_vlans": ["iot", "guest", "kids"]
|
||||
}
|
||||
]
|
||||
|
||||
}
|
||||
|
|
|
|||
210
router/core.py
210
router/core.py
|
|
@ -100,6 +100,7 @@ import urllib.error
|
|||
import argparse
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from validation import VALID_PROTOCOLS, VALID_BLOCKLIST_FORMATS
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
CONFIG_FILE = SCRIPT_DIR / "core.json"
|
||||
|
|
@ -119,14 +120,14 @@ NAT_SERVICE_FILE = SYSTEMD_DIR / f"{NAT_SERVICE_NAME}.service"
|
|||
|
||||
log = None
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Logging
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def chown_to_script_dir_owner(path):
|
||||
"""Chown a file to the owner of the script directory.
|
||||
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer),
|
||||
or as a normal user — the script directory owner is always the right target.
|
||||
or as a normal user - the script directory owner is always the right target.
|
||||
"""
|
||||
try:
|
||||
stat = SCRIPT_DIR.stat()
|
||||
|
|
@ -159,9 +160,9 @@ def setup_logging(max_kb, errors_only):
|
|||
)
|
||||
log = logging.getLogger("dns-dhcp")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def service_warning(action, svc, stderr):
|
||||
"""Print a service start/restart warning, adding --install hint if unit not found."""
|
||||
|
|
@ -172,7 +173,7 @@ def service_warning(action, svc, stderr):
|
|||
|
||||
|
||||
def die(msg):
|
||||
print(f"ERROR: {msg}")
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def check_root():
|
||||
|
|
@ -279,9 +280,9 @@ def expand_protocols(rule):
|
|||
return [("tcp", rule, " (tcp)"), ("udp", rule, " (udp)")]
|
||||
return [(proto, rule, "")]
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Load
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def load_config():
|
||||
if not CONFIG_FILE.exists():
|
||||
|
|
@ -292,9 +293,9 @@ def load_config():
|
|||
die("No vlans defined in core.json.")
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Validate
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def validate_config(data):
|
||||
errors = []
|
||||
|
|
@ -330,8 +331,8 @@ def validate_config(data):
|
|||
for field in ("name", "description", "save_as", "url", "format"):
|
||||
if not bl.get(field):
|
||||
errors.append(f"{label}: missing or empty field '{field}'.")
|
||||
if bl.get("format") and bl["format"] not in ("dnsmasq", "hosts"):
|
||||
errors.append(f"{label}: format must be 'dnsmasq' or 'hosts'.")
|
||||
if bl.get("format") and bl["format"] not in VALID_BLOCKLIST_FORMATS:
|
||||
errors.append(f"{label}: format must be one of: {', '.join(sorted(VALID_BLOCKLIST_FORMATS))}.")
|
||||
if name:
|
||||
if name in blocklists_by_name:
|
||||
errors.append(f"{label}: duplicate blocklist name '{name}'.")
|
||||
|
|
@ -365,6 +366,9 @@ def validate_config(data):
|
|||
else:
|
||||
seen_interfaces[iface] = name
|
||||
|
||||
if vlan.get("mdns_reflection") is True and is_wg(vlan):
|
||||
errors.append(f"{label}: mdns_reflection must be false for WireGuard interfaces.")
|
||||
|
||||
if is_wg(vlan):
|
||||
vpi = vlan.get("vpn_information")
|
||||
if not isinstance(vpi, dict):
|
||||
|
|
@ -538,7 +542,7 @@ def validate_config(data):
|
|||
errors.append(f"{label}: use_blocklists references unknown blocklist '{bl_name}'.")
|
||||
|
||||
# -- NAT / firewall validation ---------------------------------------------
|
||||
valid_protos = {"tcp", "udp", "both"}
|
||||
valid_protos = VALID_PROTOCOLS
|
||||
known_interfaces = set(seen_interfaces.keys())
|
||||
|
||||
def nat_check_port(label, port):
|
||||
|
|
@ -621,25 +625,11 @@ def validate_config(data):
|
|||
if r.get("dst_port") is not None:
|
||||
nat_check_port(f"{label} dst_port", r.get("dst_port"))
|
||||
|
||||
# -- mdns_reflection validation --------------------------------------------
|
||||
mdns = data.get("mdns_reflection", {})
|
||||
if mdns.get("enabled") is True:
|
||||
known_vlan_names = {v["name"] for v in data["vlans"]}
|
||||
reflect_vlans = mdns.get("reflect_vlans", [])
|
||||
for vname in reflect_vlans:
|
||||
if vname not in known_vlan_names:
|
||||
errors.append(f"mdns_reflection.reflect_vlans: '{vname}' is not a known VLAN name.")
|
||||
else:
|
||||
vlan = next(v for v in data["vlans"] if v["name"] == vname)
|
||||
if is_wg(vlan):
|
||||
errors.append(f"mdns_reflection.reflect_vlans: '{vname}' is a WireGuard VLAN "
|
||||
f"and cannot participate in mDNS reflection.")
|
||||
if not reflect_vlans:
|
||||
errors.append("mdns_reflection.reflect_vlans is empty. "
|
||||
"Add at least two VLAN names or set enabled: false.")
|
||||
elif len(reflect_vlans) < 2:
|
||||
errors.append("mdns_reflection.reflect_vlans must contain at least two VLANs — "
|
||||
"reflecting mDNS on a single VLAN has no effect.")
|
||||
# -- radius_default uniqueness check ---------------------------------------
|
||||
defaults = [v["name"] for v in data["vlans"] if v.get("radius_default") is True]
|
||||
if len(defaults) > 1:
|
||||
errors.append(f"Multiple VLANs have radius_default: true ({', '.join(defaults)}). "
|
||||
f"Only one VLAN may be the RADIUS default.")
|
||||
|
||||
# -- banned_ips validation -------------------------------------------------
|
||||
for idx, entry in enumerate(data.get("banned_ips", [])):
|
||||
|
|
@ -654,14 +644,14 @@ def validate_config(data):
|
|||
errors.append(f"{lbl}: {e}")
|
||||
|
||||
if errors:
|
||||
print("Validation failed:")
|
||||
print("Validation failed:", file=sys.stderr)
|
||||
for e in errors:
|
||||
print(f" - {e}")
|
||||
print(f" - {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Build systemd-networkd files
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def build_netdev(vlan):
|
||||
return "\n".join([
|
||||
|
|
@ -787,9 +777,9 @@ def apply_networkd(data, dry_run=False, only_if_changed=False):
|
|||
print("systemd-networkd: no changes. Good.")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Blocklist management
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def combo_hash(names):
|
||||
"""Return a stable 8-char hex hash for a list/set of blocklist names."""
|
||||
|
|
@ -934,9 +924,9 @@ def update_blocklists(data):
|
|||
any_failed = any(content is None for content, _ in downloaded.values())
|
||||
return not any_failed
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Build per-VLAN dnsmasq config
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def _wan_has_ipv6(iface):
|
||||
"""Return True if the WAN interface has a non-link-local IPv6 address."""
|
||||
|
|
@ -1087,9 +1077,9 @@ def build_vlan_dnsmasq_conf(vlan, data):
|
|||
|
||||
return "\n".join(L)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Build per-VLAN systemd service unit
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def build_vlan_service(vlan):
|
||||
name = vlan["name"]
|
||||
|
|
@ -1133,9 +1123,9 @@ def build_vlan_service(vlan):
|
|||
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# System dnsmasq / resolv.conf
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def ensure_resolv_conf(data):
|
||||
"""Ensure /etc/resolv.conf points to the physical VLAN gateway (vlan_id=1)."""
|
||||
|
|
@ -1297,9 +1287,9 @@ def restore_ntp():
|
|||
else:
|
||||
print("systemd-timesyncd is not available on this system.")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Apply dnsmasq instances
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def wg_interface_up(iface):
|
||||
"""Return True if the WireGuard interface exists and is up."""
|
||||
|
|
@ -1452,9 +1442,9 @@ def apply_dnsmasq_instances(data, dry_run=False, start_if_needed=True):
|
|||
else:
|
||||
print(f" WARNING: {svc} is not running -- skipping (run --apply to start it)")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Timer management
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def parse_time_to_calendar(time_str):
|
||||
parts = time_str.strip().split(":")
|
||||
|
|
@ -1519,9 +1509,9 @@ def remove_timer():
|
|||
print(f"Not found, skipping: {f}")
|
||||
subprocess.run(["systemctl", "daemon-reload"], capture_output=True, text=True)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# banned_ips expansion
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def _expand_banned_ipv4(ip_str):
|
||||
"""Convert an IPv4 pattern (CIDR, wildcard, range) to nftables set elements."""
|
||||
|
|
@ -1531,7 +1521,7 @@ def _expand_banned_ipv4(ip_str):
|
|||
|
||||
parts = ip_str.split('.')
|
||||
if len(parts) != 4:
|
||||
raise ValueError(f"Invalid IPv4 pattern: {ip_str!r} — expected 4 octets")
|
||||
raise ValueError(f"Invalid IPv4 pattern: {ip_str!r} - expected 4 octets")
|
||||
|
||||
def parse_octet(s, pos):
|
||||
if s == '*':
|
||||
|
|
@ -1587,7 +1577,7 @@ def _expand_banned_ipv4(ip_str):
|
|||
_enum_cidr(idx + 1, chosen + [v])
|
||||
_enum_cidr(0, [])
|
||||
else:
|
||||
# No trailing wildcards — enumerate outer 3 octets, express last as range
|
||||
# No trailing wildcards - enumerate outer 3 octets, express last as range
|
||||
outer_ranges = ranges[:3]
|
||||
lo4, hi4 = ranges[3]
|
||||
|
||||
|
|
@ -1682,9 +1672,9 @@ def banned_ip_sets(data):
|
|||
return v4, v6
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# nftables config generation
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def build_nft_config(data, dry_run=False):
|
||||
wan = data["general"]["wan_interface"]
|
||||
|
|
@ -1946,9 +1936,9 @@ def build_nft_config(data, dry_run=False):
|
|||
|
||||
return "\n".join(L)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# nftables apply / disable / status
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def table_exists(family, name):
|
||||
result = subprocess.run(
|
||||
|
|
@ -1977,8 +1967,8 @@ def apply_nft_config(config_text):
|
|||
capture_output=True, text=True
|
||||
)
|
||||
if result.returncode != 0:
|
||||
print("ERROR: nft rejected the ruleset:")
|
||||
print(result.stderr)
|
||||
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):
|
||||
|
|
@ -2075,9 +2065,9 @@ def show_rules():
|
|||
else:
|
||||
print(result.stdout)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# nftables boot service
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def install_nat_service():
|
||||
script_path = Path(__file__).resolve()
|
||||
|
|
@ -2121,13 +2111,13 @@ def remove_nat_service():
|
|||
else:
|
||||
print(f"Boot service not found, skipping: {NAT_SERVICE_NAME}.service")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Status
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# RADIUS
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
RADIUS_SECRET_FILE = SCRIPT_DIR / ".radius-secret"
|
||||
RADIUS_CLIENTS_CONF = Path("/etc/freeradius/3.0/clients.conf")
|
||||
|
|
@ -2275,25 +2265,19 @@ def apply_radius(data):
|
|||
service_warning("start", "freeradius", result.stderr)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Avahi mDNS Reflector
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
AVAHI_CONF_FILE = Path("/etc/avahi/avahi-daemon.conf")
|
||||
|
||||
def avahi_enabled(data):
|
||||
"""Return True if mdns_reflection is enabled with at least two VLANs configured."""
|
||||
mdns = data.get("mdns_reflection", {})
|
||||
return mdns.get("enabled") is True
|
||||
"""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 is_wg(v))
|
||||
|
||||
def avahi_interfaces(data):
|
||||
"""Return list of interface names for mDNS reflection based on reflect_vlans."""
|
||||
reflect = data.get("mdns_reflection", {}).get("reflect_vlans", [])
|
||||
ifaces = []
|
||||
for vlan in data["vlans"]:
|
||||
if vlan["name"] in reflect and not is_wg(vlan):
|
||||
ifaces.append(vlan["interface"])
|
||||
return ifaces
|
||||
"""Return list of interface names for VLANs with mdns_reflection enabled."""
|
||||
return [v["interface"] for v in data.get("vlans", []) if v.get("mdns_reflection") is True and not is_wg(v)]
|
||||
|
||||
def build_avahi_conf(data):
|
||||
"""Patch avahi-daemon.conf directives needed for cross-VLAN mDNS reflection.
|
||||
|
|
@ -2317,7 +2301,7 @@ def build_avahi_conf(data):
|
|||
replacement = f"{directive}={value}"
|
||||
if pattern.search(text):
|
||||
return pattern.sub(replacement, text)
|
||||
# Not present at all — this shouldn't happen with a standard avahi install
|
||||
# Not present at all - this shouldn't happen with a standard avahi install
|
||||
# but append it to the relevant section if needed
|
||||
return text + f"\n{replacement}\n"
|
||||
|
||||
|
|
@ -2403,8 +2387,8 @@ def show_status(data):
|
|||
r_enabled = subprocess.run(["systemctl", "is-enabled", unit], capture_output=True, text=True)
|
||||
active = r_active.stdout.strip()
|
||||
enabled = r_enabled.stdout.strip()
|
||||
active_sym = "✓" if active == "active" else "✗"
|
||||
enabled_sym = "✓" if enabled == "enabled" else "✗"
|
||||
active_sym = "+" if active == "active" else "x"
|
||||
enabled_sym = "+" if enabled == "enabled" else "x"
|
||||
active_ok = "(OK) " if active == expected_active else "(BAD)"
|
||||
enabled_ok = "(OK) " if enabled == "enabled" else "(BAD)"
|
||||
return active_sym, active, active_ok, enabled_sym, enabled, enabled_ok
|
||||
|
|
@ -2416,7 +2400,7 @@ def show_status(data):
|
|||
else:
|
||||
units.append((vlan_service_name(vlan), None, "active"))
|
||||
units.append((f"{TIMER_NAME}.timer", None, "active"))
|
||||
units.append((NAT_SERVICE_NAME, None, "inactive")) # oneshot — exits after running
|
||||
units.append((NAT_SERVICE_NAME, None, "inactive")) # oneshot - exits after running
|
||||
units.append(("freeradius", None, "active"))
|
||||
units.append(("avahi-daemon", None, "active"))
|
||||
|
||||
|
|
@ -2456,9 +2440,9 @@ def show_configs(data):
|
|||
else:
|
||||
print(f"No config found at {cf} (not yet applied).")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Leases
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def reset_leases(data, vlan_name=None):
|
||||
"""Stop dnsmasq instances, delete lease files, restart instances.
|
||||
|
|
@ -2572,9 +2556,9 @@ def show_leases(data):
|
|||
if not any_leases:
|
||||
print("No active leases found.")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Metrics
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def collect_metrics(data):
|
||||
"""
|
||||
|
|
@ -2755,9 +2739,9 @@ def show_metrics(data):
|
|||
print(f" NXDOMAIN : {s['nxdomain']:,}")
|
||||
print(f" Latency : {s['avg_latency_ms']}ms (last recorded)")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Stop / disable
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def stop_instances(data):
|
||||
"""Remove timer and stop all per-VLAN instances (config files preserved)."""
|
||||
|
|
@ -2867,19 +2851,19 @@ def _suggest_static_ip(physical_vlan):
|
|||
chosen = max(non_gateway, key=lambda ip: ip.packed[-1])
|
||||
return f"{chosen}/{prefix}"
|
||||
|
||||
# All identities end in .1 — pick a random unused host in the subnet
|
||||
# All identities end in .1 - pick a random unused host in the subnet
|
||||
hosts = list(network.hosts())
|
||||
candidates = [h for h in hosts if h not in known_ips and h.packed[-1] != 1]
|
||||
if candidates:
|
||||
chosen = random.choice(candidates)
|
||||
return f"{chosen}/{prefix}"
|
||||
|
||||
# Degenerate fallback — extremely small subnet
|
||||
# Degenerate fallback - extremely small subnet
|
||||
return f"{list(network.hosts())[0]}/{prefix}"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Dry-run helpers
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def _svc_state(unit):
|
||||
"""Return 'active', 'inactive', or 'unknown' for a systemd unit."""
|
||||
|
|
@ -2900,12 +2884,12 @@ def _dry_run_conflicting_services(data):
|
|||
if state == "active":
|
||||
print(f" Would stop and disable: {label} (currently: active)")
|
||||
else:
|
||||
print(f" {label}: not active — no action needed")
|
||||
print(f" {label}: not active - no action needed")
|
||||
|
||||
chrony_ok = subprocess.run(["systemctl", "cat", "chrony"],
|
||||
capture_output=True, text=True).returncode == 0
|
||||
if not chrony_ok:
|
||||
print(" chrony: not installed — dependency check would have prompted to install it")
|
||||
print(" chrony: not installed - dependency check would have prompted to install it")
|
||||
else:
|
||||
chrony_conf = Path("/etc/chrony/chrony.conf")
|
||||
if chrony_conf.exists():
|
||||
|
|
@ -2922,7 +2906,7 @@ def _dry_run_conflicting_services(data):
|
|||
if missing:
|
||||
print(f" Would add chrony allow directives for: {', '.join(missing)}")
|
||||
else:
|
||||
print(" chrony.conf already has required allow directives — no change needed")
|
||||
print(" chrony.conf already has required allow directives - no change needed")
|
||||
print(f" Would enable and restart: chrony")
|
||||
|
||||
if subprocess.run(["which", "ufw"], capture_output=True, text=True).returncode == 0:
|
||||
|
|
@ -2930,20 +2914,20 @@ def _dry_run_conflicting_services(data):
|
|||
if "Status: active" in status.stdout:
|
||||
print(" Would disable: ufw (currently: active)")
|
||||
else:
|
||||
print(" ufw: not active — no rule action needed")
|
||||
print(" ufw: not active - no rule action needed")
|
||||
if _svc_enabled("ufw"):
|
||||
print(" Would disable: ufw.service (currently: enabled at boot)")
|
||||
else:
|
||||
print(" ufw.service: not enabled at boot — no action needed")
|
||||
print(" ufw.service: not enabled at boot - no action needed")
|
||||
else:
|
||||
print(" ufw: not installed — no action needed")
|
||||
print(" ufw: not installed - no action needed")
|
||||
|
||||
r = subprocess.run(["systemctl", "is-enabled", "dnsmasq"],
|
||||
capture_output=True, text=True)
|
||||
if r.stdout.strip() in ("enabled", "enabled-runtime"):
|
||||
print(f" Would stop and disable: system dnsmasq.service (currently: enabled)")
|
||||
else:
|
||||
print(" system dnsmasq.service: not enabled — no action needed")
|
||||
print(" system dnsmasq.service: not enabled - no action needed")
|
||||
|
||||
physical = next((v for v in data["vlans"] if is_physical(v)), None)
|
||||
if physical:
|
||||
|
|
@ -2956,7 +2940,7 @@ def _dry_run_conflicting_services(data):
|
|||
if wanted not in current:
|
||||
print(f" Would update /etc/resolv.conf: nameserver {gw}")
|
||||
else:
|
||||
print(f" /etc/resolv.conf already points to {gw} — no change needed")
|
||||
print(f" /etc/resolv.conf already points to {gw} - no change needed")
|
||||
|
||||
def _dry_run_blocklists(data):
|
||||
print("-- Blocklists (dry-run) ----------------------------------------------")
|
||||
|
|
@ -2982,7 +2966,7 @@ def _dry_run_timer(data):
|
|||
for path, label in [(TIMER_FILE, "timer unit"), (TIMER_SVC_FILE, "service unit")]:
|
||||
action = "update" if path.exists() else "create and enable"
|
||||
print(f" Would {action}: {path}")
|
||||
print(f" Schedule: daily at {execute_time} local time (Persistent=true — catches up if missed)")
|
||||
print(f" Schedule: daily at {execute_time} local time (Persistent=true - catches up if missed)")
|
||||
|
||||
def _dry_run_boot_service():
|
||||
print("-- Boot service (dry-run) --------------------------------------------")
|
||||
|
|
@ -3016,11 +3000,11 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
|
|||
if r.returncode == 0:
|
||||
print(f" Would flush nftables table: {table}")
|
||||
else:
|
||||
print(f" nftables table {table}: not present — no action needed")
|
||||
print(f" nftables table {table}: not present - no action needed")
|
||||
if NAT_SERVICE_FILE.exists():
|
||||
print(f" Would stop, disable, and remove: {NAT_SERVICE_NAME}.service")
|
||||
else:
|
||||
print(f" {NAT_SERVICE_NAME}.service: not installed — no action needed")
|
||||
print(f" {NAT_SERVICE_NAME}.service: not installed - no action needed")
|
||||
print()
|
||||
|
||||
print("-- Restoring NTP client (dry-run) ------------------------------------")
|
||||
|
|
@ -3028,7 +3012,7 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
|
|||
if state == "active":
|
||||
print(f" Would stop and disable: chrony (currently: active)")
|
||||
else:
|
||||
print(f" chrony: not active — no action needed")
|
||||
print(f" chrony: not active - no action needed")
|
||||
r = subprocess.run(["systemctl", "cat", "systemd-timesyncd"],
|
||||
capture_output=True, text=True)
|
||||
if r.returncode == 0:
|
||||
|
|
@ -3063,9 +3047,9 @@ def _dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice,
|
|||
print(f" nameserver {static_nameserver}")
|
||||
print()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Disable wizard
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def cmd_disable(data, dry_run=False):
|
||||
"""Interactive wizard to revert the machine from router to plain network client."""
|
||||
|
|
@ -3085,7 +3069,7 @@ def cmd_disable(data, dry_run=False):
|
|||
print()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 1 — Confirmation
|
||||
# Step 1 - Confirmation
|
||||
# ------------------------------------------------------------------
|
||||
while True:
|
||||
print(" [1] Proceed with reversion")
|
||||
|
|
@ -3100,7 +3084,7 @@ def cmd_disable(data, dry_run=False):
|
|||
print()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 2 — IP configuration
|
||||
# Step 2 - IP configuration
|
||||
# ------------------------------------------------------------------
|
||||
physical = next((v for v in data["vlans"] if is_physical(v)), None)
|
||||
if physical is None:
|
||||
|
|
@ -3110,7 +3094,7 @@ def cmd_disable(data, dry_run=False):
|
|||
|
||||
print(" How should this machine obtain its IP address after reversion?")
|
||||
print()
|
||||
print(" [1] Obtain IP via DHCP (recommended — let the new router assign one)")
|
||||
print(" [1] Obtain IP via DHCP (recommended - let the new router assign one)")
|
||||
print(" [2] Use a static IP")
|
||||
print()
|
||||
|
||||
|
|
@ -3156,7 +3140,7 @@ def cmd_disable(data, dry_run=False):
|
|||
print()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 3 — DNS resolver
|
||||
# Step 3 - DNS resolver
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# If resolv.conf is already a plain file with no router gateway IPs, leave it alone.
|
||||
|
|
@ -3187,7 +3171,7 @@ def cmd_disable(data, dry_run=False):
|
|||
print()
|
||||
|
||||
if resolved_available:
|
||||
print(" [1] Re-enable systemd-resolved (recommended — adapts to any network)")
|
||||
print(" [1] Re-enable systemd-resolved (recommended - adapts to any network)")
|
||||
print(" [2] Enter a static nameserver IP")
|
||||
while True:
|
||||
choice = input(" Choice [1/2]: ").strip()
|
||||
|
|
@ -3219,7 +3203,7 @@ def cmd_disable(data, dry_run=False):
|
|||
print()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Step 4 — Execute (or dry-run summary)
|
||||
# Step 4 - Execute (or dry-run summary)
|
||||
# ------------------------------------------------------------------
|
||||
if dry_run:
|
||||
_dry_run_disable(data, iface, use_dhcp, static_cidr, resolv_ok, dns_choice, static_nameserver)
|
||||
|
|
@ -3260,9 +3244,9 @@ def cmd_disable(data, dry_run=False):
|
|||
else:
|
||||
print(f" Interface {iface} will use static IP: {static_cidr}")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Main
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
|
||||
def cmd_install(data):
|
||||
|
|
@ -3455,7 +3439,7 @@ def main():
|
|||
sys.exit(0)
|
||||
|
||||
if args.dry_run and not any([args.apply, args.disable]):
|
||||
print("ERROR: --dry-run must be combined with --apply or --disable.")
|
||||
print("ERROR: --dry-run must be combined with --apply or --disable.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
data = load_config()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,8 @@
|
|||
"username": "your-username",
|
||||
"password": "your-password",
|
||||
"hostnames": [
|
||||
"grotke.ddns.net"
|
||||
"yoursubdomain.ddns.net",
|
||||
"yourothersubdomain.ddns.net"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
@ -36,16 +37,19 @@
|
|||
"enabled": true,
|
||||
"api_token": "your-cloudflare-api-token",
|
||||
"hostnames": [
|
||||
"yourdomain.com"
|
||||
"yourdomain.com",
|
||||
"yoursubdomain.yourdomain.com",
|
||||
"yourothersubdomain.yourdomain.com"
|
||||
]
|
||||
},
|
||||
{
|
||||
"description": "DuckDNS Account",
|
||||
"provider": "duckdns",
|
||||
"enabled": false,
|
||||
"token": "your-duckdns-token",
|
||||
"subdomains": [
|
||||
"yoursubdomain"
|
||||
"api_token": "your-duckdns-api-token",
|
||||
"hostnames": [
|
||||
"yoursubdomain.duckdns.org",
|
||||
"yourothersubdomain.duckdns.org"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
|
|
|||
117
router/ddns.py
117
router/ddns.py
|
|
@ -18,9 +18,10 @@ Log is cleared when it exceeds general.log_max_kb from config.
|
|||
Usage:
|
||||
sudo python3 ddns.py --start Run update and install systemd timer
|
||||
sudo python3 ddns.py --disable Stop updates and remove systemd timer
|
||||
sudo python3 ddns.py --apply Run update once (used by timer)
|
||||
sudo python3 ddns.py --force Force update regardless of cached IP
|
||||
sudo python3 ddns.py --status Show timer/service status
|
||||
python3 ddns.py --apply Run update once (used by timer)
|
||||
python3 ddns.py --force Force update regardless of cached IP
|
||||
python3 ddns.py --status Show timer/service status
|
||||
python3 ddns.py --getip Print current public IP and exit
|
||||
"""
|
||||
|
||||
import json
|
||||
|
|
@ -44,13 +45,13 @@ TIMER_FILE = Path(f"/etc/systemd/system/{TIMER_NAME}.timer")
|
|||
# log is assigned in setup_logging() after config is loaded
|
||||
log = None
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Load config
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def load_config():
|
||||
if not CONFIG_FILE.exists():
|
||||
print(f"ERROR: Config file not found: {CONFIG_FILE}")
|
||||
print(f"ERROR: Config file not found: {CONFIG_FILE}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
with open(CONFIG_FILE) as f:
|
||||
data = json.load(f)
|
||||
|
|
@ -59,47 +60,47 @@ def load_config():
|
|||
required_general = {"log_max_kb", "log_errors_only", "ip_check_services"}
|
||||
missing = required_general - set(data.get("general", {}).keys())
|
||||
if missing:
|
||||
print(f"ERROR: Missing keys in general block: {missing}")
|
||||
print(f"ERROR: Missing keys in general block: {missing}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not data["general"]["ip_check_services"]:
|
||||
print("ERROR: ip_check_services list is empty.")
|
||||
print("ERROR: ip_check_services list is empty.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# Validate providers block
|
||||
if not data.get("providers"):
|
||||
print("ERROR: No providers defined in config.")
|
||||
print("ERROR: No providers defined in config.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
for p in data["providers"]:
|
||||
base_required = {"description", "provider", "enabled"}
|
||||
missing = base_required - set(p.keys())
|
||||
if missing:
|
||||
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys: {missing}")
|
||||
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys: {missing}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
ptype = p.get("provider", "").lower()
|
||||
if ptype == "noip":
|
||||
extra = {"username", "password", "hostnames"}
|
||||
elif ptype == "duckdns":
|
||||
extra = {"token", "subdomains"}
|
||||
extra = {"api_token", "hostnames"}
|
||||
elif ptype == "cloudflare":
|
||||
extra = {"api_token", "hostnames"}
|
||||
else:
|
||||
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'")
|
||||
print(f"ERROR: Provider '{p.get('description', '?')}' has unknown provider type: '{ptype}'", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
missing = extra - set(p.keys())
|
||||
if missing:
|
||||
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}")
|
||||
print(f"ERROR: Provider '{p.get('description', '?')}' missing keys for {ptype}: {missing}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
return data
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def chown_to_script_dir_owner(path):
|
||||
"""Chown a file to the owner of the script directory.
|
||||
This works correctly whether invoked via sudo, directly as root (e.g. systemd timer),
|
||||
or as a normal user — the script directory owner is always the right target.
|
||||
or as a normal user - the script directory owner is always the right target.
|
||||
"""
|
||||
try:
|
||||
stat = SCRIPT_DIR.stat()
|
||||
|
|
@ -107,9 +108,9 @@ def chown_to_script_dir_owner(path):
|
|||
except OSError:
|
||||
pass # non-fatal
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Logging
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def setup_logging(max_kb, errors_only):
|
||||
"""Clear log if oversized, then initialise logger. Must be called before log is used."""
|
||||
|
|
@ -138,9 +139,9 @@ def setup_logging(max_kb, errors_only):
|
|||
)
|
||||
log = logging.getLogger("ddns")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Per-provider IP cache
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def cache_file_for(description):
|
||||
"""Return the cache file path for a given provider description."""
|
||||
|
|
@ -158,9 +159,9 @@ def save_cached_ip(description, ip):
|
|||
f.write_text(ip)
|
||||
chown_to_script_dir_owner(f)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Service rotation
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def get_next_service_index(total):
|
||||
"""Read last used index, increment, wrap around, return next index."""
|
||||
|
|
@ -177,8 +178,9 @@ def save_service_index(index):
|
|||
CACHE_SERVICE_FILE.write_text(str(index))
|
||||
chown_to_script_dir_owner(CACHE_SERVICE_FILE)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Public IP detection
|
||||
# ===================================================================
|
||||
|
||||
def extract_ip(body):
|
||||
"""
|
||||
|
|
@ -225,7 +227,7 @@ def _get_ip_via_cf_dns(spec):
|
|||
return None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def get_public_ip(services):
|
||||
"""
|
||||
|
|
@ -258,9 +260,9 @@ def get_public_ip(services):
|
|||
log.error("Could not determine public IP from any configured service.")
|
||||
sys.exit(1)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# No-IP update
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def update_noip(provider, ip):
|
||||
"""
|
||||
|
|
@ -325,9 +327,9 @@ def interpret_noip_response(response, hostnames, ip):
|
|||
return False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# DuckDNS update
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def update_duckdns(provider, ip):
|
||||
"""
|
||||
|
|
@ -338,8 +340,8 @@ def update_duckdns(provider, ip):
|
|||
as a comma-separated list.
|
||||
Returns True on success, False on failure.
|
||||
"""
|
||||
token = provider["token"]
|
||||
subdomains = ",".join(provider["subdomains"])
|
||||
token = provider["api_token"]
|
||||
subdomains = ",".join(h.replace(".duckdns.org", "") for h in provider["hostnames"])
|
||||
description = provider["description"]
|
||||
|
||||
url = f"https://www.duckdns.org/update?domains={subdomains}&token={token}&ip={ip}"
|
||||
|
|
@ -358,9 +360,9 @@ def update_duckdns(provider, ip):
|
|||
log.error(f"Network error contacting DuckDNS: {e}")
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Cloudflare DNS update
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def _cf_api_get(url, headers):
|
||||
req = urllib.request.Request(url, headers=headers)
|
||||
|
|
@ -429,9 +431,9 @@ def update_cloudflare(provider, ip):
|
|||
success = False
|
||||
return success
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Process a single provider block
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def process_provider(provider, current_ip, force=False):
|
||||
description = provider["description"]
|
||||
|
|
@ -471,9 +473,9 @@ def process_provider(provider, current_ip, force=False):
|
|||
save_cached_ip(description, current_ip)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Timer management
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def parse_interval(interval_str):
|
||||
"""
|
||||
|
|
@ -557,16 +559,22 @@ def remove_timer():
|
|||
else:
|
||||
print("No timer found, nothing to remove.")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Main
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def run_update(cfg, force=False):
|
||||
def run_update(cfg, force=False, getip_only=False):
|
||||
"""Perform a single DDNS update pass. Called by both timer and --start.
|
||||
If force=True, bypasses the cached IP check and always updates."""
|
||||
If force=True, bypasses the cached IP check and always updates.
|
||||
If getip_only=True, prints the detected public IP and returns without updating providers."""
|
||||
general = cfg["general"]
|
||||
current_ip = get_public_ip(general["ip_check_services"])
|
||||
enabled = [p for p in cfg["providers"] if p.get("enabled") is True]
|
||||
|
||||
if getip_only:
|
||||
print(current_ip)
|
||||
return
|
||||
|
||||
enabled = [p for p in cfg["providers"] if p.get("enabled") is True]
|
||||
|
||||
if not enabled:
|
||||
log.error("No enabled providers found in config.")
|
||||
|
|
@ -594,20 +602,22 @@ def main():
|
|||
"examples:\n"
|
||||
" sudo python3 ddns.py --start Run update and install systemd timer\n"
|
||||
" sudo python3 ddns.py --disable Stop updates and remove systemd timer\n"
|
||||
" sudo python3 ddns.py --apply Run update once (used by timer)\n"
|
||||
" sudo python3 ddns.py --force Force update regardless of cached IP\n"
|
||||
" sudo python3 ddns.py --status Show timer/service status\n"
|
||||
" python3 ddns.py --apply Run update once (used by timer)\n"
|
||||
" python3 ddns.py --force Force update regardless of cached IP\n"
|
||||
" python3 ddns.py --status Show timer/service status\n"
|
||||
" python3 ddns.py --getip Print current public IP and exit\n"
|
||||
)
|
||||
)
|
||||
parser.add_argument("--start", action="store_true", help="Run update and install systemd timer")
|
||||
parser.add_argument("--start", action="store_true", help="Run update and install systemd timer")
|
||||
parser.add_argument("--disable", action="store_true", help="Stop updates and remove systemd timer")
|
||||
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
|
||||
parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP")
|
||||
parser.add_argument("--status", action="store_true", help="Show timer/service status")
|
||||
parser.add_argument("--apply", action="store_true", help="Run update once (used by timer)")
|
||||
parser.add_argument("--force", action="store_true", help="Force update regardless of cached IP")
|
||||
parser.add_argument("--status", action="store_true", help="Show timer/service status")
|
||||
parser.add_argument("--getip", action="store_true", help="Print current public IP and exit")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if not any([args.start, args.disable, args.apply, args.force, args.status]):
|
||||
if not any([args.start, args.disable, args.apply, args.force, args.status, args.getip]):
|
||||
parser.print_help()
|
||||
return
|
||||
|
||||
|
|
@ -615,6 +625,15 @@ def main():
|
|||
show_status()
|
||||
return
|
||||
|
||||
if args.getip:
|
||||
global log
|
||||
log = logging.getLogger("ddns_quiet")
|
||||
log.addHandler(logging.NullHandler())
|
||||
log.propagate = False
|
||||
cfg = load_config()
|
||||
run_update(cfg, getip_only=True)
|
||||
return
|
||||
|
||||
cfg = load_config()
|
||||
general = cfg["general"]
|
||||
setup_logging(general["log_max_kb"], general["log_errors_only"])
|
||||
|
|
|
|||
164
router/validation.py
Normal file
164
router/validation.py
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
"""
|
||||
validation.py -- Shared structural validators for core.json fields.
|
||||
|
||||
Lives alongside core.py in ~/router/ and is volume-mounted into the
|
||||
router-dash container at /configs/validation.py. Importable by both
|
||||
core.py (router host) and the Flask app (via validate.py which adds
|
||||
/configs to sys.path).
|
||||
|
||||
Convention: each function accepts a raw string and returns the
|
||||
normalised valid value, or '' if the input is invalid.
|
||||
"""
|
||||
import ipaddress
|
||||
import re
|
||||
|
||||
VALID_PROTOCOLS = {'tcp', 'udp', 'both'}
|
||||
VALID_BLOCKLIST_FORMATS = {'dnsmasq', 'hosts'}
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# IP / CIDR
|
||||
# ===================================================================
|
||||
|
||||
def ip(value):
|
||||
"""Return value if it is a valid IPv4 or IPv6 address, else ''."""
|
||||
if not value:
|
||||
return ''
|
||||
v = str(value).strip()
|
||||
try:
|
||||
ipaddress.ip_address(v)
|
||||
return v
|
||||
except ValueError:
|
||||
return ''
|
||||
|
||||
|
||||
def ip_or_cidr(value):
|
||||
"""Return value if it is a valid IPv4/IPv6 address or CIDR network, else ''."""
|
||||
if not value:
|
||||
return ''
|
||||
v = str(value).strip()
|
||||
try:
|
||||
ipaddress.ip_address(v)
|
||||
return v
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
ipaddress.ip_network(v, strict=False)
|
||||
return v
|
||||
except ValueError:
|
||||
return ''
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Port
|
||||
# ===================================================================
|
||||
|
||||
def port(value):
|
||||
"""Return port as string if valid 1-65535, else ''."""
|
||||
try:
|
||||
p = int(re.sub(r'[^0-9]', '', str(value)))
|
||||
if 1 <= p <= 65535:
|
||||
return str(p)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return ''
|
||||
|
||||
|
||||
# ===================================================================
|
||||
# Banned-IP pattern
|
||||
# ===================================================================
|
||||
|
||||
def banned_ip(value):
|
||||
"""
|
||||
Return value if it is a valid banned_ip pattern, else ''.
|
||||
|
||||
Accepted formats (mirrors core.py expand_banned_ip):
|
||||
IPv4:
|
||||
Single address 192.0.2.1
|
||||
CIDR 192.0.2.0/24
|
||||
Wildcard octet 192.0.2.*
|
||||
Octet range 192.0.2.10-20
|
||||
(combinations that expand to <=1024 entries are accepted)
|
||||
IPv6:
|
||||
Single address 2001:db8::1
|
||||
CIDR 2001:db8::/32
|
||||
Trailing wildcard 2001:db8:c17:*
|
||||
"""
|
||||
if not value:
|
||||
return ''
|
||||
v = str(value).strip()
|
||||
try:
|
||||
_check_banned_ip(v)
|
||||
return v
|
||||
except (ValueError, TypeError):
|
||||
return ''
|
||||
|
||||
|
||||
def _check_banned_ip(ip_str):
|
||||
if ':' in ip_str:
|
||||
_check_banned_ipv6(ip_str)
|
||||
else:
|
||||
_check_banned_ipv4(ip_str)
|
||||
|
||||
|
||||
def _check_banned_ipv4(ip_str):
|
||||
if '/' in ip_str:
|
||||
ipaddress.IPv4Network(ip_str, strict=False)
|
||||
return
|
||||
|
||||
parts = ip_str.split('.')
|
||||
if len(parts) != 4:
|
||||
raise ValueError(f"Expected 4 octets: {ip_str!r}")
|
||||
|
||||
def parse_octet(s):
|
||||
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}")
|
||||
return (lo, hi)
|
||||
v = int(s)
|
||||
if not 0 <= v <= 255:
|
||||
raise ValueError(f"Octet {v} out of 0-255")
|
||||
return (v, v)
|
||||
|
||||
ranges = [parse_octet(p) for p in parts]
|
||||
|
||||
trailing = 0
|
||||
for lo, hi in reversed(ranges):
|
||||
if lo == 0 and hi == 255:
|
||||
trailing += 1
|
||||
else:
|
||||
break
|
||||
|
||||
total = 1
|
||||
for lo, hi in ranges[:4 - trailing]:
|
||||
total *= (hi - lo + 1)
|
||||
if total > 1024:
|
||||
raise ValueError(f"Pattern expands to {total} entries (limit 1024); use CIDR")
|
||||
|
||||
|
||||
def _check_banned_ipv6(ip_str):
|
||||
if '/' in ip_str:
|
||||
ipaddress.IPv6Network(ip_str, strict=False)
|
||||
return
|
||||
if '*' not in ip_str:
|
||||
ipaddress.IPv6Address(ip_str)
|
||||
return
|
||||
if not ip_str.endswith(':*'):
|
||||
raise ValueError(f"Unsupported IPv6 wildcard: {ip_str!r}; use 'prefix:*' or CIDR")
|
||||
prefix_part = ip_str[:-2]
|
||||
if '::' in prefix_part:
|
||||
left, right = prefix_part.split('::', 1)
|
||||
lg = [g for g in left.split(':') if g] if left else []
|
||||
rg = [g for g in right.split(':') if g] if right else []
|
||||
zeros = 8 - len(lg) - len(rg) - 1
|
||||
if zeros < 0:
|
||||
raise ValueError(f"Too many groups in {ip_str!r}")
|
||||
groups = lg + ['0000'] * zeros + rg
|
||||
else:
|
||||
groups = [g for g in prefix_part.split(':') if g]
|
||||
if not (1 <= len(groups) <= 7):
|
||||
raise ValueError(f"IPv6 wildcard must have 1-7 prefix groups: {ip_str!r}")
|
||||
|
|
@ -41,6 +41,7 @@ import sys
|
|||
import argparse
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timezone
|
||||
from validation import ip as validate_ip
|
||||
|
||||
SCRIPT_DIR = Path(__file__).parent
|
||||
DHCP_CONFIG_FILE = SCRIPT_DIR / "core.json"
|
||||
|
|
@ -48,12 +49,12 @@ DDNS_CONFIG_FILE = SCRIPT_DIR / "ddns.json"
|
|||
WG_DIR = Path("/etc/wireguard")
|
||||
KEEPALIVE = 25
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def die(msg):
|
||||
print(f"ERROR: {msg}")
|
||||
print(f"ERROR: {msg}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
def check_root():
|
||||
|
|
@ -63,7 +64,7 @@ def check_root():
|
|||
def chown_to_script_dir_owner(path):
|
||||
"""Chown a file to the owner of the script directory.
|
||||
Keeps SCRIPT_DIR files user-owned even when running as root.
|
||||
/etc/wireguard files are intentionally excluded — they stay root-owned.
|
||||
/etc/wireguard files are intentionally excluded - they stay root-owned.
|
||||
"""
|
||||
try:
|
||||
stat = SCRIPT_DIR.stat()
|
||||
|
|
@ -124,9 +125,9 @@ def _fmt_bytes(n):
|
|||
else:
|
||||
return f"{n / 1024**3:.2f} GB"
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Load core.json / dotfiles
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def load_dhcp():
|
||||
if not DHCP_CONFIG_FILE.exists():
|
||||
|
|
@ -169,9 +170,9 @@ def save_peers(iface, peers):
|
|||
path.chmod(0o600)
|
||||
chown_to_script_dir_owner(path)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# IP allocation
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def next_available_ip(vlan, peers):
|
||||
"""
|
||||
|
|
@ -197,9 +198,9 @@ def next_available_ip(vlan, peers):
|
|||
|
||||
die(f"No available IPs in VPN subnet {network} (all .2-.254 allocated).")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Key management
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def generate_server_key(iface):
|
||||
"""Generate server private key and store at WG_DIR/<iface>.key (600)."""
|
||||
|
|
@ -228,9 +229,9 @@ def generate_peer_keypair():
|
|||
).stdout.strip()
|
||||
return private, public
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Endpoint resolution
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def resolve_endpoint(listen_port):
|
||||
"""
|
||||
|
|
@ -294,9 +295,9 @@ def resolve_endpoint(listen_port):
|
|||
entry = f"{entry}:{listen_port}"
|
||||
return entry
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Split-tunnel route computation
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def split_tunnel_routes(dhcp_data):
|
||||
"""
|
||||
|
|
@ -316,9 +317,9 @@ def split_tunnel_routes(dhcp_data):
|
|||
routes.append(str(net))
|
||||
return routes
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Client config
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def build_client_conf(peer, private_key, server_public_key, endpoint,
|
||||
allowed_ips, dns, domain, mtu):
|
||||
|
|
@ -348,9 +349,9 @@ def write_client_conf(peer, private_key, server_public_key, endpoint,
|
|||
chown_to_script_dir_owner(conf_path)
|
||||
return conf_path
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# WireGuard server conf
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def build_wg_conf(vlan, peers, server_private_key):
|
||||
iface = vlan["interface"]
|
||||
|
|
@ -381,9 +382,9 @@ def build_wg_conf(vlan, peers, server_private_key):
|
|||
]
|
||||
return "\n".join(lines)
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Live peer sync
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def sync_peers_live(iface, peers):
|
||||
"""
|
||||
|
|
@ -418,9 +419,9 @@ def sync_peers_live(iface, peers):
|
|||
run(["wg", "set", iface, "peer", key, "remove"])
|
||||
print(f" Removed peer: {key[:16]}...")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Interface selection
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def validate_wg_vlans(wg_vlans):
|
||||
"""Die with a clear message if any wg VLAN is missing a valid vpn_information block."""
|
||||
|
|
@ -432,8 +433,11 @@ def validate_wg_vlans(wg_vlans):
|
|||
f"Add: \"vpn_information\": {{\"listen_port\": 51820, \"gateway\": \"...\"}}")
|
||||
if not isinstance(info.get("listen_port"), int):
|
||||
die(f"Interface '{iface}' vpn_information is missing a valid listen_port in core.json.")
|
||||
if not info.get("gateway"):
|
||||
gw = info.get("gateway", "")
|
||||
if not gw:
|
||||
die(f"Interface '{iface}' vpn_information is missing gateway in core.json.")
|
||||
elif not validate_ip(gw):
|
||||
die(f"Interface '{iface}' vpn_information.gateway '{gw}' is not a valid IP address.")
|
||||
|
||||
def pick_wg_interface(wg_vlans):
|
||||
"""
|
||||
|
|
@ -459,9 +463,9 @@ def pick_wg_interface(wg_vlans):
|
|||
pass
|
||||
print(" Invalid selection.")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# --add-peer
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def cmd_add_peer(dhcp_data):
|
||||
check_root()
|
||||
|
|
@ -569,9 +573,9 @@ def cmd_add_peer(dhcp_data):
|
|||
print(" sudo python3 vpn.py --apply")
|
||||
print()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# --list-peers
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def cmd_list_peers(dhcp_data):
|
||||
check_root()
|
||||
|
|
@ -718,9 +722,9 @@ def cmd_list_peers(dhcp_data):
|
|||
print(" sudo python3 vpn.py --apply")
|
||||
print()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# --apply
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def cmd_apply(dhcp_data):
|
||||
check_root()
|
||||
|
|
@ -808,9 +812,9 @@ def cmd_apply(dhcp_data):
|
|||
else:
|
||||
print(f"WARNING: {core_py} not found -- run core.py --apply manually to load VPN firewall rules.")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# --disable
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def cmd_disable(dhcp_data):
|
||||
check_root()
|
||||
|
|
@ -825,9 +829,9 @@ def cmd_disable(dhcp_data):
|
|||
else:
|
||||
print(f"WireGuard service {svc} stopped and disabled.")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# --status
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def cmd_status(dhcp_data):
|
||||
check_root()
|
||||
|
|
@ -844,8 +848,8 @@ def cmd_status(dhcp_data):
|
|||
r_enabled = run(["systemctl", "is-enabled", svc], check=False)
|
||||
active = r_active.stdout.strip()
|
||||
enabled = r_enabled.stdout.strip()
|
||||
active_sym = "✓" if active == "active" else "✗"
|
||||
enabled_sym = "✓" if enabled == "enabled" else "✗"
|
||||
active_sym = "+" if active == "active" else "x"
|
||||
enabled_sym = "+" if enabled == "enabled" else "x"
|
||||
print(f" {svc:<45} {active_sym} {active:<10} {enabled_sym} {enabled}")
|
||||
|
||||
if active == "active":
|
||||
|
|
@ -869,9 +873,9 @@ def cmd_status(dhcp_data):
|
|||
enabled_peers = [p for p in peers if p.get("enabled") is True]
|
||||
print(f" peers: {len(enabled_peers)} configured, {info.get('peers', 0)} connected")
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# --logs
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def cmd_logs(dhcp_data):
|
||||
check_root()
|
||||
|
|
@ -940,9 +944,9 @@ def cmd_logs(dhcp_data):
|
|||
|
||||
print()
|
||||
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
# Main
|
||||
# ------------------------------------------------------------------------------
|
||||
# ===================================================================
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue