534 lines
18 KiB
Python
534 lines
18 KiB
Python
#!/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"
|
|
DASHB_PENDING_FILE = SCRIPT_DIR / ".dashboard-pending"
|
|
|
|
|
|
# ===================================================================
|
|
# 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()
|
|
|
|
existing_key = re.search(r"^\s*- SECRET_KEY=(.+)$", content, re.MULTILINE)
|
|
if existing_key and existing_key.group(1).strip():
|
|
print(" Dashboard is already configured.")
|
|
if not prompt_yn("Reconfigure? (generates a new SECRET_KEY, invalidates existing sessions)", default="n"):
|
|
print()
|
|
print(" 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
|
|
|
|
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 Stopping existing container...")
|
|
subprocess.run(["docker", "compose", "down"], cwd=COMPOSE_FILE.parent, check=False)
|
|
|
|
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, DASHB_PENDING_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 f"127.0.0.1:{FLASK_PORT}" in existing:
|
|
print(f" Routlin block already present in {CADDYFILE}, skipping.")
|
|
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()
|