#!/usr/bin/env python3 import ipaddress, os, re, subprocess, tempfile, json, datetime, shlex import secrets from pathlib import Path from dotenv import load_dotenv from utils.wg_easy import get_env_auth, ensure_client_and_config load_dotenv() # wg-easy connectivity BASE = os.getenv("WG_EASY_BASE_URL", "").rstrip("/") USER = os.getenv("WG_EASY_USERNAME", "") PASS = os.getenv("WG_EASY_PASSWORD", "") POOL_CIDR = os.getenv("POOL_CIDR", "10.8.0.0/24") WG_IFACE = os.getenv("WG_INTERFACE", "wg0") # SSH to IoT device SSH_USER = os.getenv("IOT_SSH_USER", "caster") SSH_PORT = int(os.getenv("IOT_SSH_PORT", "22")) SSH_KEY = os.getenv("SSH_KEY") or None # path or None PROVISION_LOG = os.getenv("PROVISION_LOG") or str((Path(__file__).resolve().parent / "provision.log")) def rewrite_allowed_ips(config_text: str, allowed_cidr: str = None) -> str: """Rewrite AllowedIPs in a WireGuard config to only route VPN traffic. By default, wg-easy generates configs with AllowedIPs = 0.0.0.0/0 which routes ALL traffic through the VPN, making the device unreachable on the local network. This rewrites it to only route traffic destined for the VPN network. """ if allowed_cidr is None: allowed_cidr = POOL_CIDR # Replace AllowedIPs = 0.0.0.0/0 (and optional ::/0 for IPv6) with just the VPN CIDR # Handles various formats: "0.0.0.0/0", "0.0.0.0/0, ::/0", "0.0.0.0/0,::/0" config_text = re.sub( r"(AllowedIPs\s*=\s*)0\.0\.0\.0/0[^\n]*", rf"\g<1>{allowed_cidr}", config_text ) return config_text def scp_and_enable(ssh_host, config_text): tmp = Path(tempfile.gettempdir()) / f"{WG_IFACE}.conf" tmp.write_text(config_text) scp_cmd = ["scp", "-P", str(SSH_PORT)] if SSH_KEY: scp_cmd += ["-i", SSH_KEY] scp_cmd += [str(tmp), f"{SSH_USER}@{ssh_host}:/tmp/{WG_IFACE}.conf"] subprocess.run(scp_cmd, check=True) remote = f"""set -e sudo mkdir -p /etc/wireguard sudo install -m 600 /tmp/{WG_IFACE}.conf /etc/wireguard/{WG_IFACE}.conf # If interface exists already, bring it down first to reload config cleanly sudo systemctl stop wg-quick@{WG_IFACE} || sudo wg-quick down {WG_IFACE} || true sudo ip link del {WG_IFACE} || true sudo systemctl reset-failed wg-quick@{WG_IFACE} || true sudo systemctl daemon-reload || true # Bring interface up and ensure service is enabled+active sudo systemctl enable --now wg-quick@{WG_IFACE} sudo systemctl is-enabled wg-quick@{WG_IFACE} || true sudo systemctl is-active wg-quick@{WG_IFACE} || true sudo wg show {WG_IFACE} || true """ ssh_cmd = ["ssh", "-p", str(SSH_PORT)] if SSH_KEY: ssh_cmd += ["-i", SSH_KEY] ssh_cmd += [f"{SSH_USER}@{ssh_host}", remote] subprocess.run(ssh_cmd, check=True) def ssh_capture(ssh_host: str, command: str) -> str: """Run a command on the remote host over SSH and capture stdout (stripped).""" ssh_cmd = ["ssh", "-p", str(SSH_PORT)] if SSH_KEY: ssh_cmd += ["-i", SSH_KEY] ssh_cmd += [f"{SSH_USER}@{ssh_host}", command] out = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True) if out.returncode != 0: return "" return (out.stdout or "").strip() def get_device_facts(ssh_host: str) -> dict: """Collect basic device facts (serial, mac, hostname) via SSH best-effort.""" # serial number (prefer device-tree) serial = ssh_capture(ssh_host, "cat /proc/device-tree/serial-number 2>/dev/null || true") if not serial: serial = ssh_capture(ssh_host, "awk -F ': *' '/Serial/ {print $2}' /proc/cpuinfo 2>/dev/null || true") # Some systems expose a trailing NUL in DTB serial; strip it explicitly serial = serial.replace("\x00", "").strip() if serial else "" # hostname hostname = ssh_capture(ssh_host, "hostname 2>/dev/null || true") # MAC: try eth0 mac = ssh_capture(ssh_host, "cat /sys/class/net/eth0/address 2>/dev/null || true") return {"serial": serial, "hostname": hostname, "mac": mac} def write_provision_log(entry: dict): """Append a JSON line to the provisioning log with timestamp.""" # Use timezone-aware UTC timestamp to avoid DeprecationWarning ts = datetime.datetime.now(datetime.timezone.utc).isoformat().replace("+00:00", "Z") enriched = {"ts": ts, **entry} try: with open(PROVISION_LOG, "a", encoding="utf-8") as f: f.write(json.dumps(enriched, ensure_ascii=False) + "\n") except Exception as e: print(f"⚠️ Failed to write log {PROVISION_LOG}: {e}") def step_wireguard_provision(iot_host: str, client_name: str): """Create or reuse a wg-easy client, fetch config, copy to device and enable it. Returns a dict with wg_name. """ name = client_name base, auth = get_env_auth() cid, iface, cfg = ensure_client_and_config(base, auth, name) # Rewrite AllowedIPs to only route VPN traffic, preserving local network access cfg = rewrite_allowed_ips(cfg, POOL_CIDR) scp_and_enable(iot_host, cfg) return {"wg_name": name, "wg_iface": iface} def step_set_eth1_mac(iot_host: str): """Generate a random locally administered MAC (02:xx:xx:xx:xx:xx) for eth1 and configure it via systemd .link on the remote device. Prints that a reboot is required. Returns a dict with the chosen mac. """ mac = "02:" + ":".join(f"{secrets.randbelow(256):02x}" for _ in range(5)) remote = ( "set -e\n" "sudo mkdir -p /etc/systemd/network\n" "sudo tee /etc/systemd/network/10-eth1-mac.link >/dev/null <<'EOF'\n" "[Match]\n" "Driver=smsc95xx\n" "\n" "[Link]\n" f"MACAddress={mac}\n" "EOF\n" ) ssh_cmd = ["ssh", "-p", str(SSH_PORT)] if SSH_KEY: ssh_cmd += ["-i", SSH_KEY] ssh_cmd += [f"{SSH_USER}@{iot_host}", remote] proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True) stdout = (proc.stdout or "").strip() stderr = (proc.stderr or "").strip() if proc.returncode != 0: print(f"❌ set eth1 mac: failed rc={proc.returncode}: {stderr}") else: print(f"✅ set eth1 mac: configured {mac} at /etc/systemd/network/10-eth1-mac.link") print("ℹ️ Reboot is required for the MAC change to take effect.") return { "rc": proc.returncode, "mac": mac, "out": stdout[-500:], "err": stderr[-500:], } def step_set_hostname(iot_host: str, hostname: str | None): """Set hostname on the device by running the project's provision script over SSH. Executes: /home/caster/bumble-auracast/src/auracast/server/provision_domain_hostname.sh Domain is always 'local'. Returns a dict including whether the hostname appears changed. """ if not hostname: print("⏭️ hostname: no hostname provided, skipping") return {"hostname": None, "changed": False} # Known locations where the script might live on the device candidates = [ "/home/caster/bumble-auracast/src/auracast/server/provision_domain_hostname.sh", ] domain = "local" # Build remote command. Use sudo since hostname changes typically require elevated privileges. quoted_name = shlex.quote(hostname) quoted_domain = shlex.quote(domain) # Build a remote snippet that picks the first existing candidate and runs it via bash # Using bash avoids executable-bit/shebang issues. remote_candidates = " ".join(shlex.quote(c) for c in candidates) remote = ( "set -e\n" f"for p in {remote_candidates}; do\n" " if [ -f \"$p\" ]; then sp=\"$p\"; break; fi\n" "done\n" "if [ -z \"${sp:-}\" ]; then echo 'script not found in any candidate path' >&2; exit 1; fi\n" f"sudo bash \"$sp\" {quoted_name} {quoted_domain}\n" "hostname 2>/dev/null || true\n" ) ssh_cmd = ["ssh", "-p", str(SSH_PORT)] if SSH_KEY: ssh_cmd += ["-i", SSH_KEY] ssh_cmd += [f"{SSH_USER}@{iot_host}", remote] proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True) stdout = (proc.stdout or "").strip() stderr = (proc.stderr or "").strip() # After running the script, re-check the hostname from the device facts_post = get_device_facts(iot_host) new_hostname = facts_post.get("hostname") changed = bool(new_hostname) and (new_hostname == hostname) if proc.returncode != 0: print(f"❌ hostname: remote script failed rc={proc.returncode}: {stderr}") else: print(f"✅ hostname: script executed. device hostname now '{new_hostname}' (rc={proc.returncode})") return { "hostname": hostname, "domain": domain, "device_hostname": new_hostname, "changed": changed, "rc": proc.returncode, "out": stdout[-500:], # tail to keep logs compact "err": stderr[-500:], } def step_git_pull(iot_host: str, branch: str = "main"): """Fetch latest tags from main branch and checkout the latest tag. Executes git fetch, finds the latest tag, and checks it out in ~/bumble-auracast. """ remote = ( "set -e\n" "cd ~/bumble-auracast\n" "git remote set-url origin https://gitea.summitwave.work/auracaster/bumble-auracast\n" f"git fetch origin {shlex.quote(branch)} --tags\n" "LATEST_TAG=$(git tag --sort=-v:refname | head -n 1)\n" "if [ -z \"$LATEST_TAG\" ]; then\n" " echo 'No tags found, falling back to main branch' >&2\n" f" git checkout {shlex.quote(branch)}\n" " git pull origin " + shlex.quote(branch) + "\n" "else\n" " echo \"Checking out latest tag: $LATEST_TAG\"\n" " git checkout \"$LATEST_TAG\"\n" "fi\n" ) ssh_cmd = ["ssh", "-p", str(SSH_PORT)] if SSH_KEY: ssh_cmd += ["-i", SSH_KEY] ssh_cmd += [f"{SSH_USER}@{iot_host}", remote] proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True) stdout = (proc.stdout or "").strip() stderr = (proc.stderr or "").strip() if proc.returncode != 0: print(f"❌ git pull: failed rc={proc.returncode}: {stderr}") else: print("✅ git pull: completed (latest tag checked out)") return { "rc": proc.returncode, "out": stdout[-500:], "err": stderr[-500:], } def step_update_app(iot_host: str): """Install dependencies using poetry for the checked-out code. Assumes code is already at the correct version (via step_git_pull). """ remote = ( "set -e\n" "cd ~/bumble-auracast\n" "/home/caster/.local/bin/poetry config virtualenvs.in-project true\n" "/home/caster/.local/bin/poetry install\n" ) ssh_cmd = ["ssh", "-p", str(SSH_PORT)] if SSH_KEY: ssh_cmd += ["-i", SSH_KEY] ssh_cmd += [f"{SSH_USER}@{iot_host}", remote] proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True) stdout = (proc.stdout or "").strip() stderr = (proc.stderr or "").strip() if proc.returncode != 0: print(f"❌ update app: failed rc={proc.returncode}: {stderr}") else: print("✅ update app: poetry install completed") return { "rc": proc.returncode, "out": stdout[-500:], "err": stderr[-500:], } def step_start_app(iot_host: str, app: str): """Placeholder: start the application flow (script or UI) on the device. Intention: depending on --app, run an app launcher over SSH. Currently does nothing. """ scripts = [ "src/service/update_and_run_pw_aes67.sh", "src/service/update_and_run_server_and_frontend.sh", ] remote = ( "set -e\n" "sudo loginctl enable-linger \"$USER\"\n" "cd ~/bumble-auracast\n" "for s in " + " ".join(shlex.quote(s) for s in scripts) + "; do\n" " if [ -f \"$s\" ]; then\n" " echo \"▶ running $s\"\n" " bash \"$s\"\n" " else\n" " echo \"⚠️ missing $s\" 1>&2\n" " fi\n" "done\n" "# Ensure services are enabled and started\n" "systemctl --user daemon-reload\n" "systemctl --user enable --now auracast-server.service\n" "sudo systemctl daemon-reload\n" "sudo systemctl enable --now auracast-frontend.service\n" "systemctl --user is-enabled auracast-server.service || true\n" "systemctl --user is-active auracast-server.service || true\n" "sudo systemctl is-enabled auracast-frontend.service || true\n" "sudo systemctl is-active auracast-frontend.service || true\n" ) ssh_cmd = ["ssh", "-p", str(SSH_PORT)] if SSH_KEY: ssh_cmd += ["-i", SSH_KEY] ssh_cmd += [f"{SSH_USER}@{iot_host}", remote] proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True) stdout = (proc.stdout or "").strip() stderr = (proc.stderr or "").strip() if proc.returncode != 0: print(f"❌ start app: failed rc={proc.returncode}") if stderr: print(f"stderr: {stderr[-1000:]}") if stdout: print(f"stdout: {stdout[-1000:]}") else: print("✅ start app: scripts executed") if stdout: print(f"Output:\n{stdout[-1000:]}") return { "app_mode": app, "rc": proc.returncode, "out": stdout[-1000:], "err": stderr[-1000:], } def step_add_ssh_key(iot_host: str): """Add Paul's SSH key to the device's authorized_keys. Adds the SSH key for user 'paul' to the caster user's authorized_keys. """ ssh_key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABgQDDg4R0lZEGAlaJnMBYi0ZuX9tZ7aJtpeYTY0JcffYZjU3ynY/GEvonvMcQq2pdO1OY1awqZQ4drAhQc195MDZCFS6iof6AsGU17MEIEEmFvIANbLwGFYFv0fDwDAZLdY4HZtEIyNZZkfX32O0v1xSSrueFM8N6PkCYQBjhRFLZpBi5jkwk1nnnATN/mGpaDBbvKpWU2FS+PlwKRhm/bF6pKuQ/eXgO7k4fvM6aegtdHNARfMR9yK6/5s5vo45o1NbSbJ4sK3Vf0TdSjlWQSyu2e9D+Xomt0+fBpvGL+yl/7bc9AKq5ZlJNEA3XMjuihNlDoIglvSAYiDOTq09pocVq+myLwDKCfobX8cfHNDTrsWevuZKKTolP6BGfcX3MEWyc/md8ndsSJi49XakdzBhMqVzXmLq9CKBw0QyZID3CuWG8NeRuqZZMGSs0GCdlYF4YqHBhH1icoNgysZ4g7kQLstnTh8ZDcNHEWTxM1ZKCh12XOPvtq506/DTN1aMM0H0= paul@paul-Yoga-Pro-7-14APH8" remote = ( "set -e\n" "mkdir -p ~/.ssh\n" "chmod 700 ~/.ssh\n" "echo " + shlex.quote(ssh_key) + " >> ~/.ssh/authorized_keys\n" "chmod 600 ~/.ssh/authorized_keys\n" "echo 'SSH key for paul added successfully'\n" ) ssh_cmd = ["ssh", "-p", str(SSH_PORT)] if SSH_KEY: ssh_cmd += ["-i", SSH_KEY] ssh_cmd += [f"{SSH_USER}@{iot_host}", remote] proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True) stdout = (proc.stdout or "").strip() stderr = (proc.stderr or "").strip() if proc.returncode != 0: print(f"❌ add ssh key: failed rc={proc.returncode}: {stderr}") else: print("✅ add ssh key: Paul's SSH key added successfully") return { "rc": proc.returncode, "out": stdout[-500:], "err": stderr[-500:], } def step_finish(iot_host: str): """Finalize setup on the device: enable linger for the user and reboot. Executes on remote: - loginctl enable-linger "$USER" - sudo reboot """ remote = ( "set -e\n" "sudo reboot\n" ) ssh_cmd = ["ssh", "-p", str(SSH_PORT)] if SSH_KEY: ssh_cmd += ["-i", SSH_KEY] ssh_cmd += [f"{SSH_USER}@{iot_host}", remote] proc = subprocess.run(ssh_cmd, check=False, capture_output=True, text=True) stdout = (proc.stdout or "").strip() stderr = (proc.stderr or "").strip() if proc.returncode != 0: print(f"❌ finish: failed rc={proc.returncode}: {stderr}") else: print("✅ finish: reboot initiated") return { "rc": proc.returncode, "out": stdout[-500:], "err": stderr[-500:], } def main(): import argparse ap = argparse.ArgumentParser( description=( "Provision IoT device: wg-easy client + optional hostname, services, and app start. " "Run all steps or select individual ones." ) ) ap.add_argument("iot_host", help="Local/LAN IP or hostname of the IoT device reachable via SSH") ap.add_argument("-n", "--name", required=False, help="Device name to use for both hostname and WireGuard client") ap.add_argument( "--steps", nargs="+", choices=["pull", "wg", "hostname", "mac", "update_app", "start_app", "add_ssh_key", "finish", "all"], default=["all"], help="Which steps to run. Default: all", ) # Hostname will be taken from --name ap.add_argument("--app", choices=["ui", "script"], default="ui", help="Application mode to start") ap.add_argument("--branch", default="main", help="Git branch to checkout and pull (default: main)") args = ap.parse_args() # Validate wg-easy env get_env_auth() # Normalize steps steps = args.steps if "all" in steps: steps = [ "pull", "add_ssh_key", "hostname", "mac", "wg", "update_app", "start_app", "finish" ] # Validate required args per step name = args.name if any(s in steps for s in ("wg", "hostname")) and not name: print("error: --name is required for steps: wg, hostname") raise SystemExit(2) # Derive device name: if numeric, prefix with 'summitwave-beacon' if name and re.fullmatch(r"\d+", name): name = f"summitwave-beacon{name}" # Gather device facts once (may change after hostname step, but we at least log the initial state) facts = get_device_facts(args.iot_host) # Execute selected steps in order with logging if "pull" in steps: pull_info = step_git_pull(args.iot_host, args.branch) write_provision_log({ "action": "pull", "branch": args.branch, **get_device_facts(args.iot_host), **pull_info, }) if "add_ssh_key" in steps: ssh_info = step_add_ssh_key(args.iot_host) write_provision_log({ "action": "add_ssh_key", **get_device_facts(args.iot_host), **ssh_info, }) if "hostname" in steps: host_info = step_set_hostname(args.iot_host, name) # refresh hostname after step (if a real implementation later changes it) facts_post = get_device_facts(args.iot_host) write_provision_log({ "action": "hostname", **facts_post, **host_info, }) if "wg" in steps: wg_info = step_wireguard_provision(args.iot_host, name) write_provision_log({ "action": "wg", **facts, **wg_info, }) if "mac" in steps: mac_info = step_set_eth1_mac(args.iot_host) write_provision_log({ "action": "mac", **get_device_facts(args.iot_host), **mac_info, }) if "update_app" in steps: upd_info = step_update_app(args.iot_host) write_provision_log({ "action": "update_app", **get_device_facts(args.iot_host), **upd_info, }) if "start_app" in steps: app_info = step_start_app(args.iot_host, args.app) write_provision_log({ "action": "start_app", **get_device_facts(args.iot_host), **app_info, }) if "finish" in steps: fin_info = step_finish(args.iot_host) write_provision_log({ "action": "finish", **get_device_facts(args.iot_host), **fin_info, }) if __name__ == "__main__": main()