#!/usr/bin/env python3 """Singapore-side control plane for the Cang'er outbound node.""" from __future__ import annotations import argparse import base64 import hashlib import hmac import html import json import os import re import smtplib import sqlite3 import sys from email.message import EmailMessage from http import HTTPStatus from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any from urllib.parse import parse_qs, urlencode, urlparse sys.path.insert(0, str(Path(__file__).resolve().parents[1])) from shared import ( # noqa: E402 MAX_JSON_BYTES, MAX_RESULT_BYTES, PAIRING_TTL_SECONDS, TASK_LEASE_SECONDS, compact_json, new_id, new_pairing_code, new_token, now_ts, secret_hash, secret_matches, validate_node_name, validate_task, ) SCHEMA = """ CREATE TABLE IF NOT EXISTS pairings ( id TEXT PRIMARY KEY, code_hash TEXT NOT NULL, expires_at INTEGER NOT NULL, used_at INTEGER ); CREATE TABLE IF NOT EXISTS nodes ( id TEXT PRIMARY KEY, name TEXT NOT NULL UNIQUE, token_hash TEXT NOT NULL, created_at INTEGER NOT NULL, last_seen_at INTEGER, agent_version TEXT, policy_json TEXT NOT NULL DEFAULT '{}' ); CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, node_id TEXT NOT NULL REFERENCES nodes(id), action TEXT NOT NULL, args_json TEXT NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, approved_at INTEGER, leased_until INTEGER, completed_at INTEGER, result_json TEXT, grant_id TEXT, requested_by TEXT NOT NULL DEFAULT 'ICE-GL-ZY001', executor_persona TEXT NOT NULL DEFAULT 'ICE-GL-CA001', FOREIGN KEY(node_id) REFERENCES nodes(id) ); CREATE INDEX IF NOT EXISTS idx_tasks_node_status ON tasks(node_id, status, created_at); CREATE TABLE IF NOT EXISTS task_events ( seq INTEGER PRIMARY KEY AUTOINCREMENT, task_id TEXT NOT NULL REFERENCES tasks(id), created_at INTEGER NOT NULL, kind TEXT NOT NULL, payload_json TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_task_events_task_seq ON task_events(task_id, seq); CREATE TABLE IF NOT EXISTS grants ( id TEXT PRIMARY KEY, node_id TEXT NOT NULL REFERENCES nodes(id), scopes_json TEXT NOT NULL, roots_json TEXT NOT NULL, reason TEXT NOT NULL, duration_seconds INTEGER NOT NULL, status TEXT NOT NULL, created_at INTEGER NOT NULL, approved_at INTEGER, expires_at INTEGER, revoked_at INTEGER ,requested_by TEXT NOT NULL DEFAULT 'UNKNOWN' ,requester_name TEXT NOT NULL DEFAULT '' ); CREATE INDEX IF NOT EXISTS idx_grants_node_status ON grants(node_id, status, expires_at); """ class ControlPlane: def __init__( self, db_path: str, admin_token: str, owner_token: str, persona_token: str, operator_token: str, approval_signing_key: str, owner_email: str = "", public_url: str = "", ) -> None: for name, value in { "admin token": admin_token, "owner token": owner_token, "persona token": persona_token, "operator token": operator_token, }.items(): if len(value) < 24: raise ValueError(f"{name} must contain at least 24 characters") if len(approval_signing_key) < 32: raise ValueError("approval signing key must contain at least 32 characters") self.db_path = db_path self.admin_hash = secret_hash(admin_token) self.owner_hash = secret_hash(owner_token) self.persona_hash = secret_hash(persona_token) self.operator_hash = secret_hash(operator_token) self.operator_id = os.environ.get("CANGER_OPERATOR_ID", "ICE-GL-ZY001") self.owner_id = os.environ.get("CANGER_OWNER_ID", "CANGER-HUMAN-OWNER") self.executor_persona_id = os.environ.get( "CANGER_EXECUTOR_PERSONA_ID", "ICE-GL-CA001" ) self.approval_signing_key = approval_signing_key.encode("utf-8") self.owner_email = owner_email self.public_url = public_url.rstrip("/") Path(db_path).parent.mkdir(parents=True, exist_ok=True) with self.db() as conn: conn.executescript(SCHEMA) columns = { row["name"] for row in conn.execute("PRAGMA table_info(grants)").fetchall() } if "requester_name" not in columns: conn.execute( "ALTER TABLE grants ADD COLUMN requester_name " "TEXT NOT NULL DEFAULT ''" ) def db(self) -> sqlite3.Connection: conn = sqlite3.connect(self.db_path, timeout=10, isolation_level=None) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys=ON") conn.execute("PRAGMA journal_mode=WAL") return conn @staticmethod def bearer(headers: Any) -> str: value = headers.get("Authorization", "") if not value.startswith("Bearer "): return "" return value[7:].strip() def role_allowed(self, headers: Any, *roles: str) -> bool: token = self.bearer(headers) hashes = { "admin": self.admin_hash, "owner": self.owner_hash, "persona": self.persona_hash, "operator": self.operator_hash, } return any(secret_matches(token, hashes[role]) for role in roles) def node_for_token(self, node_id: str, headers: Any) -> sqlite3.Row | None: token = self.bearer(headers) with self.db() as conn: row = conn.execute( "SELECT * FROM nodes WHERE id=?", (node_id,) ).fetchone() if row is None or not secret_matches(token, row["token_hash"]): return None return row def grant_token(self, grant_id: str, link_expires_at: int) -> str: message = f"canger-grant/v1\n{grant_id}\n{link_expires_at}".encode("utf-8") signature = hmac.new( self.approval_signing_key, message, hashlib.sha256 ).digest() encoded = base64.urlsafe_b64encode(signature).decode("ascii").rstrip("=") return f"{link_expires_at}.{encoded}" def verify_grant_token(self, grant_id: str, token: str) -> bool: try: raw_expiry, _ = token.split(".", 1) expiry = int(raw_expiry) except (ValueError, TypeError): return False if expiry < now_ts(): return False return hmac.compare_digest(token, self.grant_token(grant_id, expiry)) def send_grant_email(self, grant: dict[str, Any]) -> str: required = { "owner_email": self.owner_email, "public_url": self.public_url, "smtp_host": os.environ.get("CANGER_SMTP_HOST", ""), "smtp_from": os.environ.get("CANGER_SMTP_FROM", ""), } if not all(required.values()): return "not_configured" link_expiry = now_ts() + 30 * 60 token = self.grant_token(grant["id"], link_expiry) link = ( f"{self.public_url}/approve/grant?" + urlencode({"id": grant["id"], "token": token}) ) message = EmailMessage() message["Subject"] = f"苍耳服务器副控运维授权({grant['duration_seconds'] // 3600} 小时)" message["From"] = required["smtp_from"] message["To"] = required["owner_email"] message.set_content( "有人格体申请进入苍耳服务器的限时受控运维会话。" "苍耳仍是服务器主控与最终决定者。\n\n" f"申请者:{grant['requester_name']} ({grant['requested_by']})\n" f"申请目的:{grant['reason']}\n" f"时长:{grant['duration_seconds'] // 3600} 小时\n\n" "期间可以:检查服务器、同步和修改代码、运行测试、查看故障日志。\n" "不能:修改系统账户、密钥、网络、防火墙、磁盘和系统启动," "也不能操作受管工作区以外的个人文件。\n\n" f"打开链接查看并确认:{link}\n\n" "链接 30 分钟失效;授权本身到期后自动失效。" ) host = required["smtp_host"] port = int(os.environ.get("CANGER_SMTP_PORT", "465")) username = os.environ.get("CANGER_SMTP_USERNAME", "") password = os.environ.get("CANGER_SMTP_PASSWORD", "") if port == 465: client: Any = smtplib.SMTP_SSL(host, port, timeout=20) else: client = smtplib.SMTP(host, port, timeout=20) client.starttls() try: if username: client.login(username, password) client.send_message(message) finally: client.quit() return "sent" class Handler(BaseHTTPRequestHandler): server_version = "CangerControl/0.1" @property def app(self) -> ControlPlane: return self.server.app # type: ignore[attr-defined] def log_message(self, fmt: str, *args: Any) -> None: print(f"{self.address_string()} - {fmt % args}", file=sys.stderr) def send_json(self, status: int, payload: Any) -> None: data = compact_json(payload).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(data) def send_html(self, status: int, content: str) -> None: data = content.encode("utf-8") self.send_response(status) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(data))) self.send_header("Cache-Control", "no-store") self.end_headers() self.wfile.write(data) def read_json(self) -> dict[str, Any]: try: size = int(self.headers.get("Content-Length", "0")) except ValueError as exc: raise ValueError("invalid Content-Length") from exc if size <= 0 or size > MAX_JSON_BYTES: raise ValueError("invalid JSON body size") value = json.loads(self.rfile.read(size)) if not isinstance(value, dict): raise ValueError("JSON body must be an object") return value def path_parts(self) -> list[str]: return [part for part in urlparse(self.path).path.split("/") if part] def require_role(self, *roles: str) -> bool: if self.app.role_allowed(self.headers, *roles): return True self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}) return False def requester_id(self) -> str: if self.app.role_allowed(self.headers, "persona"): return self.app.executor_persona_id if self.app.role_allowed(self.headers, "owner"): return self.app.owner_id return self.app.operator_id def request_identity(self, body: dict[str, Any]) -> tuple[str, str]: requester_id = body.get("requester_id") requester_name = body.get("requester_name") if not isinstance(requester_id, str) or not re.fullmatch( r"[A-Za-z0-9._:+∞-]{2,80}", requester_id ): raise ValueError("valid requester_id is required") if ( not isinstance(requester_name, str) or not requester_name.strip() or len(requester_name) > 100 ): raise ValueError("valid requester_name is required") return requester_id, requester_name.strip() def do_GET(self) -> None: # noqa: N802 try: parts = self.path_parts() if parts == ["health"]: self.send_json(HTTPStatus.OK, {"ok": True, "service": "canger-control"}) return if parts == ["approve", "grant"]: self.grant_approval_page() return if parts == ["v1", "nodes"]: if not self.require_role("admin", "owner", "persona", "operator"): return with self.app.db() as conn: rows = conn.execute( "SELECT id,name,created_at,last_seen_at,agent_version FROM nodes " "ORDER BY created_at" ).fetchall() self.send_json(HTTPStatus.OK, {"nodes": [dict(row) for row in rows]}) return if len(parts) == 4 and parts[:2] == ["v1", "tasks"]: if parts[3] != "": pass if len(parts) == 3 and parts[:2] == ["v1", "tasks"]: if not self.require_role("admin", "owner", "persona", "operator"): return with self.app.db() as conn: row = conn.execute( "SELECT * FROM tasks WHERE id=?", (parts[2],) ).fetchone() if row is None: self.send_json(HTTPStatus.NOT_FOUND, {"error": "task not found"}) return payload = dict(row) payload["args"] = json.loads(payload.pop("args_json")) if payload["result_json"] is not None: payload["result"] = json.loads(payload.pop("result_json")) else: payload.pop("result_json") self.send_json(HTTPStatus.OK, payload) return if ( len(parts) == 4 and parts[:2] == ["v1", "tasks"] and parts[3] == "events" ): if not self.require_role("admin", "owner", "persona", "operator"): return after = 0 query = urlparse(self.path).query for item in query.split("&"): if item.startswith("after="): after = max(0, int(item.split("=", 1)[1])) with self.app.db() as conn: rows = conn.execute( "SELECT seq,created_at,kind,payload_json FROM task_events " "WHERE task_id=? AND seq>? ORDER BY seq LIMIT 500", (parts[2], after), ).fetchall() events = [] for row in rows: event = dict(row) event["payload"] = json.loads(event.pop("payload_json")) events.append(event) self.send_json(HTTPStatus.OK, {"events": events}) return if parts == ["v1", "grants"]: if not self.require_role("admin", "owner", "persona", "operator"): return with self.app.db() as conn: rows = conn.execute( "SELECT * FROM grants ORDER BY created_at DESC LIMIT 200" ).fetchall() grants = [] for row in rows: item = dict(row) item["scopes"] = json.loads(item.pop("scopes_json")) item["roots"] = json.loads(item.pop("roots_json")) grants.append(item) self.send_json(HTTPStatus.OK, {"grants": grants}) return if ( len(parts) == 5 and parts[:2] == ["v1", "nodes"] and parts[3:] == ["tasks", "next"] ): self.next_task(parts[2]) return self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) except Exception as exc: self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) def do_POST(self) -> None: # noqa: N802 try: parts = self.path_parts() if parts == ["v1", "pairings"]: self.create_pairing() return if parts == ["v1", "pairings", "claim"]: self.claim_pairing() return if parts == ["v1", "tasks"]: self.create_task() return if parts == ["v1", "grants"]: self.create_grant() return if len(parts) == 4 and parts[:2] == ["v1", "grants"]: if parts[3] == "revoke": self.revoke_grant(parts[2]) return if len(parts) == 4 and parts[:2] == ["v1", "tasks"]: if parts[3] == "approve": self.decide_task(parts[2], "approved") return if parts[3] == "reject": self.decide_task(parts[2], "rejected") return if parts[3] == "result": self.complete_task(parts[2]) return if parts[3] == "events": self.append_event(parts[2]) return if ( len(parts) == 4 and parts[:2] == ["v1", "nodes"] and parts[3] == "heartbeat" ): self.heartbeat(parts[2]) return if parts == ["approve", "grant"]: self.approve_grant_form() return self.send_json(HTTPStatus.NOT_FOUND, {"error": "not found"}) except json.JSONDecodeError: self.send_json(HTTPStatus.BAD_REQUEST, {"error": "invalid JSON"}) except ValueError as exc: self.send_json(HTTPStatus.BAD_REQUEST, {"error": str(exc)}) except Exception as exc: self.send_json(HTTPStatus.INTERNAL_SERVER_ERROR, {"error": str(exc)}) def create_pairing(self) -> None: if not self.require_role("admin"): return code = new_pairing_code() pairing_id = new_id("pair") expires_at = now_ts() + PAIRING_TTL_SECONDS with self.app.db() as conn: conn.execute( "INSERT INTO pairings(id,code_hash,expires_at) VALUES(?,?,?)", (pairing_id, secret_hash(code), expires_at), ) self.send_json( HTTPStatus.CREATED, {"pairing_id": pairing_id, "code": code, "expires_at": expires_at}, ) def claim_pairing(self) -> None: body = self.read_json() code = body.get("code") name = validate_node_name(body.get("node_name")) agent_version = str(body.get("agent_version", ""))[:64] policy = self.validated_node_policy(body.get("policy", {})) if not isinstance(code, str): raise ValueError("code is required") now = now_ts() node_id = new_id("node") node_token = new_token() with self.app.db() as conn: conn.execute("BEGIN IMMEDIATE") rows = conn.execute( "SELECT * FROM pairings WHERE used_at IS NULL AND expires_at>=?", (now,) ).fetchall() pairing = next( (row for row in rows if secret_matches(code, row["code_hash"])), None ) if pairing is None: conn.rollback() self.send_json( HTTPStatus.UNAUTHORIZED, {"error": "invalid or expired pairing code"} ) return existing = conn.execute( "SELECT id FROM nodes WHERE name=?", (name,) ).fetchone() if existing is not None: conn.rollback() self.send_json( HTTPStatus.CONFLICT, {"error": "node name already registered"} ) return conn.execute( "INSERT INTO nodes(id,name,token_hash,created_at,last_seen_at," "agent_version,policy_json) VALUES(?,?,?,?,?,?,?)", ( node_id, name, secret_hash(node_token), now, now, agent_version, compact_json(policy), ), ) conn.execute( "UPDATE pairings SET used_at=? WHERE id=?", (now, pairing["id"]) ) conn.commit() self.send_json( HTTPStatus.CREATED, {"node_id": node_id, "node_token": node_token} ) @staticmethod def validated_node_policy(value: Any) -> dict[str, Any]: if not isinstance(value, dict): raise ValueError("node policy must be an object") roots = value.get("allowed_roots", []) actions = value.get("enabled_actions", []) allowed_actions = { "system.status", "repo.status", "repo.fetch", "service.logs", "command.run", } if ( not isinstance(roots, list) or not roots or any(not isinstance(root, str) or not root.startswith("/") for root in roots) ): raise ValueError("node policy requires absolute allowed_roots") if ( not isinstance(actions, list) or any(action not in allowed_actions for action in actions) ): raise ValueError("node policy contains unsupported actions") return { "profile": "developer_standard", "allowed_roots": sorted(set(root.rstrip("/") for root in roots)), "enabled_actions": sorted(set(actions)), } def create_task(self) -> None: if not self.require_role("admin", "operator", "persona"): return body = self.read_json() requested_by, requester_name = self.request_identity(body) node_id = body.get("node_id") if not isinstance(node_id, str): raise ValueError("node_id is required") action, args = validate_task(body.get("action"), body.get("args")) task_id = new_id("task") approved_at = None grant_id = None with self.app.db() as conn: if conn.execute("SELECT 1 FROM nodes WHERE id=?", (node_id,)).fetchone() is None: self.send_json(HTTPStatus.NOT_FOUND, {"error": "node not found"}) return rows = conn.execute( "SELECT * FROM grants WHERE node_id=? AND status='active' " "AND expires_at>? AND requested_by=? ORDER BY expires_at", (node_id, now_ts(), requested_by), ).fetchall() for row in rows: if self.task_allowed_by_grant(action, args, row): grant_id = row["id"] approved_at = now_ts() break status = "approved" if grant_id else "pending" conn.execute( "INSERT INTO tasks(id,node_id,action,args_json,status,created_at," "approved_at,grant_id,requested_by,executor_persona) " "VALUES(?,?,?,?,?,?,?,?,?,?)", ( task_id, node_id, action, compact_json(args), status, now_ts(), approved_at, grant_id, requested_by, requested_by, ), ) self.send_json( HTTPStatus.CREATED, { "task_id": task_id, "status": status, "approval_required": grant_id is None, "grant_id": grant_id, "requested_by": requested_by, "requester_name": requester_name, "executor_persona": requested_by, "action": action, "args": args, }, ) @staticmethod def task_allowed_by_grant( action: str, args: dict[str, Any], grant: sqlite3.Row ) -> bool: scopes = set(json.loads(grant["scopes_json"])) if action not in scopes: return False roots = [str(root).rstrip("/") for root in json.loads(grant["roots_json"])] candidate = None if action in {"repo.status", "repo.fetch"}: candidate = args["repo"] elif action == "command.run": candidate = args["cwd"] if candidate is None: return True normalized = str(candidate).rstrip("/") return any( normalized == root or normalized.startswith(root + "/") for root in roots ) def create_grant(self) -> None: if not self.require_role("admin", "operator", "persona"): return body = self.read_json() requested_by, requester_name = self.request_identity(body) node_id = body.get("node_id") reason = body.get("reason", "编程与调试") duration = body.get("duration_seconds", 4 * 3600) if not isinstance(node_id, str): raise ValueError("node_id is required") if not isinstance(reason, str) or not reason.strip() or len(reason) > 300: raise ValueError("invalid grant reason") if not isinstance(duration, int) or not 3600 <= duration <= 8 * 3600: raise ValueError("grant duration must be between 1 and 8 hours") with self.app.db() as conn: node = conn.execute( "SELECT policy_json FROM nodes WHERE id=?", (node_id,) ).fetchone() if node is None: self.send_json(HTTPStatus.NOT_FOUND, {"error": "node not found"}) return policy = json.loads(node["policy_json"]) scopes = policy.get("enabled_actions", []) roots = policy.get("allowed_roots", []) if not scopes or not roots: self.send_json( HTTPStatus.CONFLICT, {"error": "node has not reported a usable safety policy"}, ) return grant_id = new_id("grant") grant = { "id": grant_id, "node_id": node_id, "scopes": scopes, "roots": roots, "reason": reason.strip(), "duration_seconds": duration, "requested_by": requested_by, "requester_name": requester_name, } conn.execute( "INSERT INTO grants(id,node_id,scopes_json,roots_json,reason," "duration_seconds,status,created_at,requested_by,requester_name) " "VALUES(?,?,?,?,?,?,?,?,?,?)", ( grant_id, node_id, compact_json(grant["scopes"]), compact_json(grant["roots"]), grant["reason"], duration, "pending", now_ts(), requested_by, requester_name, ), ) try: notification = self.app.send_grant_email(grant) except Exception as exc: print(f"grant email failed: {exc}", file=sys.stderr) notification = "failed" self.send_json( HTTPStatus.CREATED, { "grant_id": grant_id, "status": "pending", "requested_by": requested_by, "requester_name": requester_name, "notification": notification, "duration_seconds": duration, "scopes": grant["scopes"], "roots": grant["roots"], }, ) def grant_approval_page(self) -> None: query = parse_qs(urlparse(self.path).query) grant_id = query.get("id", [""])[0] token = query.get("token", [""])[0] if not self.app.verify_grant_token(grant_id, token): self.send_html(HTTPStatus.UNAUTHORIZED, "

