979 lines
38 KiB
Python
Executable File
979 lines
38 KiB
Python
Executable File
#!/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, "<h1>链接无效或已过期</h1>")
|
|
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, "<h1>申请已处理或不存在</h1>")
|
|
return
|
|
content = f"""<!doctype html><meta charset="utf-8">
|
|
<title>苍耳服务器临时授权</title>
|
|
<h1>苍耳服务器临时授权</h1>
|
|
<p>主控:苍耳。是否批准,以苍耳本次选择为准。</p>
|
|
<p>申请者:{html.escape(row["requester_name"])} ({html.escape(row["requested_by"])})</p>
|
|
<p>申请目的:{html.escape(row["reason"])}</p>
|
|
<p>时长:{row["duration_seconds"] // 3600} 小时</p>
|
|
<p>期间可以:检查服务器、同步和修改代码、运行测试、查看故障日志。</p>
|
|
<p>不能:修改系统账户、密钥、网络、防火墙、磁盘和系统启动,
|
|
也不能操作受管工作区以外的个人文件。</p>
|
|
<p>授权到期自动失效,可从新加坡端提前撤销。</p>
|
|
<form method="post" action="/approve/grant">
|
|
<input type="hidden" name="id" value="{html.escape(grant_id)}">
|
|
<input type="hidden" name="token" value="{html.escape(token)}">
|
|
<button type="submit">批准本次临时授权</button>
|
|
</form>"""
|
|
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, "<h1>链接无效或已过期</h1>")
|
|
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, "<h1>申请已处理或不存在</h1>")
|
|
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"<h1>副控运维授权成功</h1><p>苍耳仍保留最终控制权;"
|
|
f"本次授权将在 {expires_at} 自动失效,可以关闭此页面。</p>",
|
|
)
|
|
|
|
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<?",
|
|
(node_id, now),
|
|
)
|
|
row = conn.execute(
|
|
"SELECT id,action,args_json FROM tasks "
|
|
"WHERE node_id=? AND status='approved' ORDER BY approved_at,id LIMIT 1",
|
|
(node_id,),
|
|
).fetchone()
|
|
if row is not None:
|
|
conn.execute(
|
|
"UPDATE tasks SET status='leased',leased_until=? WHERE id=?",
|
|
(now + TASK_LEASE_SECONDS, row["id"]),
|
|
)
|
|
conn.execute(
|
|
"UPDATE nodes SET last_seen_at=? WHERE id=?", (now, node_id)
|
|
)
|
|
conn.commit()
|
|
if row is None:
|
|
self.send_json(HTTPStatus.OK, {"task": None})
|
|
return
|
|
self.send_json(
|
|
HTTPStatus.OK,
|
|
{
|
|
"task": {
|
|
"id": row["id"],
|
|
"action": row["action"],
|
|
"args": json.loads(row["args_json"]),
|
|
}
|
|
},
|
|
)
|
|
|
|
def complete_task(self, task_id: str) -> 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()
|