The bot's secrets live in Gitea: signed settings travel with the release bundle

POST /deploy-bot may now carry X-Vanguard-Env, base64 JSON of the bot's own settings, and the HMAC covers
that header together with the body so the pair cannot be separated or swapped. dumpd merges only the keys
BOT_ENV_KEYS allows (DISCORD_BOT_TOKEN, DISCORD_GUILD_ID by default) into the stack .env, atomically and
preserving mode and owner, and reports the rest to the CI job as settings_refused. The server keeps what it
generates: a CI job cannot touch the database password or the Authentik keys.

Verified: allowed keys applied, server-owned keys refused while the rest still applied, a settings swap
after signing rejected with 401, and an empty settings header leaving .env untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-11-02 21:37:35 -06:00
parent b05f129bed
commit 1e4c0e88dc
7 changed files with 106 additions and 8 deletions

View File

@@ -14,6 +14,8 @@ POST /deploy -> Gitea push webhook (bridge port only, HMAC-verified with DEPL
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.
May carry X-Vanguard-Env (base64 JSON): the bot's own settings, kept in Gitea, written
into the stack .env for the keys BOT_ENV_KEYS allows. Signature covers header+body.
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.
@@ -262,6 +264,47 @@ def deploy_env():
pass
return d
BOT_ENV_DEFAULT_KEYS = "DISCORD_BOT_TOKEN,DISCORD_GUILD_ID"
BOT_ENV_MAX_HEADER = 8192
_env_write_lock = threading.Lock()
def stack_env_path():
"""The stack's .env — docker compose interpolates ${...} from it, so the bot's settings live there."""
repo = deploy_env().get("DEPLOY_REPO_DIR", "")
return (Path(repo) / ".env") if repo else None
def apply_bot_env(values):
"""Merge allowed keys into the stack .env. Returns (applied, refused); never logs a value."""
allowed = {k.strip() for k in deploy_env().get("BOT_ENV_KEYS", BOT_ENV_DEFAULT_KEYS).split(",") if k.strip()}
refused = sorted(k for k in values if k not in allowed)
wanted = {k: str(v) for k, v in values.items() if k in allowed}
if not wanted:
return [], refused
path = stack_env_path()
if path is None or not path.is_file():
raise OSError("DEPLOY_REPO_DIR is not set or holds no .env")
bad = [k for k, v in wanted.items() if not re.fullmatch(r"[A-Z][A-Z0-9_]{1,48}", k) or "\n" in v or "\r" in v]
if bad:
raise ValueError(f"not a usable env assignment: {bad}")
with _env_write_lock:
lines = path.read_text().splitlines()
seen = set()
for i, line in enumerate(lines):
if "=" in line and not line.lstrip().startswith("#"):
key = line.split("=", 1)[0].strip()
if key in wanted:
lines[i] = f"{key}={wanted[key]}"; seen.add(key)
for key in wanted:
if key not in seen: lines.append(f"{key}={wanted[key]}")
stat = path.stat()
tmp = path.with_suffix(".env.incoming")
tmp.write_text("\n".join(lines) + "\n")
os.chmod(tmp, stat.st_mode & 0o777); os.chown(tmp, stat.st_uid, stat.st_gid)
os.replace(tmp, path)
applied = sorted(wanted)
log("bot_env_applied", keys=",".join(applied), refused=",".join(refused) or None)
return applied, refused
_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")
@@ -442,11 +485,26 @@ class H(BaseHTTPRequestHandler):
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)
env_b64 = (self.headers.get("X-Vanguard-Env") or "").strip()
if len(env_b64) > BOT_ENV_MAX_HEADER:
return self._json(413, {"error": "X-Vanguard-Env is too large"})
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):
signed = env_b64.encode() + raw # the settings are signed with the bundle, not beside it
if not sig or not hmac.compare_digest(hmac.new(secret.encode(), signed, 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"})
applied, refused = [], []
if env_b64:
try:
values = json.loads(base64.b64decode(env_b64, validate=True))
if not isinstance(values, dict): raise ValueError("X-Vanguard-Env must be a JSON object")
applied, refused = apply_bot_env(values)
except (ValueError, OSError, TypeError) as error:
log("bot_env_failed", level="error", error=error)
return self._json(400, {"error": f"settings rejected: {error}"})
code, body = install_bot_bundle(raw, self.headers.get("X-Vanguard-Reason") or "ci")
body["settings_applied"] = applied
if refused: body["settings_refused"] = refused
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.