diff --git a/app.py b/app.py index 37b94c6..5a99cdc 100644 --- a/app.py +++ b/app.py @@ -1274,6 +1274,85 @@ async def network_wireguard_status(host: str, _=Depends(_verify)): return {"host": host, **result} +class WireGuardPeerRemoveRequest(BaseModel): + public_key: str + expected_allowed_ip: str + dry_run: bool = True + + +def _wireguard_remove_peer_command(public_key: str, expected_allowed_ip: str, dry_run: bool) -> str: + script = f'''import json, os, re, shutil, subprocess, time +from pathlib import Path + +public_key = {public_key!r} +expected = {expected_allowed_ip!r} +dry_run = {dry_run!r} +config = Path("/etc/wireguard/wg0.conf") +text = config.read_text() +sections = re.split(r"(?=^\\[Peer\\]\\s*$)", text, flags=re.M) +matches = [] +for index, section in enumerate(sections): + key_match = re.search(r"^PublicKey\\s*=\\s*(\\S+)\\s*$", section, re.M) + allowed_match = re.search(r"^AllowedIPs\\s*=\\s*(.+?)\\s*$", section, re.M) + allowed = [item.strip() for item in allowed_match.group(1).split(",")] if allowed_match else [] + if key_match and key_match.group(1) == public_key and expected in allowed: + matches.append((index, allowed)) +if len(matches) != 1: + print(json.dumps({{"error": "expected exactly one matching peer", "matches": len(matches)}})); raise SystemExit(2) +index, allowed = matches[0] +result = {{"status": "would_remove" if dry_run else "removed", "allowed_ips": allowed, "removed_routes": [], "backup": None}} +if dry_run: + print(json.dumps(result)); raise SystemExit(0) +backup = config.with_name("wg0.conf.butler-" + time.strftime("%Y%m%dT%H%M%SZ", time.gmtime())) +shutil.copy2(config, backup) +result["backup"] = str(backup) +new_text = "".join(section for number, section in enumerate(sections) if number != index) +tmp = config.with_name("wg0.conf.butler-tmp") +tmp.write_text(new_text) +os.chmod(tmp, config.stat().st_mode) +os.chown(tmp, config.stat().st_uid, config.stat().st_gid) +os.replace(tmp, config) +try: + subprocess.run(["wg", "set", "wg0", "peer", public_key, "remove"], check=True, text=True, capture_output=True) + for route in allowed: + proc = subprocess.run(["ip", "route", "del", route, "dev", "wg0"], text=True, capture_output=True) + if proc.returncode == 0: result["removed_routes"].append(route) + peers = subprocess.run(["wg", "show", "wg0", "peers"], check=True, text=True, capture_output=True).stdout.split() + if public_key in peers: raise RuntimeError("peer still active") +except Exception: + shutil.copy2(backup, config) + raise +print(json.dumps(result)) +''' + encoded = base64.b64encode(script.encode()).decode() + return f'sudo -n python3 -c "import base64;exec(base64.b64decode(\'{encoded}\'))"' + + +@app.delete("/network/wireguard/{host}/peer") +async def network_wireguard_remove_peer(host: str, req: WireGuardPeerRemoveRequest, _=Depends(_verify)): + allowed = {"guck-vps": "10.7.1.0/24", "pfannkuchen": "10.200.200.60/32"} + if host not in allowed: + raise HTTPException(403, "Peer removal is restricted to the obsolete OVH-Hetzner transit") + if req.expected_allowed_ip != allowed[host]: + raise HTTPException(400, "Unexpected AllowedIP for this host") + if not re.fullmatch(r"[A-Za-z0-9+/]{43}=", req.public_key): + raise HTTPException(400, "Invalid WireGuard public key") + 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"]}' + command = _wireguard_remove_peer_command(req.public_key, req.expected_allowed_ip, req.dry_run) + rc, out, err = await asyncio.to_thread(_ssh, target, command, 45) + if rc != 0: + raise HTTPException(502, (err or out).strip()[-500:] or "WireGuard peer removal failed") + try: + result = json.loads(out) + except json.JSONDecodeError as exc: + raise HTTPException(502, "WireGuard peer removal returned invalid JSON") from exc + _audit(f"/network/wireguard/{host}/peer", "DELETE", 200, f"dry_run={req.dry_run}") + return {"host": host, **result} + + SYSCTL_AUDIT_KEYS = ( "net.core.default_qdisc", "net.core.rmem_default",