Bot releases install themselves: signed bundle upload to dumpd, no Docker socket in CI

dumpd gains POST /deploy-bot on the bridge-only auth port: HMAC-SHA256 over the body with BOT_DEPLOY_SECRET,
a 300 MB cap, one install at a time, and the installer's output returned in the response so the CI job shows
it. BOT_DEPLOY_AUTO=0 makes the server refuse. configure.py generates the secret, writes both settings into
/etc/vanguard/deploy.env, installs deployBotBundle.sh as /usr/local/bin/vanguard-deploy-bot and creates
/srv/bot/app; --show prints what to paste into Gitea.

Chose this over mounting the Docker socket into job containers, which would give every CI job root on the
host. Also moved threading/subprocess to dumpd's module imports, which the new module-level lock needs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 21:32:34 -06:00
parent 22dcf893d5
commit b05f129bed
8 changed files with 111 additions and 24 deletions

View File

@@ -12,6 +12,8 @@ POST /me/clips -> {"device_id": "..."}: create clips/<user> (+ .stignore), add
POST /deploy -> Gitea push webhook (bridge port only, HMAC-verified with DEPLOY_WEBHOOK_SECRET from
/etc/vanguard/deploy.env). Runs /usr/local/bin/vanguard-deploy pull ONLY when DEPLOY_AUTO=1;
otherwise logs the push and answers 202 "auto-deploy disabled".
POST /deploy-bot -> Gitea Actions (job container -> host gateway) hands over a Discord-bot release bundle,
HMAC-SHA256 signed with BOT_DEPLOY_SECRET; installs it with vanguard-deploy-bot.
POST /mtx-auth -> MediaMTX authHTTP hook: allows read/playback; allows publish iff user/pass carries a valid
token whose user owns the path prefix. Tokens are HMAC-signed with /etc/vanguard/stream-token.secret.
@@ -19,7 +21,7 @@ POST /mtx-auth -> MediaMTX authHTTP hook: allows read/playback; allows publish i
Default night: today if it is 17:00 or later, else yesterday (so 00:00-02:00 still counts as last night).
Users may dump only their own prefix; members of the admins / media-admins groups may pass "user" to dump anyone, or "all".
"""
import base64, hashlib, hmac, json, os, re, shutil, sys
import base64, hashlib, hmac, json, os, re, shutil, subprocess, sys, threading
import time as _time
from datetime import datetime, timedelta, time
from zoneinfo import ZoneInfo
@@ -260,9 +262,39 @@ def deploy_env():
pass
return d
_bot_deploy_lock = threading.Lock()
BOT_DEPLOY_MAX_BYTES = 300 * 1024 * 1024
BOT_DEPLOY_INSTALLER = os.environ.get("BOT_DEPLOY_INSTALLER", "/usr/local/bin/vanguard-deploy-bot")
def install_bot_bundle(raw, reason):
"""Write the uploaded bundle to a temp file and let vanguard-deploy-bot verify, swap and restart it."""
import tempfile
if not _bot_deploy_lock.acquire(blocking=False):
return 409, {"error": "a bot deploy is already running"}
tmp = None
try:
with tempfile.NamedTemporaryFile(prefix="vanguard-bot-bundle.", suffix=".bin", delete=False) as handle:
handle.write(raw); tmp = handle.name
log("bot_deploy_start", bytes=len(raw), reason=reason)
done = subprocess.run([BOT_DEPLOY_INSTALLER, tmp], capture_output=True, text=True, timeout=600)
output = (done.stdout + done.stderr).strip().splitlines()
log("bot_deploy_exit", level=("info" if done.returncode == 0 else "error"), rc=done.returncode, reason=reason)
return (200 if done.returncode == 0 else 500), {"installed": done.returncode == 0, "rc": done.returncode,
"output": output[-40:]}
except subprocess.TimeoutExpired:
log("bot_deploy_timeout", level="error", reason=reason)
return 504, {"error": "the installer did not finish within 10 minutes"}
except OSError as error:
log("bot_deploy_error", level="error", error=error)
return 500, {"error": str(error)}
finally:
if tmp:
try: os.unlink(tmp)
except OSError: pass
_bot_deploy_lock.release()
_deploy_lock = {"running": False}
def run_deploy(reason):
import subprocess, threading
if _deploy_lock["running"]: return False
_deploy_lock["running"] = True
def go():
@@ -396,6 +428,26 @@ class H(BaseHTTPRequestHandler):
"files": [str(f.relative_to(ROOT)) for f in files],
"gb": round(sum(f.stat().st_size for f in files) / 1e9, 2)})
def do_POST(self):
if self.server.server_port == AUTH_LISTEN[1] and self.path.startswith("/deploy-bot"):
# The bot's CI posts its release bundle here (bridge-only port; the firewall allows docker0/br-* alone).
# The body is the artifact; the signature proves it came from a runner holding BOT_DEPLOY_SECRET.
env = deploy_env()
if env.get("BOT_DEPLOY_AUTO", "1") != "1":
log("bot_deploy_refused", level="warn", reason="BOT_DEPLOY_AUTO=0")
return self._json(403, {"error": "automatic bot deploys are turned off (BOT_DEPLOY_AUTO=0)"})
secret = env.get("BOT_DEPLOY_SECRET", "")
size = int(self.headers.get("Content-Length") or 0)
if not secret:
return self._json(503, {"error": "BOT_DEPLOY_SECRET is not set on the server"})
if not 0 < size <= BOT_DEPLOY_MAX_BYTES:
return self._json(413, {"error": f"body must be 1..{BOT_DEPLOY_MAX_BYTES} bytes, got {size}"})
raw = self.rfile.read(size)
sig = (self.headers.get("X-Vanguard-Signature") or "").replace("sha256=", "").strip()
if not sig or not hmac.compare_digest(hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest(), sig):
log("bot_deploy_bad_signature", level="warn", ip=self.client_address[0], bytes=len(raw))
return self._json(401, {"error": "bad signature"})
code, body = install_bot_bundle(raw, self.headers.get("X-Vanguard-Reason") or "ci")
return self._json(code, body)
if self.server.server_port == AUTH_LISTEN[1] and self.path.startswith("/deploy"):
# Gitea (container) -> host gateway -> here. Verify HMAC-SHA256 of the raw body with the webhook secret.
n = int(self.headers.get("Content-Length") or 0); raw = self.rfile.read(n)
@@ -481,7 +533,6 @@ class H(BaseHTTPRequestHandler):
if __name__ == "__main__":
TRASH.mkdir(exist_ok=True); MARK.mkdir(exist_ok=True)
import threading
print(f"dumpd listening on {LISTEN[0]}:{LISTEN[1]} (dump, me) and {AUTH_LISTEN[0]}:{AUTH_LISTEN[1]} (mtx-auth) tz={TZ.key} night={NIGHT_START:%H:%M}-{NIGHT_END:%H:%M} token-secret={'ok' if token_secret() else 'MISSING'}", flush=True)
auth_srv = ThreadingHTTPServer(AUTH_LISTEN, H)
threading.Thread(target=auth_srv.serve_forever, daemon=True).start()