linuxrouter/routlin/install.py

584 lines
19 KiB
Python
Raw Normal View History

2026-05-21 03:23:31 -04:00
#!/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"
2026-05-22 01:09:23 -04:00
DASHB_PENDING_FILE = SCRIPT_DIR / ".dashboard-pending"
2026-05-22 01:47:01 -04:00
STATUS_FILE = SCRIPT_DIR / ".status"
2026-05-21 03:23:31 -04:00
# ===================================================================
# 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:
2026-05-21 03:45:14 -04:00
# Key not found; shouldn't happen with our template, warn and move on
2026-05-21 03:23:31 -04:00
print(f" WARNING: could not find {key} in docker-compose.yml")
return new
2026-05-22 02:01:52 -04:00
def _dash_already_configured():
if not COMPOSE_FILE.exists():
return False
return bool(re.search(r"^\s*- SECRET_KEY=\S", COMPOSE_FILE.read_text(), re.MULTILINE))
def setup_docker_compose(reuse_config=False):
2026-05-21 03:23:31 -04:00
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}")
2026-05-22 02:01:52 -04:00
if reuse_config:
print("\n Stopping existing container...")
subprocess.run(["docker", "compose", "down"], cwd=COMPOSE_FILE.parent, check=False)
print("\n Starting dashboard container...")
result = subprocess.run(
["docker", "compose", "up", "-d", "--build"],
cwd=COMPOSE_FILE.parent, check=False
)
if result.returncode != 0:
die("docker compose up failed. Check the output above.")
print(" Dashboard container started.")
return
2026-05-21 03:23:31 -04:00
2026-05-22 02:01:52 -04:00
content = COMPOSE_FILE.read_text()
2026-05-21 03:45:14 -04:00
2026-05-21 03:23:31 -04:00
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}")
2026-05-21 04:09:25 -04:00
print("\n Stopping existing container...")
subprocess.run(["docker", "compose", "down"], cwd=COMPOSE_FILE.parent, check=False)
2026-05-21 03:23:31 -04:00
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():
2026-05-22 01:47:01 -04:00
for f in (DASHB_QUEUE_FILE, DASHB_DONE_FILE, DASHB_LAST_RUN_FILE, DASHB_LOCK_FILE, DASHB_PENDING_FILE, STATUS_FILE):
2026-05-21 03:23:31 -04:00
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.")
2026-05-22 02:11:02 -04:00
def _external_access_domain():
"""Return the configured domain if a Routlin Caddy block exists, else None."""
if not CADDYFILE.exists():
return None
m = re.search(rf'(\S+)\s*\{{[^}}]*127\.0\.0\.1:{FLASK_PORT}', CADDYFILE.read_text(), re.DOTALL)
return m.group(1) if m else None
2026-05-21 03:23:31 -04:00
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()
2026-05-21 03:45:14 -04:00
if f"127.0.0.1:{FLASK_PORT}" in existing:
print(f" Routlin block already present in {CADDYFILE}, skipping.")
2026-05-21 03:23:31 -04:00
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():
2026-05-22 02:29:02 -04:00
"""Read the LAN IP from routlin's networkd config files.
Prefers the physical (untagged) interface its Name has no dot.
Falls back to the first address found across all routlin network files.
"""
2026-05-22 02:21:50 -04:00
import glob
2026-05-22 02:29:02 -04:00
fallback = None
2026-05-21 03:23:31 -04:00
try:
2026-05-22 02:21:50 -04:00
for path in sorted(glob.glob("/etc/systemd/network/10-routlin-*.network")):
2026-05-22 02:29:02 -04:00
content = Path(path).read_text()
name_m = re.search(r'^Name=(.+)$', content, re.MULTILINE)
addr_m = re.search(r'^Address=(\d+\.\d+\.\d+\.\d+)/', content, re.MULTILINE)
if not name_m or not addr_m:
continue
ip = addr_m.group(1)
if '.' not in name_m.group(1).strip(): # physical interface, no dot
return ip
if fallback is None:
fallback = ip
2026-05-21 03:23:31 -04:00
except Exception:
2026-05-22 02:21:50 -04:00
pass
2026-05-22 02:29:02 -04:00
return fallback or "this server"
2026-05-21 03:23:31 -04:00
# ===================================================================
# 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()
2026-05-22 02:01:52 -04:00
dash_installed = _dash_already_configured()
if dash_installed:
print(" Web dashboard is already installed.")
2026-05-22 02:05:12 -04:00
want_dashboard = prompt_yn("Rebuild?", default="y")
2026-05-22 02:01:52 -04:00
else:
want_dashboard = prompt_yn("Install the web dashboard?", default="y")
2026-05-21 03:23:31 -04:00
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
2026-05-22 02:01:52 -04:00
reuse_config = False
if dash_installed:
reuse_config = prompt_yn(
"Re-use existing configuration? (Keeps SECRET_KEY and SMTP credentials, preserving active sessions and email settings)",
default="y"
)
2026-05-21 03:23:31 -04:00
# -- 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 ----------------------------------------
2026-05-22 02:01:52 -04:00
setup_docker_compose(reuse_config=reuse_config)
2026-05-21 03:23:31 -04:00
create_dotfiles()
# -- External access -------------------------------------------
header("External Access (optional)")
2026-05-22 02:11:02 -04:00
ext_domain = _external_access_domain()
if ext_domain:
2026-05-22 02:13:39 -04:00
lan = _lan_ip()
2026-05-22 02:11:02 -04:00
print(f" External access to the web dashboard is already configured.")
print(f" https://{ext_domain}/")
2026-05-22 02:13:39 -04:00
print(f" The dashboard is also reachable on your local network at:")
print(f" http://{lan}:{FLASK_PORT}/")
2026-05-22 02:11:02 -04:00
print()
print("Done.")
return
2026-05-21 03:23:31 -04:00
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()