#!/usr/bin/env python3 """Deploy webhook — signed, allowlisted factory actions plus audit log.""" import hashlib import hmac import http.server import json import os import re import subprocess import time from datetime import datetime, timezone from pathlib import Path PORT = int(os.environ.get("DEPLOY_WEBHOOK_PORT", "8098")) AUTH_TOKEN = os.environ.get("DEPLOY_TOKEN", "factory-deploy-token-2026") REQUIRE_SIGNATURE = os.environ.get("DEPLOY_REQUIRE_SIGNATURE", "1") != "0" AUDIT_LOG = Path(os.environ.get("DEPLOY_AUDIT_LOG", "deploy_audit.jsonl")) MAX_SKEW_SECONDS = int(os.environ.get("DEPLOY_MAX_SKEW_SECONDS", "300")) SAFE_NAME = re.compile(r"^[a-zA-Z0-9_.-]+$") ALLOWED_CONTAINERS = {"hermes", "fullyhermes", "hermes-voice", "openclaw", "scanner", "deploy-webhook"} ALLOWED_ACTIONS = { "run_skill_tests", "patch_skill_from_pr", "restart_container", "rollback_container", "disable_skill", "enable_skill", } def audit(event): event = {"ts": datetime.now(timezone.utc).isoformat(), **event} AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True) with AUDIT_LOG.open("a", encoding="utf-8") as f: f.write(json.dumps(event, ensure_ascii=False) + "\n") def _signed_payload(data): keep = {k: data.get(k) for k in ("timestamp", "nonce", "actor", "action", "target", "payload_hash")} return json.dumps(keep, sort_keys=True, separators=(",", ":")).encode() def expected_signature(data): return hmac.new(AUTH_TOKEN.encode(), _signed_payload(data), hashlib.sha256).hexdigest() def verify_signature(data): if not REQUIRE_SIGNATURE: return True, "signature disabled" try: ts = int(data.get("timestamp", 0)) except (TypeError, ValueError): return False, "bad timestamp" if abs(int(time.time()) - ts) > MAX_SKEW_SECONDS: return False, "stale timestamp" got = str(data.get("signature", "")) if not hmac.compare_digest(got, expected_signature(data)): return False, "bad signature" return True, "ok" def safe_name(value): value = str(value or "") return bool(SAFE_NAME.fullmatch(value) and value not in {".", ".."} and ".." not in value.split(".")) def command_for(data): action = data.get("action") target = str(data.get("target", "")) if action not in ALLOWED_ACTIONS: raise ValueError("action not allowed") if not safe_name(target): raise ValueError("unsafe target") if action == "run_skill_tests": return f"cd /opt/data/skills/{target} && bash test.sh" if action == "restart_container": if target not in ALLOWED_CONTAINERS: raise ValueError("container not allowed") return f"docker restart {target}" if action == "rollback_container": if target not in ALLOWED_CONTAINERS: raise ValueError("container not allowed") return f"echo rollback requested for {target}" if action in {"disable_skill", "enable_skill"}: return f"echo {action} {target}" if action == "patch_skill_from_pr": payload_hash = str(data.get("payload_hash", "")) if not safe_name(payload_hash): raise ValueError("unsafe payload hash") return f"echo patch_skill_from_pr {target} {payload_hash}" raise ValueError("unhandled action") class DeployHandler(http.server.BaseHTTPRequestHandler): def do_GET(self): if self.path == "/dynamic-status": self.send_json(200, self.get_dynamic_status()) else: self.send_json(200, {"status": "ok", "service": "deploy-webhook", "phase": 4, "actions": sorted(ALLOWED_ACTIONS)}) def do_POST(self): auth = self.headers.get("Authorization", "") if auth != f"Bearer {AUTH_TOKEN}": audit({"event": "reject", "reason": "unauthorized", "client": self.client_address[0]}) self.send_json(401, {"error": "unauthorized"}) return try: data = json.loads(self.rfile.read(int(self.headers.get("Content-Length", 0)))) except Exception: self.send_json(400, {"error": "invalid json"}) return ok, reason = verify_signature(data) action = data.get("action") target = data.get("target") if not ok: audit({"event": "reject", "reason": reason, "action": action, "target": target}) self.send_json(403, {"error": reason}) return try: cmd = command_for(data) except ValueError as e: audit({"event": "reject", "reason": str(e), "action": action, "target": target}) self.send_json(403, {"error": str(e), "allowed_actions": sorted(ALLOWED_ACTIONS)}) return cmd_hash = hashlib.sha256(cmd.encode()).hexdigest()[:16] audit({"event": "execute", "actor": data.get("actor"), "action": action, "target": target, "cmd_hash": cmd_hash}) try: result = subprocess.run(["ssh", "engine", cmd], capture_output=True, text=True, timeout=60) audit({"event": "result", "action": action, "target": target, "cmd_hash": cmd_hash, "exit_code": result.returncode}) self.send_json(200, {"exit_code": result.returncode, "stdout": result.stdout[-1000:], "stderr": result.stderr[-500:]}) except subprocess.TimeoutExpired: audit({"event": "timeout", "action": action, "target": target, "cmd_hash": cmd_hash}) self.send_json(504, {"error": "command timed out"}) except Exception as e: audit({"event": "error", "action": action, "target": target, "cmd_hash": cmd_hash, "error": str(e)}) self.send_json(500, {"error": str(e)}) def get_dynamic_status(self): try: r = subprocess.run(["ssh", "engine", "docker ps -q | wc -l"], capture_output=True, text=True, timeout=10) count = int(r.stdout.strip()) if r.stdout.strip() else 25 except Exception: count = 25 return {"status": "ok", "phase": 4, "containers": count, "agents": 6, "infra": 14, "monitoring": 5, "skills": 63} def send_json(self, code, data): self.send_response(code) self.send_header("Content-Type", "application/json") self.end_headers() self.wfile.write(json.dumps(data).encode()) def log_message(self, *_args): return if __name__ == "__main__": http.server.HTTPServer(("0.0.0.0", PORT), DeployHandler).serve_forever()