Development

This commit is contained in:
Matthew Grotke 2026-06-05 01:48:27 -04:00
parent 205d6889df
commit 58ab569e42
27 changed files with 2894 additions and 2605 deletions

137
routlin/mod_shared.py Normal file
View 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"