diff --git a/app.py b/app.py index 4b0b3c5..e518153 100644 --- a/app.py +++ b/app.py @@ -1723,34 +1723,6 @@ SPEEDTEST_REPO_FILES = ( "streamscope/static/assets/longterm-metrics.js", ) -GUCK_ADMIN_REPO_FILES = ( - "guck-admin/Dockerfile", - "guck-admin/compose.yaml", - "guck-admin/requirements.txt", - "guck-admin/src/app.py", - "guck-admin/src/control.py", - "guck-admin/src/sharing_watchdog.py", - "guck-admin/src/templates/bandwidth.html", - "guck-admin/src/templates/base.html", - "guck-admin/src/templates/dashboard.html", - "guck-admin/src/templates/history.html", - "guck-admin/src/templates/sessions.html", - "guck-admin/src/templates/settings.html", - "guck-admin/src/templates/sharing.html", - "guck-admin/src/templates/users.html", - "guck-admin/static/admin.css", - "guck-admin/static/admin.js", - "guck-admin/static/icon.svg", - "guck-admin/static/manifest.webmanifest", - "guck-admin/static/sw.js", - "guck-admin/static/world.svg", -) - -FORGEJO_DEPLOY_FILES = { - "sascha/speedtest": frozenset(SPEEDTEST_REPO_FILES), - "sascha/guck-vps": frozenset(GUCK_ADMIN_REPO_FILES), -} - class SpeedtestDeployRequest(BaseModel): stats_password: str @@ -1758,7 +1730,7 @@ class SpeedtestDeployRequest(BaseModel): async def _fetch_forgejo_text(repo: str, path: str) -> str: - if path not in FORGEJO_DEPLOY_FILES.get(repo, frozenset()): + if repo != "sascha/speedtest" or path not in SPEEDTEST_REPO_FILES: raise ValueError("unsupported Forgejo file") cfg = SERVICES.get("forgejo", {}) base_url = cfg.get("url") @@ -1862,143 +1834,6 @@ async def vps_speedtest_deploy(req: SpeedtestDeployRequest, _=Depends(_verify)): return result -class GuckAdminDeployRequest(BaseModel): - dry_run: bool = True - - -def _validate_guck_admin_bundle(files: dict[str, str]) -> None: - if set(files) != set(GUCK_ADMIN_REPO_FILES): - raise ValueError("guck-admin source bundle is incomplete") - compose = files["guck-admin/compose.yaml"] - control = files["guck-admin/src/control.py"] - compose_required = ( - "network_mode: host", - "NET_ADMIN", - "/app-config/guck-admin/data:/data", - "GUCK_LIMIT: /host/guck-limit.sh", - ) - if any(item not in compose for item in compose_required): - raise ValueError("guck-admin compose is missing a required security or persistence setting") - policy_required = ( - "CREATE TABLE IF NOT EXISTS custom_networks", - "def sync_custom_networks", - "2a00:8c40:f000::/36", - "45.58.235.0/24", - ) - if any(item not in control for item in policy_required): - raise ValueError("guck-admin custom VPN policy is incomplete") - - -def _guck_admin_remote_python(target: str, script: str, timeout: int = 60): - encoded = base64.b64encode(script.encode()).decode() - command = f"sudo -n python3 -c \"import base64;exec(base64.b64decode('{encoded}'))\"" - return _ssh(target, command, timeout=timeout) - - -def _deploy_guck_admin_compose(files: dict[str, str], dry_run: bool = True) -> dict: - _validate_guck_admin_bundle(files) - inventory = _find_inventory_host("guck-vps") - if not inventory: - raise RuntimeError("guck-vps is missing from Butler inventory") - target = f'{inventory["user"]}@{inventory["ip"]}' - if dry_run: - return { - "status": "validated", - "dry_run": True, - "host": "guck-vps", - "files": len(files), - "policy_networks": ["Mozilla Firefox VPN IPv6", "Fastly VPN IPv4"], - } - relative_files = {path.removeprefix("guck-admin/"): content for path, content in files.items()} - deploy_script = f"""from pathlib import Path -import os, shutil -stack = Path('/app-config/guck-admin') -backup = Path('/app-config/deployment-backups/guck-admin-rollback') -files = {relative_files!r} -if backup.exists(): - shutil.rmtree(backup) -backup.mkdir(parents=True, exist_ok=True) -stack.mkdir(parents=True, exist_ok=True) -for relative, content in files.items(): - target = stack / relative - old = backup / relative - if target.exists(): - old.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(target, old) - target.parent.mkdir(parents=True, exist_ok=True) - temporary = target.with_name(target.name + '.butler-new') - temporary.write_text(content) - os.replace(temporary, target) -""" - rollback_script = f"""from pathlib import Path -import os, shutil -stack = Path('/app-config/guck-admin') -backup = Path('/app-config/deployment-backups/guck-admin-rollback') -files = {tuple(relative_files)!r} -for relative in files: - target = stack / relative - old = backup / relative - if old.exists(): - target.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(old, target) - elif target.exists(): - target.unlink() -""" - rc, _out, err = _guck_admin_remote_python(target, deploy_script, timeout=90) - if rc != 0: - raise RuntimeError(f"guck-admin file deployment failed: {err[-300:]}") - - def rollback(): - _guck_admin_remote_python(target, rollback_script, timeout=90) - _ssh(target, "cd /app-config/guck-admin && sudo -n docker compose up -d --build --remove-orphans", timeout=600) - - preflight = "cd /app-config/guck-admin && sudo -n docker compose config -q && sudo -n docker compose build --pull" - rc, _out, err = _ssh(target, preflight, timeout=600) - if rc != 0: - rollback() - raise RuntimeError(f"guck-admin build preflight failed: {err[-500:]}") - deploy = "cd /app-config/guck-admin && sudo -n docker compose up -d --remove-orphans" - rc, out, err = _ssh(target, deploy, timeout=240) - if rc != 0: - rollback() - raise RuntimeError(f"guck-admin deployment failed: {(err or out)[-500:]}") - health = "for i in $(seq 1 45); do curl -fsS --max-time 3 http://127.0.0.1:9090/health >/dev/null && exit 0; sleep 2; done; exit 1" - rc, _out, err = _ssh(target, health, timeout=105) - if rc != 0: - rollback() - raise RuntimeError(f"guck-admin health check failed and rollback was attempted: {err[-300:]}") - policy = "curl -fsS -X POST --max-time 120 http://127.0.0.1:9090/actions/limiter/refresh >/dev/null && sudo -n ipset test vpn-v6 2a00:8c40:f02d:a34c::1 && sudo -n ipset test vpn-v4 45.58.235.7 && sudo -n tc class show dev ens3 | python3 -c \"import sys; s=sys.stdin.read(); raise SystemExit(0 if '1:300' in s else 1)\"" - rc, out, err = _ssh(target, policy, timeout=180) - if rc != 0: - rollback() - raise RuntimeError(f"guck-admin policy verification failed and rollback was attempted: {(err or out)[-500:]}") - return { - "status": "deployed", - "health": "ok", - "policy": "verified", - "host": "guck-vps", - "custom_networks": ["2a00:8c40:f000::/36", "45.58.235.0/24"], - "ipv4": True, - "ipv6": True, - } - - -@app.post("/vps/guck-admin/deploy") -async def vps_guck_admin_deploy(req: GuckAdminDeployRequest, _=Depends(_verify)): - try: - contents = await asyncio.gather(*( - _fetch_forgejo_text("sascha/guck-vps", path) for path in GUCK_ADMIN_REPO_FILES - )) - files = dict(zip(GUCK_ADMIN_REPO_FILES, contents)) - result = await asyncio.to_thread(_deploy_guck_admin_compose, files, req.dry_run) - except ValueError as exc: - raise HTTPException(400, str(exc)) from exc - except Exception as exc: - raise HTTPException(502, f"guck-admin deployment failed: {str(exc)[-500:]}") from exc - _audit("/vps/guck-admin/deploy", "POST", 200, f"dry_run={req.dry_run}; Git-managed custom VPN policy") - return result - - # --- VM Lifecycle Endpoints --- import subprocess as _sp diff --git a/tests/test_bw_manager_deploy.py b/tests/test_bw_manager_deploy.py index 670435b..cf5a339 100644 --- a/tests/test_bw_manager_deploy.py +++ b/tests/test_bw_manager_deploy.py @@ -18,8 +18,6 @@ def load_app(monkeypatch): def test_bw_manager_deploy_rejects_bundle_without_and_gate(monkeypatch): app = load_app(monkeypatch) - if not hasattr(app, "BW_MANAGER_REPO_FILES"): - pytest.skip("BW-Manager was intentionally retired on 13.08.2026") files = {path: "placeholder" for path in app.BW_MANAGER_REPO_FILES} files["compose.yaml"] = "services:\n bw-manager:\n build: ./src\n" files["src/app.py"] = "def old_policy(): pass\n" diff --git a/tests/test_guck_admin_deploy.py b/tests/test_guck_admin_deploy.py deleted file mode 100644 index 74d1f79..0000000 --- a/tests/test_guck_admin_deploy.py +++ /dev/null @@ -1,54 +0,0 @@ -import app -import pytest -from fastapi.testclient import TestClient - - -def valid_bundle(): - files={path:'placeholder' for path in app.GUCK_ADMIN_REPO_FILES} - files['guck-admin/compose.yaml']='''services:\n guck-admin:\n build: .\n network_mode: host\n cap_add: [NET_ADMIN]\n environment:\n GUCK_LIMIT: /host/guck-limit.sh\n volumes:\n - /app-config/guck-admin/data:/data\n control-monitor:\n build: .\n network_mode: host\n cap_add: [NET_ADMIN]\n''' - files['guck-admin/src/control.py']='''CREATE TABLE IF NOT EXISTS custom_networks\n2a00:8c40:f000::/36\n45.58.235.0/24\ndef sync_custom_networks(): pass\n''' - return files - - -def test_guck_admin_bundle_requires_persistent_custom_network_policy(): - files=valid_bundle() - files['guck-admin/src/control.py']='def old_control(): pass\n' - with pytest.raises(ValueError,match='custom VPN'): - app._validate_guck_admin_bundle(files) - - -def test_guck_admin_deploy_dry_run_has_no_remote_side_effect(monkeypatch): - calls=[] - monkeypatch.setattr(app,'_find_inventory_host',lambda host:{'user':'debian','ip':'141.94.237.199'}) - monkeypatch.setattr(app,'_ssh',lambda *args,**kwargs:calls.append(args)) - result=app._deploy_guck_admin_compose(valid_bundle(),dry_run=True) - assert result['status']=='validated' - assert result['host']=='guck-vps' - assert calls==[] - - -def test_guck_admin_deploy_uses_inventory_target_and_verifies_policy(monkeypatch): - calls=[] - monkeypatch.setattr(app,'_find_inventory_host',lambda host:{'user':'debian','ip':'141.94.237.199'}) - def fake_ssh(host,command,timeout=600): - calls.append((host,command,timeout)) - if 'ipset test vpn-v6' in command: - return 0,'policy ok','' - return 0,'ok','' - monkeypatch.setattr(app,'_ssh',fake_ssh) - result=app._deploy_guck_admin_compose(valid_bundle(),dry_run=False) - assert result['status']=='deployed' - assert result['policy']=='verified' - assert all(host=='debian@141.94.237.199' for host,_,_ in calls) - commands='\n'.join(command for _,command,_ in calls) - assert 'docker compose build' in commands - assert '127.0.0.1:9090/health' in commands - assert '/actions/limiter/refresh' in commands - assert 'ipset test vpn-v6 2a00:8c40:f02d:a34c::1' in commands - assert 'ipset test vpn-v4 45.58.235.7' in commands - - -def test_guck_admin_deploy_endpoint_requires_auth(monkeypatch): - monkeypatch.setattr(app,'BUTLER_TOKEN','test-token') - response=TestClient(app.app).post('/vps/guck-admin/deploy',json={'dry_run':True}) - assert response.status_code in (401,403)