diff --git a/routlin/core.py b/routlin/core.py index 9e6596f..3e1e229 100644 --- a/routlin/core.py +++ b/routlin/core.py @@ -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 diff --git a/routlin/install.py b/routlin/install.py new file mode 100644 index 0000000..bebd3ae --- /dev/null +++ b/routlin/install.py @@ -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()