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