mage automated wg provisioning work

This commit is contained in:
2025-08-27 14:22:02 +02:00
parent 2d902c0265
commit 06d16a6b03
11 changed files with 1381 additions and 1 deletions

209
src/provision.py Normal file
View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
import ipaddress, os, re, subprocess, tempfile, json, datetime
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 _get(obj, *names):
"""Safe attribute/dict getter trying several candidate names."""
for n in names:
if hasattr(obj, n):
return getattr(obj, n)
if isinstance(obj, dict) and n in obj:
return obj[n]
return None
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 wg-quick down {WG_IFACE} || true
# Bring interface up and ensure service is enabled+active
sudo wg-quick up {WG_IFACE} || true
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)
scp_and_enable(iot_host, cfg)
return {"wg_name": name, "wg_iface": iface}
def step_set_hostname(iot_host: str, hostname: str | None):
"""Placeholder: set hostname on the device via custom script under /home/caster/bumble-auracast/src/server.
Intention: SSH into device and run a script, e.g. /home/caster/bumble-auracast/src/server/set-hostname <hostname>.
Currently does nothing.
"""
print("⏭️ [placeholder] Skipping hostname change (no-op). Intended hostname:", hostname)
return {"hostname": hostname}
def step_update_app(iot_host: str, services: list[str] | None = None):
"""Placeholder: start/enable required system services on the device.
Intention: SSH into the device and run systemctl enable --now <service> for required services.
Currently does nothing.
"""
# TODO: run the app update script
services = services or []
print("⏭️ [placeholder] Skipping start services (no-op).")
return {"services": services}
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.
"""
# TODO: start with pulling the latest main from the repo
print(f"⏭️ [placeholder] Skipping start app (no-op). --app={app}")
return {"app_mode": app}
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("--name", required=True, help="Device name to use for both hostname and WireGuard client")
ap.add_argument(
"--steps",
nargs="+",
choices=["wg", "hostname", "services", "app", "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("--services", nargs="*", default=[], help="Services to start/enable (logged)")
args = ap.parse_args()
# Validate wg-easy env
get_env_auth()
# Normalize steps
steps = args.steps
if "all" in steps:
steps = ["wg", "hostname", "services", "app"]
# 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 "wg" in steps:
wg_info = step_wireguard_provision(args.iot_host, args.name)
write_provision_log({
"action": "wg",
**facts,
**wg_info,
})
if "hostname" in steps:
host_info = step_set_hostname(args.iot_host, args.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 "services" in steps:
svc_info = step_update_app(args.iot_host, args.services)
write_provision_log({
"action": "services",
**get_device_facts(args.iot_host),
**svc_info,
})
if "app" in steps:
app_info = step_start_app(args.iot_host, args.app)
write_provision_log({
"action": "app",
**get_device_facts(args.iot_host),
**app_info,
})
if __name__ == "__main__":
main()