From 50a23dc7804771d3851a31f480fcce035c19322e Mon Sep 17 00:00:00 2001 From: sascha Date: Mon, 31 Aug 2026 09:47:25 +0200 Subject: [PATCH 1/4] =?UTF-8?q?Git-zentriertes=20guck-admin=20Deployment?= =?UTF-8?q?=20=C3=BCber=20Butler:=20app.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app.py | 167 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 166 insertions(+), 1 deletion(-) diff --git a/app.py b/app.py index e518153..4b0b3c5 100644 --- a/app.py +++ b/app.py @@ -1723,6 +1723,34 @@ 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 @@ -1730,7 +1758,7 @@ class SpeedtestDeployRequest(BaseModel): async def _fetch_forgejo_text(repo: str, path: str) -> str: - if repo != "sascha/speedtest" or path not in SPEEDTEST_REPO_FILES: + if path not in FORGEJO_DEPLOY_FILES.get(repo, frozenset()): raise ValueError("unsupported Forgejo file") cfg = SERVICES.get("forgejo", {}) base_url = cfg.get("url") @@ -1834,6 +1862,143 @@ 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 From e99e03da20c9a80536f48de0c29020211b7c5e04 Mon Sep 17 00:00:00 2001 From: sascha Date: Mon, 31 Aug 2026 09:48:47 +0200 Subject: [PATCH 2/4] =?UTF-8?q?Git-zentriertes=20guck-admin=20Deployment?= =?UTF-8?q?=20=C3=BCber=20Butler:=20app.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit From 42040b097b6de486b740dc74dc3cc5ebcc7466bd Mon Sep 17 00:00:00 2001 From: sascha Date: Mon, 31 Aug 2026 09:48:48 +0200 Subject: [PATCH 3/4] =?UTF-8?q?Git-zentriertes=20guck-admin=20Deployment?= =?UTF-8?q?=20=C3=BCber=20Butler:=20tests/test=5Fguck=5Fadmin=5Fdeploy.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_guck_admin_deploy.py | 54 +++++++++++++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 tests/test_guck_admin_deploy.py diff --git a/tests/test_guck_admin_deploy.py b/tests/test_guck_admin_deploy.py new file mode 100644 index 0000000..74d1f79 --- /dev/null +++ b/tests/test_guck_admin_deploy.py @@ -0,0 +1,54 @@ +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) From a253324cbfc7dd691f70d59c42c2d12f375b1779 Mon Sep 17 00:00:00 2001 From: sascha Date: Mon, 31 Aug 2026 09:48:49 +0200 Subject: [PATCH 4/4] =?UTF-8?q?Git-zentriertes=20guck-admin=20Deployment?= =?UTF-8?q?=20=C3=BCber=20Butler:=20tests/test=5Fbw=5Fmanager=5Fdeploy.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tests/test_bw_manager_deploy.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_bw_manager_deploy.py b/tests/test_bw_manager_deploy.py index cf5a339..670435b 100644 --- a/tests/test_bw_manager_deploy.py +++ b/tests/test_bw_manager_deploy.py @@ -18,6 +18,8 @@ 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"