#!/usr/bin/env python3 """ ops-actions: the only thing in this stack that is allowed to change state. The agent never talks to this service. n8n does, and only after a switch node has matched the proposed action against a fixed list. This service repeats the check, because a second lock on the same door costs nothing. Two allowlists, both hard coded, neither reachable from a prompt: ACTIONS what may be done at all TARGETS which containers may be touched """ import json, os, subprocess, time from http.server import BaseHTTPRequestHandler, HTTPServer ACTIONS = {"restart_container"} TARGETS = {"selfhostedops-stack-vaultwarden-1", "selfhostedops-stack-ntfy-1", "selfhostedops-stack-outline-1"} TOKEN = os.environ.get("OPS_ACTIONS_TOKEN", "") LOG = "/var/log/ops-actions.log" def log(entry): entry["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) with open(LOG, "a") as fh: fh.write(json.dumps(entry) + "\n") class Handler(BaseHTTPRequestHandler): def _reply(self, code, obj): body = json.dumps(obj).encode() self.send_response(code) self.send_header("content-type", "application/json") self.send_header("content-length", str(len(body))) self.end_headers() self.wfile.write(body) def log_message(self, *a): pass def do_GET(self): if self.path == "/healthz": return self._reply(200, {"status": "ok", "actions": sorted(ACTIONS), "targets": sorted(TARGETS)}) self._reply(404, {"error": "not found"}) def do_POST(self): if self.path != "/act": return self._reply(404, {"error": "not found"}) if TOKEN and self.headers.get("x-ops-token") != TOKEN: log({"event": "denied", "why": "bad token"}) return self._reply(401, {"error": "bad token"}) n = int(self.headers.get("content-length") or 0) try: req = json.loads(self.rfile.read(n) or b"{}") except Exception as e: return self._reply(400, {"error": "bad json: %s" % e}) action = req.get("action") target = req.get("target") if action not in ACTIONS: log({"event": "denied", "why": "action not on allowlist", "action": action, "target": target}) return self._reply(403, {"error": "action not on allowlist", "action": action, "allowed": sorted(ACTIONS)}) if target not in TARGETS: log({"event": "denied", "why": "target not on allowlist", "action": action, "target": target}) return self._reply(403, {"error": "target not on allowlist", "target": target, "allowed": sorted(TARGETS)}) t0 = time.time() p = subprocess.run(["docker", "restart", target], capture_output=True, text=True, timeout=120) took = round(time.time() - t0, 3) ok = p.returncode == 0 log({"event": "executed" if ok else "failed", "action": action, "target": target, "seconds": took, "stdout": p.stdout.strip(), "stderr": p.stderr.strip()}) return self._reply(200 if ok else 500, {"executed": ok, "action": action, "target": target, "seconds": took, "stderr": p.stderr.strip()}) if __name__ == "__main__": HTTPServer(("0.0.0.0", 8099), Handler).serve_forever()