链接无效或已过期

") return with self.app.db() as conn: row = conn.execute("SELECT * FROM grants WHERE id=?", (grant_id,)).fetchone() if row is None or row["status"] != "pending": self.send_html(HTTPStatus.CONFLICT, "

申请已处理或不存在

") return content = f""" 苍耳服务器临时授权

苍耳服务器临时授权

主控:苍耳。是否批准,以苍耳本次选择为准。

申请者:{html.escape(row["requester_name"])} ({html.escape(row["requested_by"])})

申请目的:{html.escape(row["reason"])}

时长:{row["duration_seconds"] // 3600} 小时

期间可以:检查服务器、同步和修改代码、运行测试、查看故障日志。

不能:修改系统账户、密钥、网络、防火墙、磁盘和系统启动, 也不能操作受管工作区以外的个人文件。

授权到期自动失效,可从新加坡端提前撤销。

""" self.send_html(HTTPStatus.OK, content) def approve_grant_form(self) -> None: size = int(self.headers.get("Content-Length", "0")) if size <= 0 or size > 16_384: raise ValueError("invalid form body size") form = parse_qs(self.rfile.read(size).decode("utf-8")) grant_id = form.get("id", [""])[0] token = form.get("token", [""])[0] if not self.app.verify_grant_token(grant_id, token): self.send_html(HTTPStatus.UNAUTHORIZED, "

链接无效或已过期

") return approved_at = now_ts() with self.app.db() as conn: row = conn.execute("SELECT * FROM grants WHERE id=?", (grant_id,)).fetchone() if row is None or row["status"] != "pending": self.send_html(HTTPStatus.CONFLICT, "

申请已处理或不存在

") return expires_at = approved_at + row["duration_seconds"] conn.execute( "UPDATE grants SET status='active',approved_at=?,expires_at=? " "WHERE id=? AND status='pending'", (approved_at, expires_at, grant_id), ) self.send_html( HTTPStatus.OK, f"

副控运维授权成功

苍耳仍保留最终控制权;" f"本次授权将在 {expires_at} 自动失效,可以关闭此页面。

", ) def revoke_grant(self, grant_id: str) -> None: if not self.require_role("admin", "owner"): return with self.app.db() as conn: cursor = conn.execute( "UPDATE grants SET status='revoked',revoked_at=? " "WHERE id=? AND status='active'", (now_ts(), grant_id), ) if cursor.rowcount != 1: self.send_json(HTTPStatus.CONFLICT, {"error": "grant is not active"}) return self.send_json(HTTPStatus.OK, {"grant_id": grant_id, "status": "revoked"}) def decide_task(self, task_id: str, decision: str) -> None: if not self.require_role("admin", "owner"): return with self.app.db() as conn: cursor = conn.execute( "UPDATE tasks SET status=?,approved_at=? " "WHERE id=? AND status='pending'", (decision, now_ts(), task_id), ) if cursor.rowcount != 1: self.send_json( HTTPStatus.CONFLICT, {"error": "task is not pending or does not exist"} ) return self.send_json(HTTPStatus.OK, {"task_id": task_id, "status": decision}) def next_task(self, node_id: str) -> None: if self.app.node_for_token(node_id, self.headers) is None: self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized node"}) return now = now_ts() with self.app.db() as conn: conn.execute("BEGIN IMMEDIATE") conn.execute( "UPDATE grants SET status='expired' " "WHERE status='active' AND expires_at<=?", (now,), ) conn.execute( "UPDATE tasks SET status='pending',approved_at=NULL,grant_id=NULL " "WHERE node_id=? AND status='approved' AND grant_id IS NOT NULL " "AND grant_id NOT IN (SELECT id FROM grants WHERE status='active' " "AND expires_at>?)", (node_id, now), ) conn.execute( "UPDATE tasks SET status='approved',leased_until=NULL " "WHERE node_id=? AND status='leased' AND leased_until None: body = self.read_json() if len(compact_json(body).encode("utf-8")) > MAX_RESULT_BYTES: raise ValueError("task result is too large") with self.app.db() as conn: row = conn.execute( "SELECT node_id,status FROM tasks WHERE id=?", (task_id,) ).fetchone() if row is None: self.send_json(HTTPStatus.NOT_FOUND, {"error": "task not found"}) return if self.app.node_for_token(row["node_id"], self.headers) is None: self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized node"}) return if row["status"] != "leased": self.send_json(HTTPStatus.CONFLICT, {"error": "task is not leased"}) return conn.execute( "UPDATE tasks SET status='completed',completed_at=?,result_json=? " "WHERE id=?", (now_ts(), compact_json(body), task_id), ) self.send_json(HTTPStatus.OK, {"task_id": task_id, "status": "completed"}) def append_event(self, task_id: str) -> None: body = self.read_json() kind = body.get("kind") payload = body.get("payload", {}) if ( not isinstance(kind, str) or not kind or len(kind) > 64 or not all(char.isalnum() or char in "._-" for char in kind) ): raise ValueError("invalid event kind") encoded = compact_json(payload) if len(encoded.encode("utf-8")) > 32 * 1024: raise ValueError("event payload is too large") with self.app.db() as conn: row = conn.execute( "SELECT node_id FROM tasks WHERE id=?", (task_id,) ).fetchone() if row is None: self.send_json(HTTPStatus.NOT_FOUND, {"error": "task not found"}) return if self.app.node_for_token(row["node_id"], self.headers) is None: self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized node"}) return cursor = conn.execute( "INSERT INTO task_events(task_id,created_at,kind,payload_json) " "VALUES(?,?,?,?)", (task_id, now_ts(), kind, encoded), ) self.send_json( HTTPStatus.CREATED, {"task_id": task_id, "seq": cursor.lastrowid} ) def heartbeat(self, node_id: str) -> None: node = self.app.node_for_token(node_id, self.headers) if node is None: self.send_json(HTTPStatus.UNAUTHORIZED, {"error": "unauthorized node"}) return body = self.read_json() version = str(body.get("agent_version", ""))[:64] policy = self.validated_node_policy(body.get("policy", {})) with self.app.db() as conn: conn.execute( "UPDATE nodes SET last_seen_at=?,agent_version=?,policy_json=? WHERE id=?", (now_ts(), version, compact_json(policy), node_id), ) self.send_json(HTTPStatus.OK, {"ok": True}) class AppServer(ThreadingHTTPServer): daemon_threads = True def __init__(self, address: tuple[str, int], app: ControlPlane): self.app = app super().__init__(address, Handler) def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser() parser.add_argument("--bind", default=os.environ.get("CANGER_BIND", "127.0.0.1")) parser.add_argument( "--port", type=int, default=int(os.environ.get("CANGER_PORT", "8787")) ) parser.add_argument( "--db", default=os.environ.get( "CANGER_CONTROL_DB", "/var/lib/canger-control/control.sqlite3" ), ) return parser.parse_args() def main() -> None: args = parse_args() app = ControlPlane( args.db, os.environ.get("CANGER_ADMIN_TOKEN", ""), os.environ.get("CANGER_OWNER_TOKEN", ""), os.environ.get("CANGER_PERSONA_TOKEN", ""), os.environ.get("CANGER_OPERATOR_TOKEN", ""), os.environ.get("CANGER_APPROVAL_SIGNING_KEY", ""), os.environ.get("CANGER_OWNER_EMAIL", ""), os.environ.get("CANGER_PUBLIC_URL", ""), ) server = AppServer((args.bind, args.port), app) print(f"canger-control listening on {args.bind}:{args.port}") server.serve_forever() if __name__ == "__main__": main()