Development

This commit is contained in:
Matthew Grotke 2026-05-21 03:23:31 -04:00
parent d04e6ca8cc
commit 21db91d512
2 changed files with 520 additions and 54 deletions

View file

@ -73,7 +73,6 @@ Validation:
must be tcp, udp, or both.
Usage:
sudo python3 core.py --install Check and interactively install required packages
sudo python3 core.py --apply Apply config fast: restart running services only
sudo python3 core.py --update-blocklists Refresh blocklists and apply (used by timer)
sudo python3 core.py --status Show service and timer status
@ -183,11 +182,11 @@ def setup_logging(max_kb, errors_only):
# ===================================================================
def service_warning(action, svc, stderr):
"""Print a service start/restart warning, adding --install hint if unit not found."""
"""Print a service start/restart warning, adding install hint if unit not found."""
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 core.py --install")
print(f" -> Package may not be installed. Run: sudo python3 install.py")
def die(msg):
@ -802,34 +801,6 @@ def ensure_resolv_conf(data):
RESOLV_CONF.write_text(wanted)
print(f"Updated /etc/resolv.conf: nameserver {nameserver}")
def check_dependencies():
"""Check required packages are installed; prompt to install missing ones via apt."""
import shutil
checks = [
("dnsmasq", "dnsmasq", "DHCP server and DNS resolver"),
("nft", "nftables", "firewall and NAT"),
("chronyd", "chrony", "NTP server"),
("freeradius", "freeradius", "RADIUS server for dynamic VLAN assignment"),
("avahi-daemon", "avahi-daemon", "mDNS reflector for cross-VLAN service discovery"),
]
missing = [(pkg, purpose) for binary, pkg, purpose in checks if not shutil.which(binary)]
if not missing:
return
print("The following required packages are not installed:")
for pkg, purpose in missing:
print(f" {pkg:<16} {purpose}")
print()
while True:
choice = input("Install them now via apt? [y/N]: ").strip().lower()
if choice in ("y", "yes"):
break
if choice in ("n", "no", ""):
die("Cannot continue without required packages. Install them and retry.")
result = subprocess.run(["apt-get", "install", "-y"] + [p for p, _ in missing])
if result.returncode != 0:
die("Package installation failed. Install manually and retry.")
def disable_systemd_resolved():
"""Stop and disable systemd-resolved if it is active."""
@ -2137,7 +2108,7 @@ def apply_avahi(data):
import shutil
if not shutil.which("avahi-daemon"):
print("avahi-daemon is not installed.")
print(" -> Run: sudo python3 core.py --install")
print(" -> Run: sudo python3 install.py")
return
ifaces = avahi_interfaces(data)
@ -2147,7 +2118,7 @@ def apply_avahi(data):
return
if not AVAHI_CONF_FILE.exists():
print(f"WARNING: {AVAHI_CONF_FILE} not found. Run: sudo python3 core.py --install")
print(f"WARNING: {AVAHI_CONF_FILE} not found. Run: sudo python3 install.py")
return
content = build_avahi_conf(data)
@ -3063,20 +3034,6 @@ def cmd_disable(data, dry_run=False):
# ===================================================================
def cmd_install(data):
"""--install: check and interactively install required packages."""
check_root()
check_dependencies()
print("All required packages are installed.")
install_dashboard_timer()
# Create blank dotfiles for dashboard updates
for dotfile in (DASHB_QUEUE_FILE, DASHB_DONE_FILE, DASHB_LAST_RUN_FILE, DASHB_LOCK_FILE):
if not dotfile.exists():
dotfile.touch()
chown_to_script_dir_owner(dotfile)
print(f"Created: {dotfile}")
def cmd_apply(data, dry_run=False):
"""--apply: full apply. Handles conflicting services, networkd (if changed),
dnsmasq confs, start/restart all services whose interface is up, nftables,
@ -3230,7 +3187,6 @@ def main():
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"examples:\n"
" sudo python3 core.py --install Install required packages\n"
" sudo python3 core.py --apply Apply full config (idempotent, safe to re-run)\n"
" sudo python3 core.py --update-blocklists Refresh blocklists and apply\n"
" sudo python3 core.py --status Show service and timer status\n"
@ -3246,7 +3202,6 @@ def main():
" sudo python3 core.py --disable --dry-run\n"
)
)
parser.add_argument("--install", action="store_true", help="Check and interactively install required packages")
parser.add_argument("--apply", action="store_true", help="Apply full config: services, networkd, dnsmasq, nftables, timer, boot service")
parser.add_argument("--update-blocklists", action="store_true", help="Refresh blocklists and apply (used by timer)")
parser.add_argument("--dry-run", action="store_true", help="Preview all actions without making changes (combine with --apply or --disable)")
@ -3264,7 +3219,7 @@ def main():
update_blocklists_flag = getattr(args, "update_blocklists", False)
if not any([args.install, args.apply, update_blocklists_flag,
if not any([args.apply, update_blocklists_flag,
args.dry_run, args.status, args.view_configs, args.view_leases,
args.view_rules, args.disable, args.view_metrics,
args.reset_leases]):
@ -3320,10 +3275,6 @@ def main():
cmd_disable(data, dry_run=args.dry_run)
return
if args.install:
cmd_install(data)
return
if update_blocklists_flag:
cmd_update_blocklists(data)
return

515
routlin/install.py Normal file
View file

@ -0,0 +1,515 @@
#!/usr/bin/env python3
"""
install.py -- Routlin one-time setup wizard.
Installs required system packages, optionally sets up the web dashboard
(Docker + docker-compose), and optionally configures external HTTPS access
via Caddy.
Usage:
sudo python3 install.py
"""
import os
import re
import secrets
import shutil
import socket
import subprocess
import sys
from pathlib import Path
SCRIPT_DIR = Path(__file__).parent.resolve()
COMPOSE_FILE = SCRIPT_DIR.parent / "docker" / "routlin-dash" / "docker-compose.yml"
CADDYFILE = Path("/etc/caddy/Caddyfile")
FLASK_PORT = 25327
# Per-VLAN dnsmasq dotfiles (parallel to core.py constants)
DASHB_QUEUE_FILE = SCRIPT_DIR / ".dashboard-queue"
DASHB_DONE_FILE = SCRIPT_DIR / ".dashboard-done"
DASHB_LAST_RUN_FILE = SCRIPT_DIR / ".dashboard-last-run"
DASHB_LOCK_FILE = SCRIPT_DIR / ".dashboard-lock"
# ===================================================================
# Helpers
# ===================================================================
def die(msg):
print(f"\nERROR: {msg}", file=sys.stderr)
sys.exit(1)
def check_root():
if os.geteuid() != 0:
die("This script must be run as root (sudo python3 install.py).")
def prompt_yn(question, default=None):
"""Prompt for yes/no. default: 'y', 'n', or None (require explicit answer)."""
hint = {None: "[y/n]", "y": "[Y/n]", "n": "[y/N]"}[default]
while True:
ans = input(f" {question} {hint}: ").strip().lower()
if ans in ("y", "yes"):
return True
if ans in ("n", "no"):
return False
if ans == "" and default:
return default == "y"
print(" Please enter y or n.")
def prompt_str(question, default=None, secret=False):
"""Prompt for a string value."""
hint = f" [{default}]" if default else ""
if secret:
import getpass
ans = getpass.getpass(f" {question}{hint}: ")
else:
ans = input(f" {question}{hint}: ").strip()
if not ans and default is not None:
return default
return ans
def run(cmd, check=True, capture=False):
"""Run a shell command list."""
return subprocess.run(cmd, check=check,
capture_output=capture, text=True)
def header(title):
print()
print("=" * 62)
print(f" {title}")
print("=" * 62)
print()
# ===================================================================
# Package manager detection
# ===================================================================
def detect_pm():
"""Return 'apt', 'dnf', 'yum', 'pacman', 'zypper', or None."""
for pm in ("apt-get", "dnf", "yum", "pacman", "zypper"):
if shutil.which(pm):
return pm.replace("-get", "") # apt-get -> apt
return None
# Core packages: binary-to-check -> {pm: package-name}
_CORE_PKGS = {
"dnsmasq": {
"apt": "dnsmasq",
"dnf": "dnsmasq",
"yum": "dnsmasq",
"pacman": "dnsmasq",
"zypper": "dnsmasq",
},
"nft": {
"apt": "nftables",
"dnf": "nftables",
"yum": "nftables",
"pacman": "nftables",
"zypper": "nftables",
},
"chronyd": {
"apt": "chrony",
"dnf": "chrony",
"yum": "chrony",
"pacman": "chrony",
"zypper": "chrony",
},
"freeradius": {
"apt": "freeradius",
"dnf": "freeradius",
"yum": "freeradius",
"pacman": "freeradius",
"zypper": "freeradius",
},
"avahi-daemon": {
"apt": "avahi-daemon",
"dnf": "avahi",
"yum": "avahi",
"pacman": "avahi",
"zypper": "avahi",
},
}
_INSTALL_CMD = {
"apt": ["apt-get", "install", "-y"],
"dnf": ["dnf", "install", "-y"],
"yum": ["yum", "install", "-y"],
"pacman": ["pacman", "-S", "--noconfirm"],
"zypper": ["zypper", "install", "-y"],
}
def install_core_packages(pm):
header("Core Package Installation")
missing = []
for binary, pm_map in _CORE_PKGS.items():
if not shutil.which(binary):
pkg = pm_map.get(pm)
if pkg:
missing.append((binary, pkg))
else:
print(f" WARNING: no package mapping for '{binary}' on {pm}. Install manually.")
if not missing:
print(" All required packages are already installed.")
return
print(" The following packages are not installed:")
for binary, pkg in missing:
print(f" {pkg} (provides: {binary})")
print()
if not prompt_yn("Install them now?", default="y"):
die("Cannot continue without required packages.")
packages = [pkg for _, pkg in missing]
cmd = _INSTALL_CMD[pm] + packages
print()
result = subprocess.run(cmd)
if result.returncode != 0:
die("Package installation failed. Install manually and retry.")
print("\n Core packages installed.")
# ===================================================================
# Docker
# ===================================================================
def _docker_installed():
return shutil.which("docker") is not None
def install_docker(pm):
if _docker_installed():
print(" Docker is already installed.")
return
print(" Docker is not installed.")
print()
if pm in ("apt", "dnf", "yum"):
if not prompt_yn("Install Docker via the official get.docker.com script?", default="y"):
die("Docker is required for the dashboard. Install it manually and re-run.")
print()
result = subprocess.run(
["bash", "-c", "curl -fsSL https://get.docker.com | sh"],
check=False
)
if result.returncode != 0:
die("Docker installation failed. Install manually and re-run.")
# Add current user to docker group
sudo_user = os.environ.get("SUDO_USER")
if sudo_user:
subprocess.run(["usermod", "-aG", "docker", sudo_user], check=False)
print(f"\n Added {sudo_user} to the docker group.")
print("\n Docker installed.")
else:
print(" Automatic Docker installation is not supported on this package manager.")
print(" Please install Docker manually, then re-run install.py.")
die("Docker required.")
# ===================================================================
# docker-compose.yml setup
# ===================================================================
def _set_env_var(content, key, value):
"""Replace ' - KEY=...' line in docker-compose env block."""
pattern = rf"^(\s*- {re.escape(key)}=).*$"
replacement = rf"\g<1>{value}"
new, count = re.subn(pattern, replacement, content, flags=re.MULTILINE)
if count == 0:
# Key not found — shouldn't happen with our template, warn and move on
print(f" WARNING: could not find {key} in docker-compose.yml")
return new
def setup_docker_compose():
header("Dashboard Configuration")
if not COMPOSE_FILE.exists():
die(f"docker-compose.yml not found at {COMPOSE_FILE}\n"
f" Ensure the routlin-dash directory is at {COMPOSE_FILE.parent}")
content = COMPOSE_FILE.read_text()
print(" Generating SECRET_KEY...")
secret_key = secrets.token_urlsafe(96) # ~128 chars
print()
print(" SMTP is used to send email verification codes for new accounts.")
print(" (Gmail users: use an App Password, not your account password.)")
print()
manager_email = prompt_str("Initial manager account email")
while not manager_email or "@" not in manager_email:
print(" Please enter a valid email address.")
manager_email = prompt_str("Initial manager account email")
smtp_host = prompt_str("SMTP host", default="smtp.gmail.com")
smtp_port = prompt_str("SMTP port", default="587")
smtp_user = prompt_str("SMTP username (email)")
smtp_password = prompt_str("SMTP password", secret=True)
smtp_from = prompt_str("SMTP From address", default=smtp_user)
content = _set_env_var(content, "SECRET_KEY", secret_key)
content = _set_env_var(content, "INITIAL_MANAGER_EMAIL", manager_email)
content = _set_env_var(content, "SMTP_HOST", smtp_host)
content = _set_env_var(content, "SMTP_PORT", smtp_port)
content = _set_env_var(content, "SMTP_USER", smtp_user)
content = _set_env_var(content, "SMTP_PASSWORD", smtp_password)
content = _set_env_var(content, "SMTP_FROM", smtp_from)
COMPOSE_FILE.write_text(content)
print(f"\n Written: {COMPOSE_FILE}")
print("\n Starting dashboard container...")
compose_dir = COMPOSE_FILE.parent
result = subprocess.run(
["docker", "compose", "up", "-d", "--build"],
cwd=compose_dir, check=False
)
if result.returncode != 0:
die("docker compose up failed. Check the output above.")
print(" Dashboard container started.")
def create_dotfiles():
for f in (DASHB_QUEUE_FILE, DASHB_DONE_FILE, DASHB_LAST_RUN_FILE, DASHB_LOCK_FILE):
if not f.exists():
f.touch()
# chown to the routlin dir owner so the timer can write
stat = SCRIPT_DIR.stat()
os.chown(f, stat.st_uid, stat.st_gid)
# ===================================================================
# Caddy
# ===================================================================
def _caddy_installed():
return shutil.which("caddy") is not None
def install_caddy(pm):
if _caddy_installed():
print(" Caddy is already installed.")
return
print(" Caddy is not installed.")
print()
if pm == "apt":
if not prompt_yn("Install Caddy via the official Caddy apt repository?", default="y"):
die("Caddy is required for external access. Install manually and re-run.")
print()
cmds = [
["apt-get", "install", "-y", "debian-keyring", "debian-archive-keyring", "apt-transport-https", "curl"],
["bash", "-c",
"curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' "
"| gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg"],
["bash", "-c",
"curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' "
"| tee /etc/apt/sources.list.d/caddy-stable.list"],
["apt-get", "update"],
["apt-get", "install", "-y", "caddy"],
]
for cmd in cmds:
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
die("Caddy installation failed. Install manually and re-run.")
print("\n Caddy installed.")
elif pm in ("dnf", "yum"):
if not prompt_yn("Install Caddy via COPR (dnf/yum)?", default="y"):
die("Caddy is required for external access. Install manually and re-run.")
print()
cmds = [
[pm, "copr", "enable", "-y", "@caddy/caddy"],
[pm, "install", "-y", "caddy"],
]
for cmd in cmds:
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
die("Caddy installation failed. Install manually and re-run.")
print("\n Caddy installed.")
else:
print(" Automatic Caddy installation is not supported on this package manager.")
print(" Install Caddy manually from https://caddyserver.com/docs/install")
print(" then re-run install.py.")
die("Caddy required for external access.")
def setup_caddy(domain, email):
block = (
f"\n# Routlin Dashboard\n"
f"{domain} {{\n"
f" encode gzip\n"
f" tls {email}\n"
f" reverse_proxy 127.0.0.1:{FLASK_PORT}\n"
f"}}\n"
)
if CADDYFILE.exists():
existing = CADDYFILE.read_text()
if domain in existing:
print(f" {domain} is already present in {CADDYFILE} — skipping block.")
else:
CADDYFILE.write_text(existing + block)
print(f" Appended {domain} block to {CADDYFILE}")
else:
CADDYFILE.parent.mkdir(parents=True, exist_ok=True)
CADDYFILE.write_text(block.lstrip())
print(f" Written: {CADDYFILE}")
# Start or reload
active = subprocess.run(
["systemctl", "is-active", "caddy"],
capture_output=True, text=True
).stdout.strip() == "active"
if active:
subprocess.run(["systemctl", "reload", "caddy"], check=False)
print(" Caddy reloaded.")
else:
subprocess.run(["systemctl", "enable", "--now", "caddy"], check=False)
print(" Caddy enabled and started.")
def _lan_ip():
"""Best-effort local LAN IP for the informational message."""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
return s.getsockname()[0]
except Exception:
return "127.0.0.1"
# ===================================================================
# Main
# ===================================================================
def main():
check_root()
print()
print("=" * 62)
print(" Routlin Setup")
print("=" * 62)
print()
print(" This wizard installs required packages and optionally")
print(" sets up the web dashboard and external HTTPS access.")
# -- Package manager -------------------------------------------
pm = detect_pm()
if pm is None:
print()
print(" WARNING: Could not detect a supported package manager.")
print(" Supported: apt, dnf, yum, pacman, zypper")
print(" Please install packages manually.")
pm_ok = False
else:
print(f"\n Detected package manager: {pm}")
pm_ok = True
# -- Core packages ---------------------------------------------
if pm_ok:
install_core_packages(pm)
# -- Dashboard -------------------------------------------------
header("Dashboard (optional)")
print(" The Routlin Dashboard is a web UI for managing the router.")
print(" It runs as a Docker container. Without it, core.json must")
print(" be edited manually.")
print()
want_dashboard = prompt_yn("Install the web dashboard?", default="y")
if not want_dashboard:
print()
print(" Skipping dashboard setup.")
print(" Edit ~/routlin/core.json manually, then run:")
print(" sudo python3 ~/routlin/core.py --apply")
print()
print("Done.")
return
# -- Docker ----------------------------------------------------
header("Docker")
if pm_ok:
install_docker(pm)
elif not _docker_installed():
die("Docker is required for the dashboard. Install it manually and re-run.")
else:
print(" Docker is already installed.")
# -- docker-compose.yml ----------------------------------------
setup_docker_compose()
create_dotfiles()
# -- External access -------------------------------------------
header("External Access (optional)")
print(" External access lets you reach the dashboard from outside")
print(" your LAN via HTTPS using a domain name and Caddy.")
print()
want_external = prompt_yn("Configure external HTTPS access?", default="n")
if not want_external:
lan = _lan_ip()
print()
print(" Dashboard is accessible on your local network at:")
print(f" http://{lan}:{FLASK_PORT}/")
print()
print("Done.")
return
# -- Caddy -----------------------------------------------------
header("Caddy (HTTPS)")
if pm_ok:
install_caddy(pm)
elif not _caddy_installed():
print(" Caddy is not installed. Install it from https://caddyserver.com/docs/install")
die("Caddy required for external access.")
else:
print(" Caddy is already installed.")
print()
domain = prompt_str("Domain name for the dashboard (e.g. routlin.example.com)")
while not domain or "." not in domain:
print(" Please enter a valid domain name.")
domain = prompt_str("Domain name")
email = prompt_str("Email address for TLS certificate (Let's Encrypt)")
while not email or "@" not in email:
print(" Please enter a valid email address.")
email = prompt_str("Email address for TLS certificate")
print()
setup_caddy(domain, email)
print()
print(" Dashboard will be accessible at:")
print(f" https://{domain}/")
print()
print(" NOTE: HTTPS certificate provisioning may take up to a minute.")
print(" Ensure DNS for your domain points to this machine's WAN IP")
print(" and that port 80 and 443 are forwarded to this machine.")
print()
print("Done.")
if __name__ == "__main__":
main()