diff --git a/app.py b/app.py index 526598c..c43dec1 100644 --- a/app.py +++ b/app.py @@ -1213,6 +1213,67 @@ def _ssh(host, cmd, timeout=600): return 124, "", f"SSH command timed out after {timeout} seconds" +def _wireguard_status_command() -> str: + script = '''import json, subprocess + +def run(args): + proc = subprocess.run(args, text=True, capture_output=True, timeout=15) + if proc.returncode != 0: + raise RuntimeError((proc.stderr or proc.stdout).strip() or "command failed") + return proc.stdout.strip() + +result = {"interface": "wg0", "addresses": [], "listen_port": None, "service_active": False, "service_enabled": False, "routes": [], "peers": []} +result["service_active"] = subprocess.run(["systemctl", "is-active", "--quiet", "wg-quick@wg0"]).returncode == 0 +result["service_enabled"] = subprocess.run(["systemctl", "is-enabled", "--quiet", "wg-quick@wg0"]).returncode == 0 +try: + addr_data = json.loads(run(["ip", "-j", "address", "show", "dev", "wg0"])) + for item in addr_data: + for address in item.get("addr_info", []): + result["addresses"].append(address["local"] + "/" + str(address["prefixlen"])) + result["routes"] = json.loads(run(["ip", "-j", "route", "show", "dev", "wg0"])) + rows = run(["wg", "show", "wg0", "dump"]).splitlines() + if rows: + interface = rows[0].split("\\t") + result["listen_port"] = int(interface[2]) + for raw in rows[1:]: + fields = raw.split("\\t") + result["peers"].append({ + "public_key": fields[0], + "endpoint": None if fields[2] == "(none)" else fields[2], + "allowed_ips": [] if fields[3] == "(none)" else fields[3].split(","), + "latest_handshake": int(fields[4]), + "rx_bytes": int(fields[5]), + "tx_bytes": int(fields[6]), + "persistent_keepalive": int(fields[7]), + }) +except Exception as exc: + result["error"] = str(exc)[:300] +print(json.dumps(result)) +''' + encoded = base64.b64encode(script.encode()).decode() + return f'python3 -c "import base64;exec(base64.b64decode(\'{encoded}\'))"' + + +@app.get("/network/wireguard/{host}") +async def network_wireguard_status(host: str, _=Depends(_verify)): + """Return redacted WireGuard state without private or preshared keys.""" + if not re.fullmatch(r"[a-z0-9][a-z0-9-]{0,62}", host): + raise HTTPException(400, "Invalid host name") + inventory = await asyncio.to_thread(_find_inventory_host, host) + if not inventory: + raise HTTPException(404, f"Host {host} not found") + target = f'{inventory["user"]}@{inventory["ip"]}' + rc, out, err = await asyncio.to_thread(_ssh, target, _wireguard_status_command(), 30) + if rc != 0: + raise HTTPException(502, (err or out).strip()[-500:] or "WireGuard status failed") + try: + result = json.loads(out) + except json.JSONDecodeError as exc: + raise HTTPException(502, "WireGuard status returned invalid JSON") from exc + _audit(f"/network/wireguard/{host}", "GET", 200, "redacted live status") + return {"host": host, **result} + + SYSCTL_AUDIT_KEYS = ( "net.core.default_qdisc", "net.core.rmem_default",