agentic-ai-engineering/deploy_webhook.py

163 lines
6.3 KiB
Python

#!/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()