163 lines
6.3 KiB
Python
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()
|