7f7868f7cf
---ci--- phase: 5 milestone: v0.1 status: complete requirements: covered: [REQ-01, REQ-02, REQ-03, REQ-04, REQ-05, REQ-06, REQ-07, REQ-08, REQ-09, REQ-10, REQ-11, REQ-12, REQ-13, REQ-14, REQ-15, REQ-16, REQ-17, REQ-18, REQ-19, REQ-20] partial: [] ---/ci---
1391 lines
47 KiB
Python
1391 lines
47 KiB
Python
"""title: CoreCI Broker
|
|
author: coreci
|
|
description: Single-file credential mediation broker for CoreCI agent tools.
|
|
version: 0.1.0
|
|
requirements: []
|
|
licence: MIT
|
|
"""
|
|
import base64
|
|
import datetime
|
|
import errno
|
|
import hashlib
|
|
import hmac
|
|
import http.cookies
|
|
import json
|
|
import os
|
|
import secrets
|
|
import sqlite3
|
|
import threading
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
import uuid
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
try:
|
|
import bcrypt
|
|
except ImportError:
|
|
bcrypt = None
|
|
|
|
ENV_PATH_DEFAULT = ".env"
|
|
|
|
ENV_KEYS = (
|
|
"BROKER_ENV_PATH",
|
|
"BROKER_BIND",
|
|
"BROKER_ADMIN_USER",
|
|
"BROKER_ADMIN_PASS",
|
|
"BROKER_INSTANCE_PEPPER",
|
|
"BROKER_TOKEN_TTL",
|
|
"BROKER_SESSION_TTL",
|
|
"BROKER_DB_PATH",
|
|
"BROKER_AUDIT_LOG_PATH",
|
|
"BROKER_UI_ROOT",
|
|
"BROKER_STATIC_ROOT",
|
|
"BROKER_AGENT_STATE_BACKEND",
|
|
"BROKER_CREDS_SOURCE",
|
|
"BROKER_CREDS_FILE",
|
|
"BROKER_OWUI_BASE_URL",
|
|
"BROKER_OWUI_API_KEY",
|
|
"BROKER_MAX_AGENTS_PER_APP",
|
|
)
|
|
|
|
ENV_DEFAULTS = {
|
|
"BROKER_BIND": "127.0.0.1:8765",
|
|
"BROKER_ADMIN_USER": "admin",
|
|
"BROKER_TOKEN_TTL": "86400",
|
|
"BROKER_SESSION_TTL": "300",
|
|
"BROKER_DB_PATH": "broker.db",
|
|
"BROKER_AUDIT_LOG_PATH": "broker.audit.log",
|
|
"BROKER_UI_ROOT": "coreci_broker_ui",
|
|
"BROKER_STATIC_ROOT": ".",
|
|
"BROKER_AGENT_STATE_BACKEND": "internal",
|
|
"BROKER_CREDS_SOURCE": "file",
|
|
"BROKER_CREDS_FILE": "data/creds.json",
|
|
"BROKER_MAX_AGENTS_PER_APP": "50",
|
|
}
|
|
|
|
AUDIT_ACTIONS = (
|
|
"register",
|
|
"read",
|
|
"denied",
|
|
"login",
|
|
"logout",
|
|
"add_app",
|
|
"remove_app",
|
|
"add_user",
|
|
"remove_user",
|
|
)
|
|
|
|
AUDIT_RESULTS = ("ok", "denied", "error")
|
|
|
|
BCRYPT_PREFIXES = ("$2a$", "$2b$", "$2y$")
|
|
SCRYPT_PREFIX = "scrypt$"
|
|
SCRYPT_SALT_BYTES = 16
|
|
SCRYPT_N = 2 ** 14
|
|
SCRYPT_R = 8
|
|
SCRYPT_P = 1
|
|
|
|
STATIC_PREFIXES = (
|
|
("ui", "ui_root"),
|
|
("static", "static_root"),
|
|
)
|
|
|
|
STATIC_CONTENT_TYPES = {
|
|
".html": "text/html; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".js": "application/javascript; charset=utf-8",
|
|
".py": "text/x-python; charset=utf-8",
|
|
".txt": "text/plain; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
}
|
|
|
|
MAX_BODY_BYTES = 1024 * 1024
|
|
|
|
MAX_STATE_BYTES = 256 * 1024
|
|
|
|
SCHEMA_SQL = """
|
|
CREATE TABLE IF NOT EXISTS broker_config (
|
|
key TEXT PRIMARY KEY,
|
|
value TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS app_registrations (
|
|
uuid TEXT PRIMARY KEY,
|
|
name TEXT,
|
|
status TEXT DEFAULT 'active',
|
|
created_at TEXT,
|
|
revoked_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS user_allowlist (
|
|
user_id TEXT PRIMARY KEY,
|
|
email TEXT,
|
|
status TEXT DEFAULT 'active',
|
|
created_at TEXT,
|
|
revoked_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS agent_registrations (
|
|
agent_id TEXT,
|
|
app_registration_uuid TEXT,
|
|
user_id TEXT,
|
|
tags TEXT,
|
|
token_hash TEXT,
|
|
token_expires_at TEXT,
|
|
first_registered_at TEXT,
|
|
last_seen_at TEXT,
|
|
PRIMARY KEY (agent_id, app_registration_uuid)
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_agent_registrations_app_uuid
|
|
ON agent_registrations (app_registration_uuid);
|
|
CREATE TABLE IF NOT EXISTS admin_sessions (
|
|
session_id TEXT PRIMARY KEY,
|
|
created_at TEXT,
|
|
expires_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS agent_state (
|
|
agent_id TEXT,
|
|
app_registration_uuid TEXT,
|
|
state TEXT,
|
|
updated_at TEXT,
|
|
PRIMARY KEY (agent_id, app_registration_uuid)
|
|
);
|
|
"""
|
|
|
|
|
|
def _parse_env_file(path):
|
|
entries = []
|
|
if not os.path.isfile(path):
|
|
return entries
|
|
with open(path, "r", encoding="utf-8") as handle:
|
|
for raw in handle:
|
|
line = raw.strip()
|
|
if not line or line.startswith("#") or "=" not in line:
|
|
continue
|
|
key, _, value = (part.strip() for part in line.partition("="))
|
|
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
value = value[1:-1]
|
|
if key:
|
|
entries.append((key, value))
|
|
return entries
|
|
|
|
|
|
def _append_env_entries(path, entries):
|
|
needs_newline = os.path.exists(path) and os.path.getsize(path) > 0
|
|
if needs_newline:
|
|
with open(path, "rb") as handle:
|
|
handle.seek(-1, os.SEEK_END)
|
|
needs_newline = handle.read(1) != b"\n"
|
|
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
try:
|
|
os.fchmod(fd, 0o600)
|
|
except BaseException:
|
|
os.close(fd)
|
|
raise
|
|
with os.fdopen(fd, "a", encoding="utf-8") as handle:
|
|
if needs_newline:
|
|
handle.write("\n")
|
|
for key, value in entries:
|
|
handle.write(f"{key}={value}\n")
|
|
|
|
|
|
def _generate_pepper():
|
|
return base64.b64encode(secrets.token_bytes(32)).decode("ascii")
|
|
|
|
|
|
def _generate_admin_pass():
|
|
return secrets.token_urlsafe(32)
|
|
|
|
|
|
def _utc_now_iso():
|
|
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
|
|
|
|
|
def hash_token(token):
|
|
token_bytes = token.encode("utf-8")
|
|
if bcrypt is not None:
|
|
return bcrypt.hashpw(token_bytes, bcrypt.gensalt()).decode("ascii")
|
|
salt = secrets.token_bytes(SCRYPT_SALT_BYTES)
|
|
digest = hashlib.scrypt(token_bytes, salt=salt, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P)
|
|
encoded = base64.urlsafe_b64encode
|
|
return f"{SCRYPT_PREFIX}{encoded(salt).decode('ascii')}${encoded(digest).decode('ascii')}"
|
|
|
|
|
|
def verify_token(token, stored):
|
|
if not isinstance(stored, str) or not stored:
|
|
return False
|
|
token_bytes = token.encode("utf-8")
|
|
if stored.startswith(BCRYPT_PREFIXES):
|
|
try:
|
|
return bcrypt is not None and bcrypt.checkpw(token_bytes, stored.encode("ascii"))
|
|
except (ValueError, TypeError):
|
|
return False
|
|
if stored.startswith(SCRYPT_PREFIX):
|
|
fields = stored[len(SCRYPT_PREFIX):].split("$")
|
|
if len(fields) != 2:
|
|
return False
|
|
try:
|
|
salt = base64.urlsafe_b64decode(fields[0].encode("ascii"))
|
|
expected = base64.urlsafe_b64decode(fields[1].encode("ascii"))
|
|
actual = hashlib.scrypt(token_bytes, salt=salt, n=SCRYPT_N, r=SCRYPT_R, p=SCRYPT_P)
|
|
except (ValueError, TypeError):
|
|
return False
|
|
return hmac.compare_digest(actual, expected)
|
|
return False
|
|
|
|
|
|
def derive_token(pepper, app_uuid, user_id, agent_id, tags):
|
|
material = f"{pepper}:{app_uuid}:{user_id}:{agent_id}:{','.join(sorted(tags))}"
|
|
return base64.b64encode(hashlib.sha256(material.encode("utf-8")).digest()).decode("ascii")
|
|
|
|
|
|
def load_config(env=None):
|
|
if env is None:
|
|
env = os.environ
|
|
env_path = str(env.get("BROKER_ENV_PATH") or ENV_PATH_DEFAULT)
|
|
file_values = dict(_parse_env_file(env_path))
|
|
generated = []
|
|
if "BROKER_INSTANCE_PEPPER" not in env and "BROKER_INSTANCE_PEPPER" not in file_values:
|
|
generated.append(("BROKER_INSTANCE_PEPPER", _generate_pepper()))
|
|
if "BROKER_ADMIN_PASS" not in env and "BROKER_ADMIN_PASS" not in file_values:
|
|
generated.append(("BROKER_ADMIN_PASS", _generate_admin_pass()))
|
|
if generated:
|
|
_append_env_entries(env_path, generated)
|
|
file_values.update(generated)
|
|
config = {"BROKER_ENV_PATH": env_path}
|
|
for key in ENV_KEYS:
|
|
if key == "BROKER_ENV_PATH":
|
|
continue
|
|
config[key] = str(env.get(key) or file_values.get(key) or ENV_DEFAULTS.get(key, ""))
|
|
for key, value in generated:
|
|
if key == "BROKER_ADMIN_PASS":
|
|
print(f"BROKER_ADMIN_PASS={value}", flush=True)
|
|
return config
|
|
|
|
|
|
class Store:
|
|
def __init__(self, path):
|
|
self._lock = threading.Lock()
|
|
fd = os.open(path, os.O_CREAT | os.O_WRONLY, 0o600)
|
|
try:
|
|
os.fchmod(fd, 0o600)
|
|
finally:
|
|
os.close(fd)
|
|
self.conn = sqlite3.connect(path, check_same_thread=False)
|
|
self.conn.executescript(SCHEMA_SQL)
|
|
self._run(
|
|
"DELETE FROM admin_sessions WHERE expires_at IS NULL OR expires_at <= ?",
|
|
(_utc_now_iso(),),
|
|
)
|
|
|
|
def _run(self, sql, params=()):
|
|
with self._lock:
|
|
rows = self.conn.execute(sql, params).fetchall()
|
|
self.conn.commit()
|
|
return rows
|
|
|
|
def set_config(self, key, value):
|
|
self._run("INSERT OR REPLACE INTO broker_config VALUES (?, ?)", (key, str(value)))
|
|
|
|
def get_config(self, key):
|
|
rows = self._run("SELECT value FROM broker_config WHERE key = ?", (key,))
|
|
return rows[0][0] if rows else None
|
|
|
|
def create_admin_session(self, session_id, created_at, expires_at):
|
|
self._run(
|
|
"INSERT OR REPLACE INTO admin_sessions VALUES (?, ?, ?)",
|
|
(session_id, created_at, expires_at),
|
|
)
|
|
|
|
def get_admin_session(self, session_id):
|
|
rows = self._run("SELECT * FROM admin_sessions WHERE session_id = ?", (session_id,))
|
|
return rows[0] if rows else None
|
|
|
|
def delete_admin_session(self, session_id):
|
|
self._run("DELETE FROM admin_sessions WHERE session_id = ?", (session_id,))
|
|
|
|
def list_rows(self, table):
|
|
return self._run("SELECT * FROM %s ORDER BY created_at" % table)
|
|
|
|
def get_row(self, table, key, value):
|
|
rows = self._run("SELECT * FROM %s WHERE %s = ?" % (table, key), (value,))
|
|
return rows[0] if rows else None
|
|
|
|
def add_row(self, table, value, field):
|
|
self._run(
|
|
"INSERT INTO %s VALUES (?, ?, 'active', ?, NULL)" % table,
|
|
(value, field, _utc_now_iso()),
|
|
)
|
|
|
|
def set_row_status(self, table, key, value, status):
|
|
self._run(
|
|
"UPDATE %s SET status = ?, revoked_at = ? WHERE %s = ?" % (table, key),
|
|
(status, _utc_now_iso() if status == "revoked" else None, value),
|
|
)
|
|
|
|
def get_agent(self, agent_id, app_uuid):
|
|
rows = self._run(
|
|
"SELECT * FROM agent_registrations WHERE agent_id = ? AND app_registration_uuid = ?",
|
|
(agent_id, app_uuid),
|
|
)
|
|
return rows[0] if rows else None
|
|
|
|
def agents_by_uuid(self, app_uuid):
|
|
return self._run(
|
|
"SELECT * FROM agent_registrations WHERE app_registration_uuid = ?", (app_uuid,)
|
|
)
|
|
|
|
def save_agent(self, agent_id, app_uuid, user_id, tags, token_hash, expires_at, now):
|
|
self._run(
|
|
"INSERT INTO agent_registrations VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
|
(agent_id, app_uuid, user_id, tags, token_hash, expires_at, now, now),
|
|
)
|
|
|
|
def update_agent(self, agent_id, app_uuid, user_id, tags, token_hash, expires_at, now):
|
|
self._run(
|
|
"UPDATE agent_registrations SET user_id = ?, tags = ?, token_hash = ?, "
|
|
"token_expires_at = ?, last_seen_at = ? WHERE agent_id = ? AND app_registration_uuid = ?",
|
|
(user_id, tags, token_hash, expires_at, now, agent_id, app_uuid),
|
|
)
|
|
|
|
def touch_agent(self, agent_id, app_uuid, now):
|
|
self._run(
|
|
"UPDATE agent_registrations SET last_seen_at = ? WHERE agent_id = ? AND app_registration_uuid = ?",
|
|
(now, agent_id, app_uuid),
|
|
)
|
|
|
|
def get_agent_state(self, agent_id, app_uuid):
|
|
rows = self._run("SELECT state FROM agent_state WHERE agent_id = ? AND app_registration_uuid = ?",
|
|
(agent_id, app_uuid))
|
|
return json.loads(rows[0][0]) if rows else None
|
|
|
|
def save_agent_state(self, agent_id, app_uuid, state):
|
|
self._run("INSERT OR REPLACE INTO agent_state VALUES (?, ?, ?, ?)",
|
|
(agent_id, app_uuid, state, _utc_now_iso()))
|
|
|
|
def close(self):
|
|
with self._lock:
|
|
self.conn.close()
|
|
|
|
|
|
class InternalAgentStateBackend:
|
|
def __init__(self, store):
|
|
self.store = store
|
|
|
|
def get_state(self, agent_id, app_uuid):
|
|
return self.store.get_agent_state(agent_id, app_uuid)
|
|
|
|
def put_state(self, agent_id, app_uuid, state):
|
|
self.store.save_agent_state(agent_id, app_uuid, json.dumps(state))
|
|
|
|
|
|
class OwuiAgentStateBackend:
|
|
"""Agent-state documents over HTTP: GET/PUT {base}/api/v1/broker-agent-state/{agent_id}."""
|
|
|
|
def __init__(self, base_url, api_key):
|
|
self.base_url = str(base_url or "").rstrip("/")
|
|
self.api_key = str(api_key or "")
|
|
|
|
def _request(self, method, agent_id, payload=None):
|
|
url = self.base_url + "/api/v1/broker-agent-state/" + urllib.parse.quote(agent_id, safe="")
|
|
data = None if payload is None else json.dumps(payload).encode("utf-8")
|
|
request = urllib.request.Request(
|
|
url, data=data, method=method,
|
|
headers={"Authorization": "Bearer %s" % self.api_key, "Content-Type": "application/json"},
|
|
)
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
return response.read()
|
|
|
|
def get_state(self, agent_id, app_uuid):
|
|
try:
|
|
raw = self._request("GET", agent_id)
|
|
except urllib.error.HTTPError as error:
|
|
if error.code == 404:
|
|
return None
|
|
raise
|
|
return json.loads(raw.decode("utf-8")) if raw else None
|
|
|
|
def put_state(self, agent_id, app_uuid, state):
|
|
self._request("PUT", agent_id, state)
|
|
|
|
|
|
def _build_agent_state_backend(config, store):
|
|
if str(config.get("BROKER_AGENT_STATE_BACKEND") or "") == "owui":
|
|
return OwuiAgentStateBackend(
|
|
config.get("BROKER_OWUI_BASE_URL"), config.get("BROKER_OWUI_API_KEY")
|
|
)
|
|
return InternalAgentStateBackend(store)
|
|
|
|
|
|
class _SecretNotFound(Exception):
|
|
pass
|
|
|
|
|
|
class _CredsSourceError(Exception):
|
|
pass
|
|
|
|
|
|
def _secret_response(path, data, updated_at=None):
|
|
serialized = json.dumps(data, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
return {"path": path, "data": data, "version": hashlib.sha256(serialized).hexdigest()[:8],
|
|
"updated_at": updated_at}
|
|
|
|
|
|
class FileCredsSource:
|
|
def __init__(self, path):
|
|
self.path = str(path or "data/creds.json")
|
|
|
|
def get_secret(self, path):
|
|
try:
|
|
with open(self.path, "r", encoding="utf-8") as handle:
|
|
document = json.load(handle)
|
|
except FileNotFoundError:
|
|
raise _SecretNotFound(path)
|
|
except (OSError, ValueError) as error:
|
|
raise _CredsSourceError(f"creds file unusable: {error}")
|
|
if not isinstance(document, dict):
|
|
raise _CredsSourceError("creds file must map paths to secret objects")
|
|
if path not in document:
|
|
raise _SecretNotFound(path)
|
|
return _secret_response(path, document[path])
|
|
|
|
|
|
class OwuiApiCredsSource:
|
|
"""Secrets from the `creds-coreci-coo` KB via the OWUI HTTP API; a KB file's
|
|
name is its secret path (e.g. `coreci/proxmox`), its content the secret JSON."""
|
|
KB_NAME = "creds-coreci-coo"
|
|
|
|
def __init__(self, base_url, api_key):
|
|
self.base_url = str(base_url or "").rstrip("/")
|
|
self.api_key = str(api_key or "")
|
|
self._file_map = None
|
|
|
|
def _fetch_json(self, url_path):
|
|
try:
|
|
url = self.base_url + url_path
|
|
request = urllib.request.Request(url, headers={"Authorization": "Bearer %s" % self.api_key})
|
|
with urllib.request.urlopen(request, timeout=30) as response:
|
|
raw = response.read()
|
|
except urllib.error.HTTPError as error:
|
|
if error.code == 404:
|
|
raise _SecretNotFound(url_path)
|
|
raise _CredsSourceError(f"creds source returned HTTP {error.code} for {url_path}")
|
|
except (OSError, ValueError) as error:
|
|
raise _CredsSourceError(f"creds source unavailable: {error}")
|
|
try:
|
|
body = json.loads(raw.decode("utf-8"))
|
|
except ValueError:
|
|
raise _CredsSourceError(f"creds source returned invalid JSON for {url_path}")
|
|
if not isinstance(body, dict):
|
|
raise _CredsSourceError(f"creds source returned a non-object for {url_path}")
|
|
return body
|
|
|
|
def _kb_id(self):
|
|
for page in range(1, 1001):
|
|
body = self._fetch_json("/api/v1/knowledge/?page=%d" % page)
|
|
items = body.get("items") or []
|
|
for item in items:
|
|
if item.get("name") == self.KB_NAME:
|
|
return item.get("id")
|
|
total = body.get("total")
|
|
if not items or (isinstance(total, int) and page * 30 >= total):
|
|
raise _SecretNotFound(self.KB_NAME)
|
|
raise _CredsSourceError("creds source pagination did not terminate")
|
|
|
|
def _load_file_map(self):
|
|
kb_id = self._kb_id()
|
|
body = self._fetch_json("/api/v1/knowledge/%s" % urllib.parse.quote(str(kb_id), safe=""))
|
|
file_map = {}
|
|
for meta in body.get("files") or []:
|
|
name, file_id = meta.get("name"), meta.get("id")
|
|
if isinstance(name, str) and name and file_id is not None:
|
|
file_map[name] = (str(file_id), meta.get("updated_at"))
|
|
return file_map
|
|
|
|
def get_secret(self, path):
|
|
if self._file_map is None or path not in self._file_map:
|
|
self._file_map = self._load_file_map()
|
|
if path not in self._file_map:
|
|
raise _SecretNotFound(path)
|
|
file_id, updated_at = self._file_map[path]
|
|
body = self._fetch_json("/api/v1/files/%s/data/content" % urllib.parse.quote(file_id, safe=""))
|
|
content = body.get("content")
|
|
if not isinstance(content, str):
|
|
raise _CredsSourceError(f"creds file for {path} returned no content")
|
|
try:
|
|
data = json.loads(content)
|
|
except ValueError:
|
|
raise _CredsSourceError(f"creds file for {path} is invalid JSON")
|
|
return _secret_response(path, data, updated_at)
|
|
|
|
|
|
def _build_creds_source(config):
|
|
source = str(config.get("BROKER_CREDS_SOURCE") or "file")
|
|
if source == "file":
|
|
return FileCredsSource(config.get("BROKER_CREDS_FILE"))
|
|
if source == "owui":
|
|
return OwuiApiCredsSource(config.get("BROKER_OWUI_BASE_URL"), config.get("BROKER_OWUI_API_KEY"))
|
|
raise ValueError(f"BROKER_CREDS_SOURCE must be file or owui, got {source!r}")
|
|
|
|
|
|
class Audit:
|
|
def __init__(self, path):
|
|
self.path = path
|
|
self._lock, self._handle = threading.Lock(), None
|
|
fd = os.open(path, os.O_CREAT | os.O_WRONLY, 0o600)
|
|
try:
|
|
os.fchmod(fd, 0o600)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
def write(self, action, path="", result="ok", user_id=None,
|
|
app_registration_uuid=None, agent_id=None):
|
|
if action not in AUDIT_ACTIONS:
|
|
raise ValueError(f"unknown audit action: {action!r}")
|
|
if result not in AUDIT_RESULTS:
|
|
raise ValueError(f"unknown audit result: {result!r}")
|
|
entry = {
|
|
"ts": _utc_now_iso(), "user_id": user_id, "app_registration_uuid": app_registration_uuid,
|
|
"agent_id": agent_id, "action": action, "path": path, "result": result,
|
|
}
|
|
line = json.dumps(entry, separators=(",", ":")) + "\n"
|
|
with self._lock:
|
|
if self._handle is None:
|
|
self._handle = open(self.path, "a", encoding="utf-8")
|
|
self._handle.write(line)
|
|
self._handle.flush()
|
|
os.fsync(self._handle.fileno())
|
|
|
|
def close(self):
|
|
with self._lock:
|
|
if self._handle is not None:
|
|
self._handle.close()
|
|
self._handle = None
|
|
|
|
|
|
class Broker:
|
|
def __init__(self, config, store, audit):
|
|
self.config, self.store, self.audit = config, store, audit
|
|
self.ui_root = config.get("BROKER_UI_ROOT") or "coreci_broker_ui"
|
|
self.static_root = config.get("BROKER_STATIC_ROOT") or "."
|
|
self.token_cache = {}
|
|
self.token_cache_lock = threading.Lock()
|
|
self.agent_state_backend = _build_agent_state_backend(config, store)
|
|
self.creds_source = _build_creds_source(config)
|
|
self.router = Router()
|
|
self.router.add("GET", "/healthz", _healthz)
|
|
self.router.add("POST", "/api/broker/auth/login", _auth_login)
|
|
self.router.add("POST", "/api/broker/auth/logout", _auth_logout)
|
|
self.router.add("GET", "/api/broker/auth/whoami", _auth_whoami)
|
|
self.router.add("GET", "/api/broker/app_registrations", _apps_list)
|
|
self.router.add("POST", "/api/broker/app_registrations", _apps_add)
|
|
self.router.add("DELETE", "/api/broker/app_registrations/<uuid>", _apps_revoke)
|
|
self.router.add("POST", "/api/broker/app_registrations/<uuid>/reinstate", _apps_reinstate)
|
|
self.router.add("GET", "/api/broker/user_allowlist", _users_list)
|
|
self.router.add("POST", "/api/broker/user_allowlist", _users_add)
|
|
self.router.add("DELETE", "/api/broker/user_allowlist/<user_id>", _users_revoke)
|
|
self.router.add("POST", "/api/broker/user_allowlist/<user_id>/reinstate", _users_reinstate)
|
|
self.router.add("GET", "/api/broker/audit", _audit_query)
|
|
self.router.add("POST", "/api/broker/agents/register", _agents_register)
|
|
self.router.add("GET", "/api/broker/agents/<agent_id>", _require_app(_agent_meta))
|
|
self.router.add("GET", "/api/broker/agents/<agent_id>/state", _require_app(_agent_state_get))
|
|
self.router.add("PUT", "/api/broker/agents/<agent_id>/state", _require_app(_agent_state_put))
|
|
self.router.add("GET", "/api/broker/secrets/<path...>", _require_app(_secrets_get))
|
|
|
|
def clear_token_cache(self):
|
|
with self.token_cache_lock:
|
|
self.token_cache.clear()
|
|
|
|
|
|
def boot(env=None):
|
|
config = load_config(env)
|
|
store = Store(config["BROKER_DB_PATH"])
|
|
store.set_config("BROKER_INSTANCE_PEPPER", config["BROKER_INSTANCE_PEPPER"])
|
|
audit = Audit(config["BROKER_AUDIT_LOG_PATH"])
|
|
return Broker(config, store, audit)
|
|
|
|
|
|
class _BadRequest(Exception):
|
|
pass
|
|
|
|
|
|
def _healthz(request):
|
|
request.send_json(200, {"ok": True})
|
|
|
|
|
|
def _check_admin_credentials(broker, username, password):
|
|
if not isinstance(username, str) or not isinstance(password, str):
|
|
return False
|
|
config = broker.config
|
|
user = str(config.get("BROKER_ADMIN_USER") or "").encode("utf-8")
|
|
expected = str(config.get("BROKER_ADMIN_PASS") or "").encode("utf-8")
|
|
return (hmac.compare_digest(username.encode("utf-8"), user)
|
|
and hmac.compare_digest(password.encode("utf-8"), expected))
|
|
|
|
|
|
def _basic_credentials(request):
|
|
header = request.headers.get("Authorization") or ""
|
|
if header[:6].lower() != "basic ":
|
|
return "", ""
|
|
try:
|
|
decoded = base64.b64decode(header[6:].strip(), validate=True).decode("utf-8")
|
|
except ValueError:
|
|
return "", ""
|
|
username, sep, password = decoded.partition(":")
|
|
return (username, password) if sep else ("", "")
|
|
|
|
|
|
def _request_session(request):
|
|
jar = http.cookies.SimpleCookie()
|
|
try:
|
|
jar.load(request.headers.get("Cookie") or "")
|
|
except http.cookies.CookieError:
|
|
return None
|
|
morsel = jar.get("broker_session")
|
|
if morsel is None or not morsel.value:
|
|
return None
|
|
row = request.broker.store.get_admin_session(morsel.value)
|
|
if row is not None and row[2] > _utc_now_iso():
|
|
return row
|
|
return None
|
|
|
|
|
|
def _require_admin(request):
|
|
if _request_session(request) is not None:
|
|
return True
|
|
if not request.headers.get("Authorization"):
|
|
request.send_error_json(401, "unauthorized")
|
|
return False
|
|
username, password = _basic_credentials(request)
|
|
if _check_admin_credentials(request.broker, username, password):
|
|
return True
|
|
request.send_error_json(401, "invalid credentials")
|
|
return False
|
|
|
|
|
|
def _auth_login(request):
|
|
broker = request.broker
|
|
body = request.body if isinstance(request.body, dict) else {}
|
|
username = body.get("username")
|
|
if not _check_admin_credentials(broker, username, body.get("password")):
|
|
broker.audit.write("login", path="/api/broker/auth/login", result="denied")
|
|
request.send_error_json(401, "invalid credentials")
|
|
return
|
|
session_id = secrets.token_urlsafe(32)
|
|
raw_ttl = str(broker.config.get("BROKER_SESSION_TTL") or "")
|
|
seconds = int(raw_ttl) if raw_ttl.isdigit() else 300
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
expires = (now + datetime.timedelta(seconds=seconds)).isoformat()
|
|
broker.store.create_admin_session(session_id, now.isoformat(), expires)
|
|
broker.audit.write("login", path="/api/broker/auth/login", result="ok")
|
|
cookie = "broker_session=%s; Path=/; HttpOnly; SameSite=Lax" % session_id
|
|
request.send_json(200, {"ok": True, "username": username}, [("Set-Cookie", cookie)])
|
|
|
|
|
|
def _auth_logout(request):
|
|
broker = request.broker
|
|
if not _require_admin(request):
|
|
return
|
|
row = _request_session(request)
|
|
if row is not None:
|
|
broker.store.delete_admin_session(row[0])
|
|
broker.audit.write("logout", path="/api/broker/auth/logout", result="ok")
|
|
request.send_json(200, {"ok": True})
|
|
|
|
|
|
def _auth_whoami(request):
|
|
if not _require_admin(request):
|
|
return
|
|
row = _request_session(request)
|
|
payload = {"username": request.broker.config.get("BROKER_ADMIN_USER") or "",
|
|
"created_at": row[1] if row else None, "expires_at": row[2] if row else None}
|
|
request.send_json(200, payload)
|
|
|
|
|
|
def _list_rows(request, table, keys, field):
|
|
if not _require_admin(request):
|
|
return
|
|
rows = request.broker.store.list_rows(table)
|
|
request.send_json(200, {field: [dict(zip(keys, row)) for row in rows]})
|
|
|
|
|
|
def _apps_list(request):
|
|
_list_rows(request, "app_registrations",
|
|
("uuid", "name", "status", "created_at", "revoked_at"), "registrations")
|
|
|
|
|
|
def _users_list(request):
|
|
_list_rows(request, "user_allowlist",
|
|
("user_id", "email", "status", "created_at", "revoked_at"), "allowlist")
|
|
|
|
|
|
def _canonical_uuid(value):
|
|
try:
|
|
canonical = str(uuid.UUID(value))
|
|
except (ValueError, TypeError, AttributeError):
|
|
return None
|
|
return canonical if canonical == value else None
|
|
|
|
|
|
def _required_string(value):
|
|
return value if isinstance(value, str) and value else None
|
|
|
|
|
|
def _has_control_chars(value):
|
|
return any(ord(ch) < 32 or ord(ch) == 127 for ch in value)
|
|
|
|
|
|
def _valid_id(value):
|
|
if not _required_string(value):
|
|
return None
|
|
if value in (".", ".."):
|
|
return None
|
|
if ":" in value or "/" in value or "\\" in value or _has_control_chars(value):
|
|
return None
|
|
return value
|
|
|
|
|
|
def _add_row(request, table, key, field, action, validate):
|
|
if not _require_admin(request):
|
|
return
|
|
broker = request.broker
|
|
body = request.body if isinstance(request.body, dict) else {}
|
|
value = validate(body.get(key))
|
|
if value is None:
|
|
request.send_error_json(400, "invalid " + key)
|
|
return
|
|
if broker.store.get_row(table, key, value):
|
|
request.send_error_json(409, "duplicate " + key)
|
|
return
|
|
optional = body.get(field)
|
|
broker.store.add_row(table, value, optional if isinstance(optional, str) else None)
|
|
broker.audit.write(action, path="/api/broker/%s/%s" % (table, value))
|
|
request.send_json(201, {key: value, "status": "active"})
|
|
|
|
|
|
def _apps_add(request):
|
|
_add_row(request, "app_registrations", "uuid", "name", "add_app", _canonical_uuid)
|
|
|
|
|
|
def _users_add(request):
|
|
_add_row(request, "user_allowlist", "user_id", "email", "add_user", _valid_id)
|
|
|
|
|
|
def _set_row_status(request, table, key, status, action):
|
|
if not _require_admin(request):
|
|
return
|
|
broker = request.broker
|
|
value = request.path_params[key]
|
|
row = broker.store.get_row(table, key, value)
|
|
if row is None:
|
|
request.send_error_json(404, "not found")
|
|
return
|
|
if row[2] != status:
|
|
broker.store.set_row_status(table, key, value, status)
|
|
broker.audit.write(action, path="/api/broker/%s/%s" % (table, value))
|
|
request.send_json(200, {key: value, "status": status})
|
|
|
|
|
|
def _apps_revoke(request):
|
|
_set_row_status(request, "app_registrations", "uuid", "revoked", "remove_app")
|
|
|
|
|
|
def _apps_reinstate(request):
|
|
_set_row_status(request, "app_registrations", "uuid", "active", "add_app")
|
|
|
|
|
|
def _users_revoke(request):
|
|
_set_row_status(request, "user_allowlist", "user_id", "revoked", "remove_user")
|
|
|
|
|
|
def _users_reinstate(request):
|
|
_set_row_status(request, "user_allowlist", "user_id", "active", "add_user")
|
|
|
|
|
|
def _parse_app_header(header):
|
|
parts = (header or "").split()
|
|
if len(parts) != 3 or parts[0] != "App":
|
|
return None, None
|
|
app_uuid = _canonical_uuid(parts[1])
|
|
return (app_uuid, parts[2]) if app_uuid is not None else (None, None)
|
|
|
|
|
|
def _app_auth_context(row):
|
|
return {
|
|
"agent_id": row[0],
|
|
"user_id": row[2],
|
|
"app_registration_uuid": row[1],
|
|
"token_expires_at": row[5],
|
|
}
|
|
|
|
|
|
def _verify_app_token(broker, header):
|
|
app_uuid, token = _parse_app_header(header)
|
|
if app_uuid is None:
|
|
return None
|
|
now = _utc_now_iso()
|
|
key = (app_uuid, token)
|
|
with broker.token_cache_lock:
|
|
cached = broker.token_cache.get(key)
|
|
if cached is not None:
|
|
row = broker.store.get_agent(cached["agent_id"], app_uuid)
|
|
if row is not None and row[5] and row[5] > now:
|
|
return _app_auth_context(row)
|
|
with broker.token_cache_lock:
|
|
broker.token_cache.pop(key, None)
|
|
return None
|
|
for row in broker.store.agents_by_uuid(app_uuid):
|
|
if row[5] and row[5] > now and verify_token(token, row[4]):
|
|
context = _app_auth_context(row)
|
|
with broker.token_cache_lock:
|
|
broker.token_cache[key] = context
|
|
return context
|
|
return None
|
|
|
|
|
|
def _app_token_valid(broker, header):
|
|
return _verify_app_token(broker, header) is not None
|
|
|
|
|
|
def _audit_app_denied(request):
|
|
parts = (request.headers.get("Authorization") or "").split()
|
|
if len(parts) < 2 or parts[0] != "App":
|
|
return
|
|
request.broker.audit.write(
|
|
"denied", path=request.path, result="denied",
|
|
app_registration_uuid=_canonical_uuid(parts[1]),
|
|
)
|
|
|
|
|
|
def _require_app(handler):
|
|
def wrapped(request):
|
|
context = _verify_app_token(request.broker, request.headers.get("Authorization"))
|
|
if context is None:
|
|
_audit_app_denied(request)
|
|
request.send_error_json(401, "invalid app credentials")
|
|
return
|
|
request.app_auth = context
|
|
request.broker.store.touch_agent(
|
|
context["agent_id"], context["app_registration_uuid"], _utc_now_iso()
|
|
)
|
|
handler(request)
|
|
return wrapped
|
|
|
|
|
|
def _agents_register(request):
|
|
broker = request.broker
|
|
body = request.body if isinstance(request.body, dict) else {}
|
|
agent_id = body.get("agent_id")
|
|
app_uuid = body.get("app_registration_uuid")
|
|
user_id = body.get("user_id")
|
|
tags = body.get("tags")
|
|
strings = [v for v in (agent_id, app_uuid, user_id) if isinstance(v, str) and v]
|
|
if len(strings) != 3 or not isinstance(tags, list) or not all(isinstance(v, str) for v in tags):
|
|
request.send_error_json(400, "invalid registration")
|
|
return
|
|
if _valid_id(agent_id) is None:
|
|
request.send_error_json(400, "invalid agent_id")
|
|
return
|
|
if any(":" in tag or _has_control_chars(tag) for tag in tags):
|
|
request.send_error_json(400, "invalid tags")
|
|
return
|
|
path = "/api/broker/agents/register"
|
|
identity = {"user_id": user_id, "app_registration_uuid": app_uuid, "agent_id": agent_id}
|
|
app_row = broker.store.get_row("app_registrations", "uuid", app_uuid)
|
|
user_row = broker.store.get_row("user_allowlist", "user_id", user_id)
|
|
if app_row is None or app_row[2] != "active" or user_row is None or user_row[2] != "active":
|
|
broker.audit.write("register", path=path, result="denied", **identity)
|
|
request.send_error_json(403, "registration not allowed")
|
|
return
|
|
max_raw = str(broker.config.get("BROKER_MAX_AGENTS_PER_APP") or "")
|
|
max_agents = int(max_raw) if max_raw.isdigit() else 50
|
|
if len(broker.store.agents_by_uuid(app_uuid)) >= max_agents:
|
|
request.send_error_json(400, "too many agents for this app")
|
|
return
|
|
pepper = broker.store.get_config("BROKER_INSTANCE_PEPPER") or broker.config["BROKER_INSTANCE_PEPPER"]
|
|
token = derive_token(pepper, app_uuid, user_id, agent_id, tags)
|
|
raw_ttl = str(broker.config.get("BROKER_TOKEN_TTL") or "")
|
|
ttl = int(raw_ttl) if raw_ttl.isdigit() else 86400
|
|
now = datetime.datetime.now(datetime.timezone.utc)
|
|
expires = (now + datetime.timedelta(seconds=ttl)).isoformat()
|
|
tags_json = json.dumps(sorted(tags))
|
|
row = broker.store.get_agent(agent_id, app_uuid)
|
|
created = row is None
|
|
if created:
|
|
try:
|
|
broker.store.save_agent(agent_id, app_uuid, user_id, tags_json, hash_token(token), expires, now.isoformat())
|
|
except sqlite3.IntegrityError:
|
|
row = broker.store.get_agent(agent_id, app_uuid)
|
|
if row is None:
|
|
raise
|
|
created = False
|
|
if not created:
|
|
if row[2] == user_id and row[3] == tags_json and row[5] and row[5] > now.isoformat():
|
|
expires = row[5]
|
|
broker.store.touch_agent(agent_id, app_uuid, now.isoformat())
|
|
else:
|
|
broker.store.update_agent(agent_id, app_uuid, user_id, tags_json, hash_token(token), expires, now.isoformat())
|
|
with broker.token_cache_lock:
|
|
for cached_key in [k for k in broker.token_cache if k[0] == app_uuid]:
|
|
broker.token_cache.pop(cached_key, None)
|
|
payload = {"token": token, "token_expires_at": expires, "created": created}
|
|
if created or not _app_token_valid(broker, request.headers.get("Authorization")):
|
|
payload["pepper"] = pepper
|
|
broker.audit.write("register", path=path, **identity)
|
|
request.send_json(201 if created else 200, payload)
|
|
|
|
|
|
def _agent_identity_row(request):
|
|
auth = request.app_auth
|
|
row = request.broker.store.get_agent(
|
|
request.path_params["agent_id"], auth["app_registration_uuid"]
|
|
)
|
|
if row is None:
|
|
request.send_error_json(404, "not found")
|
|
return None
|
|
if row[0] != auth["agent_id"] or row[2] != auth["user_id"]:
|
|
request.send_error_json(403, "agent identity mismatch")
|
|
return None
|
|
return row
|
|
|
|
|
|
def _agent_meta(request):
|
|
row = _agent_identity_row(request)
|
|
if row is None:
|
|
return
|
|
request.send_json(200, {
|
|
"agent_id": row[0],
|
|
"app_registration_uuid": row[1],
|
|
"user_id": row[2],
|
|
"tags": json.loads(row[3]),
|
|
"token_expires_at": row[5],
|
|
"first_registered_at": row[6],
|
|
"last_seen_at": row[7],
|
|
})
|
|
|
|
|
|
def _agent_state_get(request):
|
|
row = _agent_identity_row(request)
|
|
if row is None:
|
|
return
|
|
state = request.broker.agent_state_backend.get_state(row[0], row[1])
|
|
if state is None:
|
|
request.send_error_json(404, "not found")
|
|
return
|
|
request.send_json(200, state)
|
|
|
|
|
|
def _agent_state_put(request):
|
|
row = _agent_identity_row(request)
|
|
if row is None:
|
|
return
|
|
body = request.body
|
|
if not isinstance(body, dict):
|
|
request.send_error_json(400, "state must be a JSON object")
|
|
return
|
|
if len(json.dumps(body).encode("utf-8")) > MAX_STATE_BYTES:
|
|
request.send_error_json(400, "state too large")
|
|
return
|
|
request.broker.agent_state_backend.put_state(row[0], row[1], body)
|
|
request.send_json(200, {"ok": True})
|
|
|
|
|
|
def _secret_path_unsafe(path):
|
|
return (path.startswith("/") or "\\" in path or "\x00" in path
|
|
or ".." in path.split("/"))
|
|
|
|
|
|
def _secrets_denied(request, path, identity):
|
|
request.broker.audit.write("read", path=path, result="denied", **identity)
|
|
request.send_error_json(403, "forbidden")
|
|
|
|
|
|
def _secrets_get(request):
|
|
broker = request.broker
|
|
auth = request.app_auth
|
|
path = urllib.parse.unquote(request.path_params["path"])
|
|
identity = {"user_id": auth["user_id"],
|
|
"app_registration_uuid": auth["app_registration_uuid"],
|
|
"agent_id": auth["agent_id"]}
|
|
if _secret_path_unsafe(path):
|
|
_secrets_denied(request, path, identity)
|
|
return
|
|
if path != "coreci/" + auth["agent_id"]:
|
|
_secrets_denied(request, path, identity)
|
|
return
|
|
try:
|
|
secret = broker.creds_source.get_secret(path)
|
|
except _SecretNotFound:
|
|
broker.audit.write("read", path=path, result="error", **identity)
|
|
request.send_error_json(404, "not found")
|
|
except _CredsSourceError:
|
|
broker.audit.write("read", path=path, result="error", **identity)
|
|
request.send_error_json(500, "creds source unavailable")
|
|
else:
|
|
broker.audit.write("read", path=path, **identity)
|
|
request.send_json(200, secret)
|
|
|
|
|
|
def _parse_iso_ts(value):
|
|
try:
|
|
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
except (ValueError, AttributeError):
|
|
return None
|
|
if parsed.tzinfo is None:
|
|
parsed = parsed.replace(tzinfo=datetime.timezone.utc)
|
|
return parsed
|
|
|
|
|
|
def _audit_entry_matches(entry, query, since, until):
|
|
for field in ("user_id", "app_registration_uuid", "action"):
|
|
if query.get(field) and entry.get(field) != query[field]:
|
|
return False
|
|
if since is None and until is None:
|
|
return True
|
|
parsed = _parse_iso_ts(entry.get("ts") or "")
|
|
if parsed is None:
|
|
return False
|
|
if since is not None and parsed <= since:
|
|
return False
|
|
if until is not None and parsed > until:
|
|
return False
|
|
return True
|
|
|
|
|
|
def _audit_query(request):
|
|
if not _require_admin(request):
|
|
return
|
|
query = request.query
|
|
since = until = None
|
|
if query.get("since"):
|
|
since = _parse_iso_ts(query["since"])
|
|
if since is None:
|
|
request.send_error_json(400, "invalid since")
|
|
return
|
|
if query.get("until"):
|
|
until = _parse_iso_ts(query["until"])
|
|
if until is None:
|
|
request.send_error_json(400, "invalid until")
|
|
return
|
|
try:
|
|
limit = int(query.get("limit") or "")
|
|
except ValueError:
|
|
limit = 100
|
|
limit = min(max(limit, 1), 1000)
|
|
entries = []
|
|
with open(request.broker.audit.path, "r", encoding="utf-8") as handle:
|
|
for raw in handle:
|
|
if len(entries) >= limit:
|
|
break
|
|
line = raw.strip()
|
|
if not line:
|
|
continue
|
|
try:
|
|
entry = json.loads(line)
|
|
except ValueError:
|
|
continue
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
if not _audit_entry_matches(entry, query, since, until):
|
|
continue
|
|
entries.append(entry)
|
|
next_since = entries[-1].get("ts") if len(entries) >= limit else None
|
|
request.send_json(200, {"entries": entries, "next_since": next_since})
|
|
|
|
|
|
def _parse_query(query):
|
|
values = urllib.parse.parse_qs(query, keep_blank_values=True)
|
|
return {key: value[0] for key, value in values.items()}
|
|
|
|
|
|
def _match_pattern(pattern, path):
|
|
pattern_parts = pattern.split("/")
|
|
path_parts = path.split("/")
|
|
if len(pattern_parts) > len(path_parts):
|
|
return None
|
|
params = {}
|
|
for index, expected in enumerate(pattern_parts):
|
|
if not (len(expected) > 2 and expected.startswith("<") and expected.endswith(">")):
|
|
if expected != path_parts[index]:
|
|
return None
|
|
continue
|
|
name = expected[1:-1]
|
|
if name.endswith("..."):
|
|
if index + 1 != len(pattern_parts) or not path_parts[index:]:
|
|
return None
|
|
params[name[:-3]] = "/".join(path_parts[index:])
|
|
return params
|
|
if not path_parts[index]:
|
|
return None
|
|
params[name] = path_parts[index]
|
|
if len(pattern_parts) != len(path_parts):
|
|
return None
|
|
return params
|
|
|
|
|
|
def _static_content_type(name):
|
|
extension = os.path.splitext(name)[1].lower()
|
|
return STATIC_CONTENT_TYPES.get(extension, "application/octet-stream")
|
|
|
|
|
|
def _broker_protected_paths(broker):
|
|
protected = set()
|
|
for key in ("BROKER_ENV_PATH", "BROKER_DB_PATH", "BROKER_AUDIT_LOG_PATH", "BROKER_CREDS_FILE"):
|
|
value = broker.config.get(key) or ""
|
|
if not value:
|
|
continue
|
|
base = os.path.realpath(value)
|
|
protected.add(base)
|
|
for suffix in ("-journal", "-wal", "-shm"):
|
|
protected.add(base + suffix)
|
|
return protected
|
|
|
|
|
|
def _resolve_static_file(root, rest, protected=()):
|
|
if "\x00" in rest or "\\" in rest or rest.startswith("/"):
|
|
return None
|
|
parts = rest.split("/")
|
|
if ".." in parts or any(part.startswith(".") for part in parts):
|
|
return None
|
|
stripped = rest.strip("/")
|
|
names = []
|
|
if stripped:
|
|
names.append(stripped)
|
|
if "." not in os.path.basename(stripped):
|
|
names.append(stripped + ".html")
|
|
else:
|
|
names.append("index.html")
|
|
root_real = os.path.realpath(root)
|
|
for name in names:
|
|
target = os.path.realpath(os.path.join(root_real, name))
|
|
if not target.startswith(root_real + os.sep):
|
|
continue
|
|
if target in protected:
|
|
continue
|
|
if os.path.isfile(target):
|
|
return target
|
|
return None
|
|
|
|
|
|
class Router:
|
|
def __init__(self):
|
|
self._routes = []
|
|
|
|
def add(self, method, pattern, handler):
|
|
self._routes.append((method.upper(), pattern, handler))
|
|
|
|
def resolve(self, method, path):
|
|
for route_method, pattern, handler in self._routes:
|
|
if route_method != method:
|
|
continue
|
|
params = _match_pattern(pattern, path)
|
|
if params is not None:
|
|
return handler, params
|
|
return None, None
|
|
|
|
|
|
class BrokerRequestHandler(BaseHTTPRequestHandler):
|
|
broker = None
|
|
timeout = 30
|
|
responded = False
|
|
path_params = None
|
|
query = None
|
|
body = None
|
|
|
|
def log_message(self, format, *args):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
self._dispatch("GET")
|
|
|
|
def do_POST(self):
|
|
self._dispatch("POST")
|
|
|
|
def do_PUT(self):
|
|
self._dispatch("PUT")
|
|
|
|
def do_DELETE(self):
|
|
self._dispatch("DELETE")
|
|
|
|
def send_json(self, status, payload, headers=()):
|
|
if self.responded:
|
|
return
|
|
self.responded = True
|
|
data = json.dumps(payload).encode("utf-8")
|
|
self.send_response(status)
|
|
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
for key, value in headers:
|
|
self.send_header(key, value)
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def send_error_json(self, status, message):
|
|
self.send_json(status, {"error": status, "message": message})
|
|
|
|
def _reject_oversized_body(self):
|
|
header = self.headers.get("Content-Length")
|
|
if not header:
|
|
return
|
|
try:
|
|
length = int(header)
|
|
except ValueError:
|
|
return
|
|
if length > MAX_BODY_BYTES:
|
|
raise _BadRequest("body too large")
|
|
|
|
def _read_body(self):
|
|
header = self.headers.get("Content-Length") or "0"
|
|
try:
|
|
length = int(header)
|
|
except ValueError:
|
|
raise _BadRequest("invalid Content-Length")
|
|
if length > MAX_BODY_BYTES:
|
|
raise _BadRequest("body too large")
|
|
if length <= 0:
|
|
return b""
|
|
return self.rfile.read(length)
|
|
|
|
def _dispatch(self, method):
|
|
self.path_params, self.query, self.body = {}, {}, None
|
|
try:
|
|
self._reject_oversized_body()
|
|
parsed = urllib.parse.urlsplit(self.path)
|
|
path = urllib.parse.unquote(parsed.path)
|
|
self.query = _parse_query(parsed.query)
|
|
raw = self._read_body() if method in ("POST", "PUT") else b""
|
|
if self._serve_static(method, path):
|
|
return
|
|
handler, params = self.broker.router.resolve(method, path)
|
|
if handler is None:
|
|
self.send_error_json(404, "not found")
|
|
return
|
|
if raw:
|
|
try:
|
|
self.body = json.loads(raw)
|
|
except (ValueError, RecursionError):
|
|
self.send_error_json(400, "invalid JSON body")
|
|
return
|
|
self.path_params = params
|
|
handler(self)
|
|
except _BadRequest as error:
|
|
self.send_error_json(400, str(error))
|
|
except Exception:
|
|
if not self.responded:
|
|
self.send_error_json(500, "internal error")
|
|
|
|
def _serve_static(self, method, path):
|
|
if method != "GET":
|
|
return False
|
|
for prefix, attr in STATIC_PREFIXES:
|
|
if path.startswith("/" + prefix + "/"):
|
|
self._send_static_file(getattr(self.broker, attr), path[len(prefix) + 2:])
|
|
return True
|
|
return False
|
|
|
|
def _send_static_file(self, root, rest):
|
|
target = _resolve_static_file(
|
|
root, rest, _broker_protected_paths(self.broker)
|
|
)
|
|
if target is None:
|
|
self.send_error_json(404, "not found")
|
|
return
|
|
with open(target, "rb") as handle:
|
|
data = handle.read()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", _static_content_type(target))
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
|
|
def serve(host, port, broker):
|
|
handler = type("BoundHandler", (BrokerRequestHandler,), {"broker": broker})
|
|
server = ThreadingHTTPServer((host, port), handler)
|
|
server.broker = broker
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
return server
|
|
|
|
|
|
_server_lock = threading.Lock()
|
|
_started_server = None
|
|
|
|
|
|
def _parse_bind(bind):
|
|
host, _, port = str(bind).rpartition(":")
|
|
if not port.isdigit():
|
|
raise ValueError(f"BROKER_BIND must be host:port, got {bind!r}")
|
|
return host or "127.0.0.1", int(port)
|
|
|
|
|
|
def _start_guarded_server(env=None):
|
|
global _started_server
|
|
with _server_lock:
|
|
if _started_server is not None:
|
|
return _started_server
|
|
broker = boot(env)
|
|
host, port = _parse_bind(broker.config["BROKER_BIND"])
|
|
_started_server = serve(host, port, broker)
|
|
return _started_server
|
|
|
|
|
|
def _reset_server_guard():
|
|
global _started_server
|
|
with _server_lock:
|
|
server = _started_server
|
|
_started_server = None
|
|
if server is None:
|
|
return None
|
|
server.shutdown()
|
|
server.server_close()
|
|
server.broker.clear_token_cache()
|
|
server.broker.audit.close()
|
|
server.broker.store.close()
|
|
return server
|
|
|
|
|
|
class Tools:
|
|
class Valves:
|
|
pass
|
|
|
|
def __init__(self):
|
|
self.valves = self.Valves()
|
|
try:
|
|
self._server = _start_guarded_server()
|
|
except OSError as error:
|
|
if error.errno != errno.EADDRINUSE:
|
|
raise
|
|
raise RuntimeError(
|
|
f"broker bind failed ({error}): a broker from previously loaded "
|
|
"module code is still serving the configured bind; restart "
|
|
"Open WebUI to load the updated broker"
|
|
) from error
|
|
|
|
def status(self) -> str:
|
|
"""Report the broker server state as a one-line string."""
|
|
host, port = self._server.server_address[:2]
|
|
return f"coreci-broker serving on http://{host}:{port}"
|
|
|
|
|
|
def run_standalone(env=None):
|
|
broker = boot(env)
|
|
host, port = _parse_bind(broker.config["BROKER_BIND"])
|
|
server = serve(host, port, broker)
|
|
bound_host, bound_port = server.server_address[:2]
|
|
print(f"coreci-broker listening on http://{bound_host}:{bound_port}", flush=True)
|
|
try:
|
|
while True:
|
|
time.sleep(1)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.shutdown()
|
|
server.server_close()
|
|
broker.clear_token_cache()
|
|
broker.audit.close()
|
|
broker.store.close()
|
|
print("coreci-broker shut down", flush=True)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
run_standalone()
|