From 900e31dce918a00fe73843406406dbdf05144b9f Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 16 Aug 2026 20:10:44 +0200 Subject: [PATCH 1/2] Add scoped obsolete transit peer removal --- app.py | 79 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 79 insertions(+) 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", From f39a72681cc2aac7b6c969a46c0352a155a5cd61 Mon Sep 17 00:00:00 2001 From: sascha Date: Sun, 16 Aug 2026 20:10:44 +0200 Subject: [PATCH 2/2] Add scoped obsolete transit peer removal --- tests/test_app.py | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/tests/test_app.py b/tests/test_app.py index c49a918..726d11d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -104,6 +104,37 @@ def test_wireguard_status_command_uses_sudo_and_accepts_off_keepalive(): assert '0 if fields[7] == "off" else int(fields[7])' in script +def test_wireguard_peer_remove_is_scoped_and_audited(monkeypatch): + calls = [] + monkeypatch.setattr(app, "_find_inventory_host", lambda host: {"user": "debian", "ip": "141.94.237.199"}) + monkeypatch.setattr(app, "_ssh", lambda host, command, timeout=30: (calls.append((host, command, timeout)) or (0, json.dumps({"status": "removed", "removed_routes": ["10.7.1.0/24"]}), ""))) + + with TestClient(app.app) as client: + response = client.request( + "DELETE", + "/network/wireguard/guck-vps/peer", + headers={"Authorization": "Bearer test-token"}, + json={"public_key": "A" * 43 + "=", "expected_allowed_ip": "10.7.1.0/24", "dry_run": False}, + ) + + assert response.status_code == 200 + assert response.json()["status"] == "removed" + assert calls[0][0] == "debian@141.94.237.199" + assert calls[0][2] == 45 + + +def test_wireguard_peer_remove_rejects_non_allowlisted_host(monkeypatch): + monkeypatch.setattr(app, "_ssh", lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("SSH must not run"))) + with TestClient(app.app) as client: + response = client.request( + "DELETE", + "/network/wireguard/node7/peer", + headers={"Authorization": "Bearer test-token"}, + json={"public_key": "A" * 43 + "=", "expected_allowed_ip": "10.7.1.0/24", "dry_run": True}, + ) + assert response.status_code == 403 + + def test_tts_generate_returns_cloned_wav(monkeypatch): captured = {}