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:
57
bin/dumpd.py
57
bin/dumpd.py
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user