Add scoped removal for obsolete OVH-Hetzner peer #39
1 changed files with 79 additions and 0 deletions
79
app.py
79
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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue