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}
|
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 = (
|
SYSCTL_AUDIT_KEYS = (
|
||||||
"net.core.default_qdisc",
|
"net.core.default_qdisc",
|
||||||
"net.core.rmem_default",
|
"net.core.rmem_default",
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue