Push-to-deploy (OFF by default): vanguard-deploy applies only changed parts with a live-stream guard for MediaMTX, rollback, pending timer; dumpd /deploy webhook gated by DEPLOY_AUTO; configure --apply; switchToClone.sh; draft CI workflow; committed plugin zip

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-11-01 01:08:43 -05:00
parent 07ffae0746
commit 66f05cf9a7
15 changed files with 295 additions and 3 deletions

View File

@@ -9,6 +9,9 @@ GET /me/stream-token -> short-lived publish token for the caller (member of str
GET /me/clips -> Syncthing self-service status for the caller: our device id, their folder/device state
POST /me/clips -> {"device_id": "..."}: create clips/<user> (+ .stignore), add their device to our Syncthing,
create the receive-only folder clips-<user> shared with them (min free 300 GB). Idempotent.
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 /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.
@@ -244,6 +247,34 @@ def clips_setup(user, device_id, device_name=None):
print(f"{datetime.now(TZ):%F %T} clips setup user={user} device={device_id[:7]}… folder={fid}", flush=True)
return clips_status(user)
DEPLOY_ENV = Path(os.environ.get("DEPLOY_ENV_FILE", "/etc/vanguard/deploy.env"))
def deploy_env():
d = {}
try:
for line in DEPLOY_ENV.read_text().splitlines():
if "=" in line and not line.startswith("#"): k, v = line.split("=", 1); d[k.strip()] = v.strip()
except OSError:
pass
return d
_deploy_lock = {"running": False}
def run_deploy(reason):
import subprocess, threading
if _deploy_lock["running"]: return False
_deploy_lock["running"] = True
def go():
try:
print(f"{datetime.now(TZ):%F %T} deploy: start ({reason})", flush=True)
r = subprocess.run(["/usr/local/bin/vanguard-deploy", "pull"], capture_output=True, text=True, timeout=1800)
for line in (r.stdout + r.stderr).splitlines()[-40:]: print(f"deploy| {line}", flush=True)
print(f"{datetime.now(TZ):%F %T} deploy: exit {r.returncode}", flush=True)
except Exception as e:
print(f"{datetime.now(TZ):%F %T} deploy: error {e}", flush=True)
finally:
_deploy_lock["running"] = False
threading.Thread(target=go, daemon=True).start()
return True
def token_secret():
try:
sec = TOKEN_SECRET_FILE.read_bytes().strip()
@@ -354,6 +385,25 @@ 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"):
# 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)
env = deploy_env(); secret = env.get("DEPLOY_WEBHOOK_SECRET", "")
sig = self.headers.get("X-Gitea-Signature") or self.headers.get("X-Hub-Signature-256", "").replace("sha256=", "")
if not secret or not sig or not hmac.compare_digest(hmac.new(secret.encode(), raw, hashlib.sha256).hexdigest(), sig):
print(f"{datetime.now(TZ):%F %T} deploy webhook: bad signature from {self.client_address[0]}", flush=True)
return self._json(401, {"error": "bad signature"})
try: payload = json.loads(raw or b"{}")
except json.JSONDecodeError: return self._json(400, {"error": "bad json"})
ref = payload.get("ref", ""); branch = env.get("DEPLOY_BRANCH", "main")
after = (payload.get("after") or "")[:10]; pusher = (payload.get("pusher") or {}).get("username", "?")
if ref != f"refs/heads/{branch}":
return self._json(200, {"ignored": f"ref {ref} is not {branch}"})
if env.get("DEPLOY_AUTO", "0") != "1":
print(f"{datetime.now(TZ):%F %T} deploy webhook: push {after} by {pusher} on {branch} — auto-deploy disabled (DEPLOY_AUTO=0)", flush=True)
return self._json(202, {"queued": False, "note": "auto-deploy disabled (DEPLOY_AUTO=0); run vanguard-deploy pull by hand"})
started = run_deploy(f"push {after} by {pusher}")
return self._json(202, {"queued": started, "note": "deploy started" if started else "a deploy is already running"})
if self.server.server_port == AUTH_LISTEN[1] and not self.path.startswith("/mtx-auth"):
return self._json(404, {"error": "not found"})
if self.path.startswith("/mtx-auth"):