Add scoped removal for obsolete OVH-Hetzner peer #39

Merged
sascha merged 2 commits from feature/remove-obsolete-ovh-hetzner-peer into main 2026-08-16 20:10:47 +02:00
2 changed files with 110 additions and 0 deletions

79
app.py
View file

@ -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",

View file

@ -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 = {}