diff --git a/.env.example b/.env.example index 23fba90..921f133 100644 --- a/.env.example +++ b/.env.example @@ -20,3 +20,21 @@ VOLC_WS=wss://openspeech.bytedance.com/api/v3/sauc/bigmodel ASTERISK_IMAGE= # ARI_PASSWORD and SIP_PRIMARY_PASSWORD/SIP_BACKUP_PASSWORD go to the renderer # through the process environment/secret manager, not this committed example. +# Optional independent SIP management profile: use distinct credentials and +# inject opaque token maps through the secret manager before starting it. +SIP_MANAGEMENT_PORT=18090 +SIP_MANAGEMENT_MODE=mock +SIP_ADMIN_TOKENS= +SIP_READ_TOKENS= +# Real mode only: host directory containing the CA and client certificate/key. +SIP_CELL_TLS_DIR=./secrets/sip-management +SIP_CELL_TLS_CA_FILE=/run/sip-tls/ca.pem +SIP_CELL_TLS_CERT_FILE=/run/sip-tls/client.pem +SIP_CELL_TLS_KEY_FILE=/run/sip-tls/client-key.pem +SIP_CELL_PUBLISH_TIMEOUT_SECONDS=15 + +# Optional SaaS scheduler read-only SIP directory integration. +AGENT_CALL_SIP_READONLY_URL= +AGENT_CALL_SIP_READONLY_TOKEN= +AGENT_CALL_SIP_READONLY_CA_FILE= +AGENT_CALL_SIP_READONLY_TIMEOUT_SECONDS=5 diff --git a/.gitignore b/.gitignore index d29e893..e93e11c 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ __pycache__/ deploy/state/ data/*.sqlite3* deploy/asterisk/generated/ +secrets/ services/asr-web/asr-web *.test coverage.out diff --git a/AGENTS.md b/AGENTS.md index a272390..cf39a03 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -53,6 +53,7 @@ - 供应商/线路层预先维护独立 trunk:服务端、协议、鉴权、注册、允许的主叫、被叫改写规则和并发额度。不同供应商不能共用并逐呼覆盖同一份线路配置。 - 呼叫层只从服务端已配置且获授权的线路中选择,按该线路规则设置本次主叫与被叫;**不为每个号码重写 pjsip.conf、重载 Asterisk 或重建注册**。 - MQ 指令不能让调用方任意注入 SIP 地址、认证凭据或越权主叫;线路选择对应的具体消息字段仍由用户制定,不擅自定接口。 +- SIP Trunk、codec、主叫/被叫规则、出口池及多机器 Cell 发布由独立 Asterisk/SIP 管理后台作为唯一写入面统一管理;SaaS 仅可读取受控 Trunk 目录/状态,不直接访问 Asterisk/ARI 管理面或写入 SIP 配置。 - 切换线路时从原始被叫重新应用目标线路规则,不能沿用上一家的前缀或主叫。重试/切换条件需按业务契约另行实现,不能默认重复拨打已接通的电话。 - 现有生成器仅支持固定 primary/backup,尚非完整多供应商路由;后续支持单线路启动、可扩展线路列表及逐呼选路,不为凑齐主备而虚构供应商。 diff --git a/agent_call/cell_agent.py b/agent_call/cell_agent.py new file mode 100644 index 0000000..94fcbad --- /dev/null +++ b/agent_call/cell_agent.py @@ -0,0 +1,667 @@ +from __future__ import annotations + +import hashlib +import json +import os +import sqlite3 +import ssl +import subprocess +import tempfile +import threading +import uuid +from collections.abc import Mapping +from contextlib import contextmanager, suppress +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import unquote, urlsplit + +from .sip_management import ( + SipManagementError, + _id, + _json, + _validate_trunk, + asterisk_codec_allow, +) + +CELL_BODY_BYTES = 256 * 1024 + + +class CellAgentError(Exception): + def __init__( + self, + status: int, + code: str, + detail: str, + extra: Mapping[str, Any] | None = None, + ) -> None: + super().__init__(detail) + self.status = status + self.code = code + self.detail = detail + self.extra = dict(extra or {}) + + def response(self) -> dict[str, Any]: + return {"error": {"code": self.code, "message": self.detail, **self.extra}} + + +class CellAgentStore: + def __init__(self, db_path: str | Path) -> None: + self.path = str(db_path) + if self.path != ":memory:": + Path(self.path).parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.RLock() + self._db = sqlite3.connect(self.path, timeout=5, check_same_thread=False) + self._db.row_factory = sqlite3.Row + self._db.execute( + "PRAGMA journal_mode = WAL" + ) if self.path != ":memory:" else None + self._db.execute("PRAGMA busy_timeout = 5000") + self._db.executescript( + """ + CREATE TABLE IF NOT EXISTS trunk_state ( + trunk_id TEXT PRIMARY KEY, + desired_revision INTEGER NOT NULL, + desired_hash TEXT NOT NULL, + desired_config_json TEXT NOT NULL, + applied_revision INTEGER NOT NULL, + applied_hash TEXT, + applied_config_json TEXT, + status TEXT NOT NULL, + last_error TEXT, + updated_at TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS audit_log ( + audit_id TEXT PRIMARY KEY, + trunk_id TEXT NOT NULL, + revision INTEGER NOT NULL, + action TEXT NOT NULL, + details_json TEXT NOT NULL, + created_at TEXT NOT NULL + ); + """ + ) + self._db.commit() + + @contextmanager + def write(self): + with self._lock: + self._db.execute("BEGIN IMMEDIATE") + try: + yield self._db + except Exception: + self._db.rollback() + raise + else: + self._db.commit() + + @contextmanager + def read(self): + with self._lock: + yield self._db + + def close(self) -> None: + with self._lock: + self._db.close() + + +def _now() -> str: + from datetime import datetime, timezone + + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + +def _decode(value: str, detail: str) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError as exc: + raise CellAgentError(500, "CORRUPT_STATE", detail) from exc + + +def _hash_config(config: Mapping[str, Any]) -> str: + return hashlib.sha256(_json(config).encode("utf-8")).hexdigest() + + +def _request_id(value: str | None) -> str: + if not value or len(value) > 128 or value != value.strip(): + raise CellAgentError(400, "MISSING_REQUEST_ID", "X-Request-ID is required") + if any(ord(char) < 32 for char in value): + raise CellAgentError(400, "INVALID_REQUEST_ID", "X-Request-ID is invalid") + return value + + +def _parse_config(body: Any, trunk_id: str) -> tuple[dict[str, Any], str]: + if not isinstance(body, dict): + raise CellAgentError( + 400, "INVALID_PUBLICATION", "publication must be an object" + ) + required = {"mode", "cell_id", "trunk_id", "revision", "config", "config_sha256"} + if set(body) != required: + raise CellAgentError( + 400, "INVALID_PUBLICATION", "publication fields are invalid" + ) + if body["mode"] != "real": + raise CellAgentError( + 400, "INVALID_MODE", "Cell Agent accepts real publications only" + ) + if body["trunk_id"] != trunk_id: + raise CellAgentError(400, "TRUNK_MISMATCH", "path and body trunk_id differ") + if ( + not isinstance(body["revision"], int) + or isinstance(body["revision"], bool) + or body["revision"] < 1 + ): + raise CellAgentError( + 400, "INVALID_REVISION", "revision must be a positive integer" + ) + digest = body["config_sha256"] + if ( + not isinstance(digest, str) + or len(digest) != 64 + or any(char not in "0123456789abcdef" for char in digest) + ): + raise CellAgentError( + 400, "INVALID_CONFIG_HASH", "config_sha256 must be lowercase SHA-256" + ) + try: + config = _validate_trunk(trunk_id, body["config"]) + except SipManagementError as exc: + raise CellAgentError(exc.status, exc.code, exc.detail, exc.extra) from exc + actual = _hash_config(config) + if actual != digest: + raise CellAgentError( + 400, "CONFIG_HASH_MISMATCH", "config_sha256 does not match config" + ) + return config, digest + + +def _asterisk_name(trunk_id: str) -> str: + return "agent_trunk_" + trunk_id.encode("utf-8").hex() + + +def render_pjsip_trunk(config: Mapping[str, Any], trunk_id: str | None = None) -> str: + if config["sip"]["auth_mode"] == "digest": + raise CellAgentError( + 422, + "DIGEST_SECRET_RESOLUTION_REQUIRED", + "Cell Agent requires a local secret resolver for digest trunks", + ) + name = _asterisk_name( + trunk_id or config.get("trunk_id") or config.get("display_name", "trunk") + ) + sip = config["sip"] + host = sip["host"] + contact_host = f"[{host}]" if ":" in host and not host.startswith("[") else host + scheme = "sips" if sip["transport"] == "tls" else "sip" + allow = ",".join(asterisk_codec_allow(config["codec_profile"])) + caller = config["caller_ids"][0] + aor = f"{name}-aor" + return ( + f"[{name}]\n" + "type=endpoint\n" + f"transport=transport-{sip['transport']}\n" + "context=deny-inbound\n" + "disallow=all\n" + f"allow={allow}\n" + "direct_media=no\n" + "rtp_symmetric=yes\n" + "force_rport=yes\n" + "rewrite_contact=yes\n" + f"from_user={caller}\n" + f"aors={aor}\n\n" + f"[{aor}]\n" + "type=aor\n" + f"contact={scheme}:{contact_host}:{sip['port']}\n" + "qualify_frequency=30\n" + ) + + +class AsteriskConfigApplier: + def __init__( + self, + config_dir: str | Path, + *, + asterisk_cli: str = "/usr/sbin/asterisk", + reload_timeout: float = 30.0, + ) -> None: + self.config_dir = Path(config_dir) + self.asterisk_cli = asterisk_cli + self.reload_timeout = reload_timeout + + def _target(self, trunk_id: str) -> Path: + return self.config_dir / f"{_asterisk_name(trunk_id)}.conf" + + def _reload(self) -> None: + try: + result = subprocess.run( + [self.asterisk_cli, "-rx", "pjsip reload"], + stdin=subprocess.DEVNULL, + capture_output=True, + check=False, + timeout=self.reload_timeout, + text=True, + ) + except (OSError, subprocess.SubprocessError) as exc: + raise CellAgentError( + 502, "ASTERISK_RELOAD_FAILED", "Asterisk reload command failed" + ) from exc + if result.returncode != 0: + raise CellAgentError( + 502, "ASTERISK_RELOAD_FAILED", "Asterisk rejected the PJSIP reload" + ) + + def apply(self, trunk_id: str, config: Mapping[str, Any]) -> None: + self.config_dir.mkdir(parents=True, exist_ok=True) + target = self._target(trunk_id) + previous = target.read_bytes() if target.exists() else None + try: + if config["enabled"]: + content = render_pjsip_trunk(config, trunk_id).encode("utf-8") + fd, temporary = tempfile.mkstemp( + prefix=target.name + ".", dir=self.config_dir + ) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, 0o640) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + elif target.exists(): + target.unlink() + self._reload() + except CellAgentError: + self._restore(target, previous) + raise + + def _restore(self, target: Path, previous: bytes | None) -> None: + try: + if previous is None: + if target.exists(): + target.unlink() + else: + fd, temporary = tempfile.mkstemp( + prefix=target.name + ".restore.", dir=self.config_dir + ) + try: + with os.fdopen(fd, "wb") as stream: + stream.write(previous) + stream.flush() + os.fsync(stream.fileno()) + os.chmod(temporary, 0o640) + os.replace(temporary, target) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + with suppress(CellAgentError): + self._reload() + except OSError: + pass + + +class CellAgentService: + def __init__( + self, + db_path: str | Path, + cell_id: str, + applier: Any, + *, + mode: str = "real", + ) -> None: + if mode != "real": + raise ValueError("Cell Agent only supports real mode") + self.cell_id = _id(cell_id, "cell_id") + self.mode = mode + self.applier = applier + self.store = CellAgentStore(db_path) + + def close(self) -> None: + self.store.close() + + def apply(self, body: Any, request_id: str) -> dict[str, Any]: + request_id = _request_id(request_id) + if not isinstance(body, dict) or body.get("cell_id") != self.cell_id: + raise CellAgentError( + 403, "CELL_MISMATCH", "publication is for another Cell" + ) + trunk_id = _id(body.get("trunk_id"), "trunk_id") + config, digest = _parse_config(body, trunk_id) + revision = body["revision"] + with self.store.write() as db: + row = db.execute( + "SELECT * FROM trunk_state WHERE trunk_id = ?", (trunk_id,) + ).fetchone() + applied_revision = row["applied_revision"] if row else 0 + applied_hash = row["applied_hash"] if row else None + if revision == applied_revision and digest == applied_hash: + return { + "mode": self.mode, + "cell_id": self.cell_id, + "trunk_id": trunk_id, + "revision": revision, + "config_sha256": digest, + "status": "applied", + "idempotent": True, + } + if revision <= applied_revision: + raise CellAgentError( + 409, + "REVISION_CONFLICT", + "publication revision is older than the applied revision", + {"applied_revision": applied_revision}, + ) + if revision != applied_revision + 1: + raise CellAgentError( + 409, + "REVISION_GAP", + "Cell requires the next revision in sequence", + {"applied_revision": applied_revision}, + ) + now = _now() + db.execute( + "INSERT INTO trunk_state(trunk_id,desired_revision,desired_hash,desired_config_json,applied_revision,applied_hash,applied_config_json,status,last_error,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?) " + "ON CONFLICT(trunk_id) DO UPDATE SET desired_revision=excluded.desired_revision,desired_hash=excluded.desired_hash,desired_config_json=excluded.desired_config_json,status=excluded.status,last_error=NULL,updated_at=excluded.updated_at", + ( + trunk_id, + revision, + digest, + _json(config), + applied_revision, + applied_hash, + row["applied_config_json"] if row else None, + "applying", + None, + now, + ), + ) + db.execute( + "INSERT INTO audit_log(audit_id,trunk_id,revision,action,details_json,created_at) VALUES(?,?,?,?,?,?)", + ( + str(uuid.uuid4()), + trunk_id, + revision, + "apply_started", + _json({"request_id": request_id}), + now, + ), + ) + try: + self.applier.apply(trunk_id, config) + except CellAgentError as exc: + self._mark_failed(trunk_id, revision, exc.code) + raise + except Exception as exc: + self._mark_failed(trunk_id, revision, "APPLIER_ERROR") + raise CellAgentError( + 502, "ASTERISK_APPLY_FAILED", "Cell configuration apply failed" + ) from exc + with self.store.write() as db: + now = _now() + db.execute( + "UPDATE trunk_state SET applied_revision=?,applied_hash=?,applied_config_json=?,status='applied',last_error=NULL,updated_at=? WHERE trunk_id=?", + (revision, digest, _json(config), now, trunk_id), + ) + db.execute( + "INSERT INTO audit_log(audit_id,trunk_id,revision,action,details_json,created_at) VALUES(?,?,?,?,?,?)", + ( + str(uuid.uuid4()), + trunk_id, + revision, + "applied", + _json({"request_id": request_id}), + now, + ), + ) + return { + "mode": self.mode, + "cell_id": self.cell_id, + "trunk_id": trunk_id, + "revision": revision, + "config_sha256": digest, + "status": "applied", + "idempotent": False, + } + + def _mark_failed(self, trunk_id: str, revision: int, error_code: str) -> None: + with self.store.write() as db: + db.execute( + "UPDATE trunk_state SET status='failed',last_error=?,updated_at=? WHERE trunk_id=? AND desired_revision=?", + (error_code, _now(), trunk_id, revision), + ) + db.execute( + "INSERT INTO audit_log(audit_id,trunk_id,revision,action,details_json,created_at) VALUES(?,?,?,?,?,?)", + ( + str(uuid.uuid4()), + trunk_id, + revision, + "apply_failed", + _json({"error_code": error_code}), + _now(), + ), + ) + + def state(self, trunk_id: str) -> dict[str, Any]: + trunk_id = _id(trunk_id, "trunk_id") + with self.store.read() as db: + row = db.execute( + "SELECT * FROM trunk_state WHERE trunk_id=?", (trunk_id,) + ).fetchone() + if not row: + raise CellAgentError( + 404, "TRUNK_NOT_FOUND", "trunk has not been applied" + ) + return { + "mode": self.mode, + "cell_id": self.cell_id, + "trunk_id": trunk_id, + "desired_revision": row["desired_revision"], + "applied_revision": row["applied_revision"], + "status": row["status"], + "last_error": row["last_error"], + "updated_at": row["updated_at"], + } + + +class CellAgentHandler(BaseHTTPRequestHandler): + server_version = "agent-call-cell-agent/1" + + def __init__( + self, + request: Any, + client_address: Any, + server: Any, + *, + service: CellAgentService, + ) -> None: + self.service = service + super().__init__(request, client_address, server) + + def log_message(self, format: str, *args: Any) -> None: + return + + def _send(self, status: int, payload: Mapping[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _body(self) -> Any: + raw_length = self.headers.get("Content-Length") + try: + length = int(raw_length or "-1") + except ValueError as exc: + raise CellAgentError( + 400, "INVALID_CONTENT_LENGTH", "Content-Length is invalid" + ) from exc + if length < 0 or length > CELL_BODY_BYTES: + raise CellAgentError( + 413, "PAYLOAD_TOO_LARGE", "publication body is too large" + ) + try: + return json.loads(self.rfile.read(length).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CellAgentError( + 400, "INVALID_JSON", "publication body must be JSON" + ) from exc + + def _path(self) -> list[str]: + parts = [unquote(part) for part in urlsplit(self.path).path.split("/") if part] + if any(part in {".", ".."} for part in parts): + raise CellAgentError(400, "INVALID_PATH", "path is invalid") + return parts + + def do_GET(self) -> None: + try: + parts = self._path() + if parts == ["healthz", "live"]: + self._send( + 200, + { + "status": "ok", + "mode": self.service.mode, + "cell_id": self.service.cell_id, + }, + ) + return + if ( + len(parts) == 5 + and parts[:3] == ["v1", "sip", "trunks"] + and parts[4] == "state" + ): + self._send(200, self.service.state(parts[3])) + return + raise CellAgentError(404, "NOT_FOUND", "endpoint does not exist") + except CellAgentError as exc: + self._send(exc.status, exc.response()) + + def do_POST(self) -> None: + try: + parts = self._path() + if ( + len(parts) != 5 + or parts[:3] != ["v1", "sip", "trunks"] + or parts[4] != "apply" + ): + raise CellAgentError(404, "NOT_FOUND", "endpoint does not exist") + body = self._body() + if not isinstance(body, dict) or body.get("trunk_id") != parts[3]: + raise CellAgentError( + 400, "TRUNK_MISMATCH", "path and body trunk_id differ" + ) + result = self.service.apply( + body, _request_id(self.headers.get("X-Request-ID")) + ) + self._send(200, result) + except CellAgentError as exc: + self._send(exc.status, exc.response()) + + +class CellAgentHTTPServer(ThreadingHTTPServer): + allow_reuse_address = True + daemon_threads = True + + def __init__( + self, + address: tuple[str, int], + handler: Any, + *, + ssl_context: ssl.SSLContext | None = None, + ) -> None: + self.ssl_context = ssl_context + super().__init__(address, handler) + + def get_request(self): + connection, address = super().get_request() + if self.ssl_context is None: + return connection, address + try: + return self.ssl_context.wrap_socket(connection, server_side=True), address + except Exception: + connection.close() + raise + + +def make_server( + service: CellAgentService, + host: str = "127.0.0.1", + port: int = 9443, + *, + ssl_context: ssl.SSLContext | None = None, +) -> CellAgentHTTPServer: + return CellAgentHTTPServer( + (host, port), + lambda request, client_address, server: CellAgentHandler( + request, client_address, server, service=service + ), + ssl_context=ssl_context, + ) + + +def build_server_tls_context( + ca_file: str, cert_file: str, key_file: str +) -> ssl.SSLContext: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.verify_mode = ssl.CERT_REQUIRED + try: + context.load_cert_chain(certfile=cert_file, keyfile=key_file) + context.load_verify_locations(cafile=ca_file) + except (OSError, ssl.SSLError) as exc: + raise ValueError("Cell Agent TLS files could not be loaded") from exc + return context + + +def build_from_env() -> tuple[CellAgentService, CellAgentHTTPServer]: + cell_id = os.environ.get("CELL_ID") + if not cell_id: + raise ValueError("CELL_ID is required") + config_dir = os.environ.get("CELL_ASTERISK_CONFIG_DIR", "/etc/asterisk/pjsip.d") + db_path = os.environ.get("CELL_AGENT_DB", "/var/lib/agent-call/cell-agent.sqlite3") + applier = AsteriskConfigApplier( + config_dir, + asterisk_cli=os.environ.get("CELL_ASTERISK_CLI", "/usr/sbin/asterisk"), + ) + service = CellAgentService(db_path, cell_id, applier) + ca_file = os.environ.get("CELL_TLS_CA_FILE") + cert_file = os.environ.get("CELL_TLS_CERT_FILE") + key_file = os.environ.get("CELL_TLS_KEY_FILE") + if not ca_file or not cert_file or not key_file: + service.close() + raise ValueError( + "CELL_TLS_CA_FILE, CELL_TLS_CERT_FILE and CELL_TLS_KEY_FILE are required" + ) + try: + port = int(os.environ.get("CELL_AGENT_PORT", "9443")) + except ValueError as exc: + service.close() + raise ValueError("CELL_AGENT_PORT must be an integer") from exc + server = make_server( + service, + os.environ.get("CELL_AGENT_HOST", "127.0.0.1"), + port, + ssl_context=build_server_tls_context(ca_file, cert_file, key_file), + ) + return service, server + + +__all__ = [ + "AsteriskConfigApplier", + "CellAgentError", + "CellAgentHTTPServer", + "CellAgentService", + "CellAgentStore", + "build_from_env", + "build_server_tls_context", + "make_server", + "render_pjsip_trunk", +] diff --git a/agent_call/cell_agent_main.py b/agent_call/cell_agent_main.py new file mode 100644 index 0000000..e86babc --- /dev/null +++ b/agent_call/cell_agent_main.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from .cell_agent import build_from_env + + +def main() -> None: + service, server = build_from_env() + try: + server.serve_forever() + except KeyboardInterrupt: + return + finally: + server.shutdown() + server.server_close() + service.close() + + +if __name__ == "__main__": + try: + main() + except ValueError as exc: + raise SystemExit(f"configuration error: {exc}") from exc diff --git a/agent_call/core.py b/agent_call/core.py index 0d980aa..6830c3d 100644 --- a/agent_call/core.py +++ b/agent_call/core.py @@ -31,6 +31,7 @@ from pathlib import Path from typing import Any, cast from .mocks import MockAI, MockARI, MockRTP, MockSIPPeer, ProtocolJournal +from .sip_readonly import SipReadonlyClient, SipReadonlyError try: from jsonschema import Draft202012Validator, FormatChecker @@ -1103,6 +1104,7 @@ class AgentCallService: broker: InMemoryBroker | PikaBroker | None = None, mode: str | None = None, clock: Callable[[], datetime] = utcnow, + sip_readonly: SipReadonlyClient | Any | None = None, start_background: bool = False, ) -> None: self.clock = clock @@ -1111,6 +1113,7 @@ class AgentCallService: self.mq_schema = _load_json_file(DEFAULT_MQ_SCHEMA) self.mode = mode or self.profile.get("mode", "mock") self._validate_profile() + self.sip_readonly = sip_readonly self.store = Store(db_path) self.instance_id = new_id("instance") object_root = ( @@ -1364,10 +1367,34 @@ class AgentCallService: if route.get("route_policy_id") == route_policy_id and route.get( "allowed", True ): - return route - raise ValidationError( - "ROUTE_POLICY_NOT_ALLOWED", "route policy is not configured" - ) + break + else: + raise ValidationError( + "ROUTE_POLICY_NOT_ALLOWED", "route policy is not configured" + ) + if self.sip_readonly is None: + return route + snapshot = self.sip_readonly.get_trunk(route["trunk_id"]) + config = snapshot.get("config") + enabled = config.get("enabled") if isinstance(config, dict) else None + if not isinstance(config, dict) or not isinstance(enabled, bool) or not enabled: + raise SipReadonlyError( + "SIP_TRUNK_NOT_PUBLISHED", "SIP Trunk is not published" + ) + dynamic = dict(route) + for field in ( + "codec_profile", + "caller_ids", + "dial_prefix", + "egress_pool_id", + "max_concurrency", + "max_cps", + "sip", + ): + if field in config: + dynamic[field] = config[field] + dynamic["trunk_revision"] = snapshot.get("revision") + return dynamic def _tenant(self, tenant_id: str, tenant_key: str | None = None) -> sqlite3.Row: row = self.store.one("SELECT * FROM tenants WHERE tenant_id = ?", (tenant_id,)) @@ -2095,8 +2122,13 @@ class AgentCallService: ) return _integer(row["n"]), 0 - def _resource_cell(self) -> tuple[str, dict[str, Any]] | None: + def _resource_cell( + self, route: dict[str, Any] | None = None + ) -> tuple[str, dict[str, Any]] | None: + egress_pool_id = route.get("egress_pool_id") if route else None for cell in self.profile.get("cells", []): + if egress_pool_id and cell.get("egress_pool_id") != egress_pool_id: + continue if not self._cell_health.get(cell["cell_id"], False): continue row = self.store.one( @@ -2196,6 +2228,19 @@ class AgentCallService: ) if row is None: continue + payload = _loads(row["payload_json"])["payload"] + try: + route = self._route_config(payload["route_policy_id"]) + except SipReadonlyError as exc: + self._mark_waiting( + tenant_id, + row["command_id"], + "SIP_DIRECTORY" if exc.retryable else exc.code, + ) + continue + except ValidationError as exc: + self._reject_command(tenant_id, row["command_id"], exc.code) + continue now = self._now() if ( row["admission_deadline"] @@ -2230,11 +2275,16 @@ class AgentCallService: if not self._cps_available(tenant_id): self._mark_waiting(tenant_id, row["command_id"], "CPS") continue - cell = self._resource_cell() + cell = self._resource_cell(route) if cell is None: self._mark_waiting(tenant_id, row["command_id"], "CELL_CAPACITY") continue - if not self._reserve_call(row["command_id"], row, cell): + try: + reserved = self._reserve_call(row["command_id"], row, cell, route) + except ValidationError as exc: + self._reject_command(tenant_id, row["command_id"], exc.code) + continue + if not reserved: self._mark_waiting(tenant_id, row["command_id"], "RESOURCE_RACE") continue scheduled += 1 @@ -2242,11 +2292,15 @@ class AgentCallService: return scheduled def _reserve_call( - self, command_id: str, row: sqlite3.Row, cell: tuple[str, dict[str, Any]] + self, + command_id: str, + row: sqlite3.Row, + cell: tuple[str, dict[str, Any]], + route: dict[str, Any] | None = None, ) -> bool: cell_id, cell_cfg = cell payload = _loads(row["payload_json"])["payload"] - route = self._route_config(payload["route_policy_id"]) + route = route or self._route_config(payload["route_policy_id"]) prefix = str(route.get("dial_prefix", "")) target_uri = ( payload["callee"] @@ -2261,6 +2315,10 @@ class AgentCallService: ), payload["caller_profile_id"], ) + if route.get("caller_ids") and caller not in route["caller_ids"]: + raise ValidationError( + "CALLER_NOT_ALLOWED", "caller is not allowed by the published Trunk" + ) call_id = new_id("call") attempt_id = new_id("attempt") now_dt = self._now() diff --git a/agent_call/main.py b/agent_call/main.py index 09d9c1f..3bac64f 100644 --- a/agent_call/main.py +++ b/agent_call/main.py @@ -5,6 +5,7 @@ from pathlib import Path from .core import AgentCallService, ConfigurationError, _integer from .http import make_server +from .sip_readonly import SipReadonlyClient def build_service() -> AgentCallService: @@ -14,11 +15,37 @@ def build_service() -> AgentCallService: db_path = os.environ.get("AGENT_CALL_DB", "data/agent-call.sqlite3") object_dir = os.environ.get("AGENT_CALL_OBJECT_DIR") provider_mode = os.environ.get("AGENT_CALL_MODE") + readonly_url = os.environ.get("AGENT_CALL_SIP_READONLY_URL") + readonly_token = os.environ.get("AGENT_CALL_SIP_READONLY_TOKEN") + sip_readonly = None + if readonly_url or readonly_token: + if not readonly_url or not readonly_token: + raise ConfigurationError( + "SIP read-only URL and token must be configured together" + ) + try: + readonly_timeout = float( + os.environ.get("AGENT_CALL_SIP_READONLY_TIMEOUT_SECONDS", "5") + ) + except ValueError as exc: + raise ConfigurationError( + "AGENT_CALL_SIP_READONLY_TIMEOUT_SECONDS must be a number" + ) from exc + try: + sip_readonly = SipReadonlyClient( + readonly_url, + readonly_token, + ca_file=os.environ.get("AGENT_CALL_SIP_READONLY_CA_FILE") or None, + timeout=readonly_timeout, + ) + except ValueError as exc: + raise ConfigurationError(str(exc)) from exc return AgentCallService( db_path=db_path, profile_path=profile, object_dir=object_dir, mode=provider_mode, + sip_readonly=sip_readonly, start_background=True, ) diff --git a/agent_call/sip_management.py b/agent_call/sip_management.py new file mode 100644 index 0000000..d81f459 --- /dev/null +++ b/agent_call/sip_management.py @@ -0,0 +1,1871 @@ +from __future__ import annotations + +import hashlib +import json +import os +import re +import sqlite3 +import ssl +import threading +import uuid +from collections.abc import Iterator, Mapping +from contextlib import contextmanager +from dataclasses import dataclass +from datetime import datetime, timezone +from functools import partial +from http.client import HTTPSConnection +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from pathlib import Path +from typing import Any +from urllib.parse import quote, unquote, urlsplit + +CODEC_TO_ASTERISK = {"PCMA": "alaw", "PCMU": "ulaw"} +ADMIN_AUDIENCE = "agent-call.sip-admin" +READ_AUDIENCE = "agent-call.sip-read" +MAX_BODY_BYTES = 64 * 1024 +_ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$") + + +class SipManagementError(Exception): + def __init__( + self, + status: int, + code: str, + detail: str, + extra: Mapping[str, Any] | None = None, + ) -> None: + super().__init__(detail) + self.status = status + self.code = code + self.detail = detail + self.extra = dict(extra or {}) + + def response(self) -> dict[str, Any]: + return {"error": {"code": self.code, "message": self.detail, **self.extra}} + + +class ConfigurationError(ValueError): + pass + + +@dataclass(frozen=True) +class Principal: + subject: str + issuer: str + audience: str + scopes: frozenset[str] + trunk_ids: frozenset[str] + token_class: str + + +def _now() -> str: + return ( + datetime.now(timezone.utc) + .replace(microsecond=0) + .isoformat() + .replace("+00:00", "Z") + ) + + +def _json(value: Any) -> str: + try: + return json.dumps( + value, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ) + except (TypeError, ValueError) as exc: + raise ConfigurationError("value is not JSON serializable") from exc + + +def _copy(value: Any) -> Any: + try: + return json.loads(json.dumps(value, ensure_ascii=False)) + except (TypeError, ValueError, json.JSONDecodeError) as exc: + raise ConfigurationError("value is not JSON serializable") from exc + + +def _decode_json(value: str, detail: str) -> Any: + try: + return json.loads(value) + except json.JSONDecodeError as exc: + raise SipManagementError(500, "CORRUPT_STATE", detail) from exc + + +def _json_object(raw: str | None, detail: str) -> dict[str, Any]: + try: + value = json.loads(raw or "{}") + except json.JSONDecodeError as exc: + raise ConfigurationError(detail) from exc + if not isinstance(value, dict): + raise ConfigurationError(detail) + return value + + +def _id(value: Any, field: str) -> str: + if not isinstance(value, str) or not _ID_RE.fullmatch(value): + raise SipManagementError(400, "INVALID_" + field.upper(), f"{field} is invalid") + return value + + +def _text(value: Any, field: str, *, max_length: int = 256) -> str: + if not isinstance(value, str) or not value or len(value) > max_length: + raise SipManagementError(400, "INVALID_" + field.upper(), f"{field} is invalid") + if value != value.strip() or any(ord(char) < 32 for char in value): + raise SipManagementError( + 400, "INVALID_" + field.upper(), f"{field} contains control characters" + ) + return value + + +def _integer( + value: Any, field: str, *, minimum: int = 0, maximum: int = 2**31 - 1 +) -> int: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or not minimum <= value <= maximum + ): + raise SipManagementError(400, "INVALID_" + field.upper(), f"{field} is invalid") + return value + + +def _keys(value: Mapping[str, Any], allowed: set[str], field: str) -> None: + unknown = sorted(set(value) - allowed) + if unknown: + raise SipManagementError( + 400, + "UNKNOWN_CONFIG_FIELD", + f"{field} contains unsupported fields", + {"fields": unknown}, + ) + + +def _codec_list(value: Any, field: str) -> list[str]: + if not isinstance(value, list) or not value or len(value) > len(CODEC_TO_ASTERISK): + raise SipManagementError( + 400, "INVALID_CODEC_PROFILE", f"{field} must be a non-empty list" + ) + codecs: list[str] = [] + for item in value: + if not isinstance(item, str) or item.upper() not in CODEC_TO_ASTERISK: + raise SipManagementError( + 400, "UNSUPPORTED_CODEC", f"unsupported codec in {field}" + ) + codec = item.upper() + if codec in codecs: + raise SipManagementError( + 400, "DUPLICATE_CODEC", f"duplicate codec in {field}" + ) + codecs.append(codec) + return codecs + + +def _validate_trunk(trunk_id: str, raw: Any) -> dict[str, Any]: + trunk_id = _id(trunk_id, "trunk_id") + if not isinstance(raw, dict): + raise SipManagementError( + 400, "INVALID_TRUNK_CONFIG", "trunk config must be an object" + ) + _keys( + raw, + { + "display_name", + "enabled", + "sip", + "codec_profile", + "caller_ids", + "dial_prefix", + "egress_pool_id", + "max_concurrency", + "max_cps", + }, + "trunk", + ) + display_name = _text(raw.get("display_name"), "display_name") + enabled = raw.get("enabled", True) + if not isinstance(enabled, bool): + raise SipManagementError(400, "INVALID_ENABLED", "enabled must be boolean") + + sip = raw.get("sip") + if not isinstance(sip, dict): + raise SipManagementError(400, "INVALID_SIP_CONFIG", "sip must be an object") + _keys( + sip, + {"host", "port", "transport", "auth_mode", "credential_ref", "register"}, + "sip", + ) + host = _text(sip.get("host"), "sip.host", max_length=253) + if "/" in host or "@" in host: + raise SipManagementError( + 400, "INVALID_SIP_HOST", "sip.host must be a host, not a URL or credential" + ) + port = _integer(sip.get("port"), "sip.port", minimum=1, maximum=65535) + transport = sip.get("transport", "udp") + if transport not in {"udp", "tcp", "tls"}: + raise SipManagementError( + 400, "INVALID_SIP_TRANSPORT", "sip.transport is unsupported" + ) + auth_mode = sip.get("auth_mode", "ip") + if auth_mode not in {"ip", "digest"}: + raise SipManagementError( + 400, "INVALID_AUTH_MODE", "sip.auth_mode is unsupported" + ) + credential_ref = sip.get("credential_ref") + if credential_ref is not None: + credential_ref = _text(credential_ref, "sip.credential_ref", max_length=512) + if not credential_ref.startswith(("secret://", "vault://")): + raise SipManagementError( + 400, + "INVALID_CREDENTIAL_REF", + "sip.credential_ref must reference a secret store", + ) + if auth_mode == "digest" and not credential_ref: + raise SipManagementError( + 400, "MISSING_CREDENTIAL_REF", "digest auth requires sip.credential_ref" + ) + register = sip.get("register", False) + if not isinstance(register, bool): + raise SipManagementError( + 400, "INVALID_REGISTER", "sip.register must be boolean" + ) + + codec_profile = raw.get("codec_profile") + if not isinstance(codec_profile, dict): + raise SipManagementError( + 400, "INVALID_CODEC_PROFILE", "codec_profile must be an object" + ) + _keys(codec_profile, {"allowed", "preferred"}, "codec_profile") + allowed_codecs = _codec_list(codec_profile.get("allowed"), "codec_profile.allowed") + preferred = codec_profile.get("preferred", allowed_codecs[0]) + if not isinstance(preferred, str) or preferred.upper() not in allowed_codecs: + raise SipManagementError( + 400, "INVALID_CODEC_PREFERENCE", "codec_profile.preferred must be allowed" + ) + preferred = preferred.upper() + + caller_ids = raw.get("caller_ids") + if not isinstance(caller_ids, list) or not caller_ids or len(caller_ids) > 100: + raise SipManagementError( + 400, "INVALID_CALLER_IDS", "caller_ids must be a non-empty list" + ) + normalized_callers = [ + _text(item, "caller_ids", max_length=128) for item in caller_ids + ] + if len(set(normalized_callers)) != len(normalized_callers): + raise SipManagementError( + 400, "DUPLICATE_CALLER_ID", "caller_ids contains duplicates" + ) + + dial_prefix = raw.get("dial_prefix", "") + if ( + not isinstance(dial_prefix, str) + or len(dial_prefix) > 32 + or any(char.isspace() for char in dial_prefix) + ): + raise SipManagementError(400, "INVALID_DIAL_PREFIX", "dial_prefix is invalid") + egress_pool_id = _id(raw.get("egress_pool_id"), "egress_pool_id") + + return { + "display_name": display_name, + "enabled": enabled, + "sip": { + "host": host, + "port": port, + "transport": transport, + "auth_mode": auth_mode, + "credential_ref": credential_ref, + "register": register, + }, + "codec_profile": {"allowed": allowed_codecs, "preferred": preferred}, + "caller_ids": normalized_callers, + "dial_prefix": dial_prefix, + "egress_pool_id": egress_pool_id, + "max_concurrency": _integer( + raw.get("max_concurrency"), "max_concurrency", minimum=1 + ), + "max_cps": _integer(raw.get("max_cps"), "max_cps", minimum=1), + } + + +def _validate_cell(cell_id: str, raw: Any) -> dict[str, Any]: + cell_id = _id(cell_id, "cell_id") + if not isinstance(raw, dict): + raise SipManagementError( + 400, "INVALID_CELL_CONFIG", "cell config must be an object" + ) + _keys( + raw, + { + "egress_pool_id", + "codec_capabilities", + "status", + "max_concurrency", + "management_url", + }, + "cell", + ) + status = raw.get("status", "healthy") + if status not in {"healthy", "draining", "disabled"}: + raise SipManagementError( + 400, "INVALID_CELL_STATUS", "cell.status is unsupported" + ) + capabilities = _codec_list(raw.get("codec_capabilities"), "codec_capabilities") + management_url = raw.get("management_url") + if management_url is not None: + if not isinstance(management_url, str) or not management_url: + raise SipManagementError( + 400, "INVALID_MANAGEMENT_URL", "cell.management_url must be a URL" + ) + parsed = urlsplit(management_url) + try: + port = parsed.port + except ValueError as exc: + raise SipManagementError( + 400, "INVALID_MANAGEMENT_URL", "cell.management_url port is invalid" + ) from exc + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or (port is not None and not 1 <= port <= 65535) + ): + raise SipManagementError( + 400, + "INVALID_MANAGEMENT_URL", + "cell.management_url must be an HTTPS URL without credentials or query data", + ) + management_url = management_url.rstrip("/") + return { + "egress_pool_id": _id(raw.get("egress_pool_id"), "egress_pool_id"), + "codec_capabilities": capabilities, + "status": status, + "max_concurrency": _integer( + raw.get("max_concurrency"), "max_concurrency", minimum=1 + ), + "management_url": management_url, + } + + +def asterisk_codec_allow(codec_profile: Mapping[str, Any]) -> list[str]: + """Map the public codec names to Asterisk names without exposing Asterisk syntax upstream.""" + return [CODEC_TO_ASTERISK[codec] for codec in codec_profile["allowed"]] + + +def _public_config( + config: Mapping[str, Any], *, include_asterisk: bool = False +) -> dict[str, Any]: + result = _copy(config) + result.pop("trunk_id", None) + sip = result["sip"] + credential_ref = sip.pop("credential_ref", None) + result["credential_configured"] = bool(credential_ref) + if include_asterisk: + result["asterisk_allow"] = asterisk_codec_allow(result["codec_profile"]) + return result + + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS trunks ( + trunk_id TEXT PRIMARY KEY, + latest_revision INTEGER NOT NULL, + active_revision INTEGER NOT NULL, + active_status TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS trunk_versions ( + trunk_id TEXT NOT NULL REFERENCES trunks(trunk_id), + revision INTEGER NOT NULL, + config_json TEXT NOT NULL, + state TEXT NOT NULL, + created_at TEXT NOT NULL, + created_by TEXT NOT NULL, + PRIMARY KEY (trunk_id, revision) +); +CREATE TABLE IF NOT EXISTS cells ( + cell_id TEXT PRIMARY KEY, + revision INTEGER NOT NULL, + config_json TEXT NOT NULL, + updated_at TEXT NOT NULL, + updated_by TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS publications ( + trunk_id TEXT NOT NULL, + revision INTEGER NOT NULL, + cell_id TEXT NOT NULL REFERENCES cells(cell_id), + status TEXT NOT NULL, + error_code TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (trunk_id, revision, cell_id) +); +CREATE TABLE IF NOT EXISTS audit_log ( + audit_id TEXT PRIMARY KEY, + resource_type TEXT NOT NULL, + resource_id TEXT NOT NULL, + action TEXT NOT NULL, + revision INTEGER NOT NULL, + actor TEXT NOT NULL, + request_id TEXT, + details_json TEXT NOT NULL, + created_at TEXT NOT NULL +); +""" + + +class SipManagementStore: + def __init__(self, db_path: str | Path) -> None: + self.path = str(db_path) + if self.path != ":memory:": + Path(self.path).parent.mkdir(parents=True, exist_ok=True) + self._lock = threading.RLock() + self._db = sqlite3.connect(self.path, timeout=5, check_same_thread=False) + self._db.row_factory = sqlite3.Row + self._db.execute("PRAGMA foreign_keys = ON") + self._db.execute("PRAGMA busy_timeout = 5000") + if self.path != ":memory:": + self._db.execute("PRAGMA journal_mode = WAL") + self._db.executescript(SCHEMA) + self._db.commit() + + @contextmanager + def write(self) -> Iterator[sqlite3.Connection]: + with self._lock: + self._db.execute("BEGIN IMMEDIATE") + try: + yield self._db + except Exception: + self._db.rollback() + raise + else: + self._db.commit() + + @contextmanager + def read(self) -> Iterator[sqlite3.Connection]: + with self._lock: + yield self._db + + def close(self) -> None: + with self._lock: + self._db.close() + + +class CellPublishError(Exception): + def __init__(self, code: str, detail: str) -> None: + super().__init__(detail) + self.code = code + self.detail = detail + + +class MtlsCellPublisher: + def __init__( + self, + *, + ca_file: str, + cert_file: str, + key_file: str, + timeout: float = 15.0, + ) -> None: + if not timeout > 0: + raise ConfigurationError( + "SIP_CELL_PUBLISH_TIMEOUT_SECONDS must be positive" + ) + try: + context = ssl.create_default_context( + ssl.Purpose.SERVER_AUTH, cafile=ca_file + ) + context.load_cert_chain(certfile=cert_file, keyfile=key_file) + except (OSError, ssl.SSLError) as exc: + raise ConfigurationError( + "Cell publisher TLS files could not be loaded" + ) from exc + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.check_hostname = True + self._context = context + self._timeout = timeout + + def publish( + self, + cell: Mapping[str, Any], + trunk_id: str, + revision: int, + config: Mapping[str, Any], + request_id: str, + ) -> dict[str, Any]: + management_url = cell.get("management_url") + if not management_url: + raise CellPublishError( + "CELL_ENDPOINT_MISSING", "Cell Agent endpoint is not configured" + ) + parsed = urlsplit(management_url) + try: + port = parsed.port or 443 + except ValueError as exc: + raise CellPublishError( + "CELL_ENDPOINT_INVALID", "Cell Agent endpoint is invalid" + ) from exc + if parsed.scheme != "https" or not parsed.hostname: + raise CellPublishError( + "CELL_ENDPOINT_INVALID", "Cell Agent endpoint must use HTTPS" + ) + path = ( + f"{parsed.path.rstrip('/')}/v1/sip/trunks/{quote(trunk_id, safe='')}/apply" + ) + if not path.startswith("/"): + path = "/" + path + digest = hashlib.sha256(_json(config).encode("utf-8")).hexdigest() + body = _json( + { + "mode": "real", + "cell_id": cell["cell_id"], + "trunk_id": trunk_id, + "revision": revision, + "config": config, + "config_sha256": digest, + } + ).encode("utf-8") + connection: HTTPSConnection | None = None + try: + connection = HTTPSConnection( + parsed.hostname, + port, + context=self._context, + timeout=self._timeout, + ) + connection.request( + "POST", + path, + body=body, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "Content-Length": str(len(body)), + "X-Request-ID": request_id, + }, + ) + response = connection.getresponse() + raw = response.read(MAX_BODY_BYTES + 1) + except (OSError, ValueError) as exc: + raise CellPublishError( + "CELL_UNREACHABLE", "Cell Agent request failed" + ) from exc + finally: + if connection is not None: + connection.close() + if response.status < 200 or response.status >= 300: + code = "CELL_REJECTED" + try: + error = json.loads(raw.decode("utf-8")).get("error", {}) + if isinstance(error, dict) and isinstance(error.get("code"), str): + code = error["code"] + except (UnicodeDecodeError, json.JSONDecodeError, AttributeError): + pass + raise CellPublishError(code, "Cell Agent rejected the publication") + try: + acknowledgement = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise CellPublishError( + "CELL_INVALID_ACK", "Cell Agent returned invalid JSON" + ) from exc + if not isinstance(acknowledgement, dict): + raise CellPublishError( + "CELL_INVALID_ACK", "Cell Agent acknowledgement is invalid" + ) + if ( + acknowledgement.get("status") != "applied" + or acknowledgement.get("mode") != "real" + or acknowledgement.get("cell_id") != cell["cell_id"] + or acknowledgement.get("trunk_id") != trunk_id + or acknowledgement.get("revision") != revision + or acknowledgement.get("config_sha256") != digest + ): + raise CellPublishError( + "CELL_INVALID_ACK", + "Cell Agent acknowledgement does not match publication", + ) + return acknowledgement + + +class SipManagementService: + def __init__( + self, + db_path: str | Path, + *, + mode: str = "mock", + publisher: MtlsCellPublisher | Any | None = None, + ) -> None: + if mode not in {"mock", "real"}: + raise ConfigurationError("SIP_MANAGEMENT_MODE must be mock or real") + if mode == "real" and publisher is None: + raise ConfigurationError("real mode requires an mTLS Cell publisher") + self.mode = mode + self.publisher = publisher + self.store = SipManagementStore(db_path) + + def close(self) -> None: + self.store.close() + + def _audit( + self, + db: sqlite3.Connection, + resource_type: str, + resource_id: str, + action: str, + revision: int, + actor: str, + request_id: str | None, + details: Mapping[str, Any] | None = None, + ) -> None: + db.execute( + "INSERT INTO audit_log(audit_id,resource_type,resource_id,action,revision,actor,request_id,details_json,created_at) VALUES(?,?,?,?,?,?,?,?,?)", + ( + str(uuid.uuid4()), + resource_type, + resource_id, + action, + revision, + actor, + request_id, + _json(details or {}), + _now(), + ), + ) + + @staticmethod + def _row_config(row: sqlite3.Row | None) -> dict[str, Any] | None: + return ( + _decode_json(row["config_json"], "stored Trunk config is invalid") + if row + else None + ) + + def _compatible_cell_records( + self, + db: sqlite3.Connection, + config: Mapping[str, Any], + *, + healthy_only: bool = True, + ) -> list[dict[str, Any]]: + cells = db.execute( + "SELECT cell_id, config_json FROM cells ORDER BY cell_id" + ).fetchall() + allowed = set(config["codec_profile"]["allowed"]) + result: list[dict[str, Any]] = [] + for cell in cells: + cell_config = _decode_json( + cell["config_json"], "stored Cell config is invalid" + ) + if healthy_only and cell_config["status"] != "healthy": + continue + if cell_config["egress_pool_id"] != config["egress_pool_id"]: + continue + if allowed.intersection(cell_config["codec_capabilities"]): + result.append({"cell_id": cell["cell_id"], **cell_config}) + return result + + def _compatible_cells( + self, db: sqlite3.Connection, config: Mapping[str, Any] + ) -> list[str]: + return [cell["cell_id"] for cell in self._compatible_cell_records(db, config)] + + def _trunk_summary( + self, db: sqlite3.Connection, trunk_id: str, *, admin: bool + ) -> dict[str, Any]: + trunk = db.execute( + "SELECT * FROM trunks WHERE trunk_id = ?", (trunk_id,) + ).fetchone() + if not trunk: + raise SipManagementError(404, "TRUNK_NOT_FOUND", "trunk does not exist") + latest = db.execute( + "SELECT * FROM trunk_versions WHERE trunk_id = ? AND revision = ?", + (trunk_id, trunk["latest_revision"]), + ).fetchone() + active = None + if trunk["active_revision"]: + active = db.execute( + "SELECT * FROM trunk_versions WHERE trunk_id = ? AND revision = ?", + (trunk_id, trunk["active_revision"]), + ).fetchone() + latest_config = self._row_config(latest) + active_config = self._row_config(active) + selected_config = active_config or latest_config + compatible = ( + self._compatible_cells(db, selected_config) if selected_config else [] + ) + active_view = ( + _public_config(active_config, include_asterisk=admin) + if active_config + else None + ) + if not admin: + return { + "mode": self.mode, + "trunk_id": trunk_id, + "revision": trunk["active_revision"], + "status": trunk["active_status"], + "updated_at": trunk["updated_at"], + "config": active_view, + } + result: dict[str, Any] = { + "mode": self.mode, + "trunk_id": trunk_id, + "latest_revision": trunk["latest_revision"], + "active_revision": trunk["active_revision"], + "status": trunk["active_status"], + "updated_at": trunk["updated_at"], + "compatible_cell_ids": compatible, + "latest": _public_config(latest_config, include_asterisk=True) + if latest_config + else None, + "active": active_view, + } + result["versions"] = [ + { + "revision": row["revision"], + "state": row["state"], + "created_at": row["created_at"], + "created_by": row["created_by"], + } + for row in db.execute( + "SELECT revision,state,created_at,created_by FROM trunk_versions WHERE trunk_id = ? ORDER BY revision", + (trunk_id,), + ).fetchall() + ] + return result + + def upsert_cell( + self, + cell_id: str, + payload: Any, + *, + expected_revision: int, + actor: str, + request_id: str, + ) -> dict[str, Any]: + config = _validate_cell(cell_id, payload) + if self.mode == "real" and not config["management_url"]: + raise SipManagementError( + 422, + "CELL_ENDPOINT_MISSING", + "real mode requires cell.management_url", + ) + _integer(expected_revision, "expected_revision") + with self.store.write() as db: + row = db.execute( + "SELECT revision FROM cells WHERE cell_id = ?", (cell_id,) + ).fetchone() + current = row["revision"] if row else 0 + if expected_revision != current: + raise SipManagementError( + 409, + "REVISION_CONFLICT", + "cell revision changed", + { + "expected_revision": expected_revision, + "current_revision": current, + }, + ) + revision = current + 1 + now = _now() + db.execute( + "INSERT INTO cells(cell_id,revision,config_json,updated_at,updated_by) VALUES(?,?,?,?,?) " + "ON CONFLICT(cell_id) DO UPDATE SET revision=excluded.revision,config_json=excluded.config_json,updated_at=excluded.updated_at,updated_by=excluded.updated_by", + (cell_id, revision, _json(config), now, actor), + ) + self._audit(db, "cell", cell_id, "upsert", revision, actor, request_id) + return self.get_cell(cell_id) + + def get_cell(self, cell_id: str) -> dict[str, Any]: + cell_id = _id(cell_id, "cell_id") + with self.store.read() as db: + row = db.execute( + "SELECT * FROM cells WHERE cell_id = ?", (cell_id,) + ).fetchone() + if not row: + raise SipManagementError(404, "CELL_NOT_FOUND", "cell does not exist") + config = _decode_json(row["config_json"], "stored Cell config is invalid") + return { + "mode": self.mode, + "cell_id": cell_id, + "revision": row["revision"], + "config": config, + "updated_at": row["updated_at"], + "updated_by": row["updated_by"], + } + + def list_cells(self) -> list[dict[str, Any]]: + with self.store.read() as db: + rows = db.execute("SELECT * FROM cells ORDER BY cell_id").fetchall() + return [ + { + "mode": self.mode, + "cell_id": row["cell_id"], + "revision": row["revision"], + "config": _decode_json( + row["config_json"], "stored Cell config is invalid" + ), + "updated_at": row["updated_at"], + "updated_by": row["updated_by"], + } + for row in rows + ] + + def upsert_trunk( + self, + trunk_id: str, + payload: Any, + *, + expected_revision: int, + actor: str, + request_id: str, + ) -> dict[str, Any]: + config = _validate_trunk(trunk_id, payload) + _integer(expected_revision, "expected_revision") + with self.store.write() as db: + row = db.execute( + "SELECT * FROM trunks WHERE trunk_id = ?", (trunk_id,) + ).fetchone() + current = row["latest_revision"] if row else 0 + if expected_revision != current: + raise SipManagementError( + 409, + "REVISION_CONFLICT", + "trunk revision changed", + { + "expected_revision": expected_revision, + "current_revision": current, + }, + ) + latest = ( + db.execute( + "SELECT state FROM trunk_versions WHERE trunk_id=? AND revision=?", + (trunk_id, current), + ).fetchone() + if row + else None + ) + if latest and latest["state"] == "publishing": + raise SipManagementError( + 409, + "PUBLISH_IN_PROGRESS", + "trunk publication is still in progress", + ) + revision = current + 1 + now = _now() + if row: + db.execute( + "UPDATE trunks SET latest_revision=?,updated_at=? WHERE trunk_id=?", + (revision, now, trunk_id), + ) + else: + db.execute( + "INSERT INTO trunks(trunk_id,latest_revision,active_revision,active_status,created_at,updated_at) VALUES(?,?,?,?,?,?)", + (trunk_id, revision, 0, "draft", now, now), + ) + db.execute( + "INSERT INTO trunk_versions(trunk_id,revision,config_json,state,created_at,created_by) VALUES(?,?,?,?,?,?)", + (trunk_id, revision, _json(config), "draft", now, actor), + ) + self._audit(db, "trunk", trunk_id, "upsert", revision, actor, request_id) + return self.get_trunk(trunk_id) + + def get_trunk(self, trunk_id: str) -> dict[str, Any]: + trunk_id = _id(trunk_id, "trunk_id") + with self.store.read() as db: + return self._trunk_summary(db, trunk_id, admin=True) + + def list_trunks(self) -> list[dict[str, Any]]: + with self.store.read() as db: + rows = db.execute( + "SELECT trunk_id FROM trunks ORDER BY trunk_id" + ).fetchall() + return [ + self._trunk_summary(db, row["trunk_id"], admin=True) for row in rows + ] + + def list_readonly_trunks( + self, allowed_trunk_ids: frozenset[str] + ) -> list[dict[str, Any]]: + with self.store.read() as db: + rows = db.execute( + "SELECT trunk_id FROM trunks WHERE active_status = 'published' ORDER BY trunk_id" + ).fetchall() + result: list[dict[str, Any]] = [] + for row in rows: + if ( + "*" not in allowed_trunk_ids + and row["trunk_id"] not in allowed_trunk_ids + ): + continue + result.append(self._trunk_summary(db, row["trunk_id"], admin=False)) + return result + + def get_readonly_trunk( + self, trunk_id: str, allowed_trunk_ids: frozenset[str] + ) -> dict[str, Any]: + trunk_id = _id(trunk_id, "trunk_id") + if "*" not in allowed_trunk_ids and trunk_id not in allowed_trunk_ids: + raise SipManagementError(404, "TRUNK_NOT_FOUND", "trunk does not exist") + with self.store.read() as db: + summary = self._trunk_summary(db, trunk_id, admin=False) + if summary["status"] != "published": + raise SipManagementError( + 404, "TRUNK_NOT_FOUND", "trunk is not published" + ) + return summary + + def _staged_publication_cells( + self, + db: sqlite3.Connection, + trunk_id: str, + revision: int, + config: Mapping[str, Any], + ) -> list[dict[str, Any]]: + rows = db.execute( + "SELECT p.cell_id,c.config_json FROM publications p LEFT JOIN cells c ON c.cell_id=p.cell_id WHERE p.trunk_id=? AND p.revision=? ORDER BY p.cell_id", + (trunk_id, revision), + ).fetchall() + if not rows: + raise SipManagementError( + 409, "PUBLICATION_NOT_FOUND", "publication has no Cell intents" + ) + result: list[dict[str, Any]] = [] + for row in rows: + if row["config_json"] is None: + result.append( + { + "cell_id": row["cell_id"], + "egress_pool_id": config["egress_pool_id"], + "codec_capabilities": [], + "status": "unavailable", + "max_concurrency": 1, + "management_url": None, + } + ) + continue + cell_config = _decode_json( + row["config_json"], "stored Cell config is invalid" + ) + result.append({"cell_id": row["cell_id"], **cell_config}) + return result + + def _deliver_publication( + self, + trunk_id: str, + revision: int, + config: Mapping[str, Any], + cells: list[dict[str, Any]], + request_id: str, + ) -> list[dict[str, str]]: + if self.mode == "mock": + return [] + publisher: Any = self.publisher + if publisher is None: + raise ConfigurationError("real mode requires an mTLS Cell publisher") + failures: list[dict[str, str]] = [] + for cell in cells: + with self.store.read() as db: + publication = db.execute( + "SELECT status FROM publications WHERE trunk_id=? AND revision=? AND cell_id=?", + (trunk_id, revision, cell["cell_id"]), + ).fetchone() + if publication and publication["status"] == "applied": + continue + try: + acknowledgement = publisher.publish( + cell, trunk_id, revision, config, request_id + ) + if ( + not isinstance(acknowledgement, dict) + or acknowledgement.get("status") != "applied" + ): + raise CellPublishError( + "CELL_INVALID_ACK", "Cell Agent did not acknowledge applied" + ) + status = "applied" + error_code = None + except CellPublishError as exc: + status = "failed" + error_code = exc.code + failures.append({"cell_id": cell["cell_id"], "error_code": exc.code}) + except (OSError, TypeError, ValueError): + status = "failed" + error_code = "CELL_PUBLISH_FAILED" + failures.append({"cell_id": cell["cell_id"], "error_code": error_code}) + with self.store.write() as db: + db.execute( + "UPDATE publications SET status=?,error_code=?,updated_at=? WHERE trunk_id=? AND revision=? AND cell_id=?", + ( + status, + error_code, + _now(), + trunk_id, + revision, + cell["cell_id"], + ), + ) + return failures + + def _complete_publication( + self, + trunk_id: str, + revision: int, + config: Mapping[str, Any], + cells: list[dict[str, Any]], + failures: list[dict[str, str]], + *, + action: str, + actor: str, + request_id: str, + details: Mapping[str, Any] | None = None, + ) -> dict[str, Any]: + details = {"cell_ids": [cell["cell_id"] for cell in cells], **(details or {})} + with self.store.write() as db: + trunk = db.execute( + "SELECT * FROM trunks WHERE trunk_id=?", (trunk_id,) + ).fetchone() + if not trunk or trunk["latest_revision"] != revision: + raise SipManagementError( + 409, + "REVISION_CONFLICT", + "publication revision changed before completion", + ) + if failures: + db.execute( + "UPDATE trunk_versions SET state='draft' WHERE trunk_id=? AND revision=?", + (trunk_id, revision), + ) + self._audit( + db, + "trunk", + trunk_id, + f"{action}_failed", + revision, + actor, + request_id, + { + **details, + "delivery_status": "failed", + "failures": failures, + }, + ) + else: + if trunk["active_revision"]: + db.execute( + "UPDATE trunk_versions SET state='superseded' WHERE trunk_id=? AND revision=?", + (trunk_id, trunk["active_revision"]), + ) + db.execute( + "UPDATE trunk_versions SET state='published' WHERE trunk_id=? AND revision=?", + (trunk_id, revision), + ) + status = "published" if config["enabled"] else "disabled" + db.execute( + "UPDATE trunks SET active_revision=?,active_status=?,updated_at=? WHERE trunk_id=?", + (revision, status, _now(), trunk_id), + ) + if self.mode == "real": + db.execute( + "UPDATE publications SET status='applied',error_code=NULL,updated_at=? WHERE trunk_id=? AND revision=?", + (_now(), trunk_id, revision), + ) + self._audit( + db, + "trunk", + trunk_id, + action, + revision, + actor, + request_id, + { + **details, + "delivery_status": "applied" + if self.mode == "real" + else "pending", + }, + ) + return self._trunk_summary(db, trunk_id, admin=True) + + def _raise_delivery_failure( + self, action: str, failures: list[dict[str, str]] + ) -> None: + if failures: + raise SipManagementError( + 502, + "CELL_PUBLISH_FAILED", + f"{action} was not applied by every Cell", + {"failures": failures}, + ) + + def publish_trunk( + self, + trunk_id: str, + *, + expected_revision: int, + actor: str, + request_id: str, + ) -> dict[str, Any]: + trunk_id = _id(trunk_id, "trunk_id") + _integer(expected_revision, "expected_revision") + with self.store.write() as db: + trunk = db.execute( + "SELECT * FROM trunks WHERE trunk_id=?", (trunk_id,) + ).fetchone() + if not trunk: + raise SipManagementError(404, "TRUNK_NOT_FOUND", "trunk does not exist") + if trunk["latest_revision"] != expected_revision: + raise SipManagementError( + 409, + "REVISION_CONFLICT", + "trunk revision changed", + { + "expected_revision": expected_revision, + "current_revision": trunk["latest_revision"], + }, + ) + version = db.execute( + "SELECT * FROM trunk_versions WHERE trunk_id=? AND revision=?", + (trunk_id, expected_revision), + ).fetchone() + if not version: + raise SipManagementError( + 409, "VERSION_NOT_FOUND", "trunk version does not exist" + ) + resume = version["state"] == "publishing" + if ( + trunk["active_revision"] == expected_revision + and trunk["active_status"] == "published" + ): + return self._trunk_summary(db, trunk_id, admin=True) + config = _decode_json( + version["config_json"], "stored Trunk config is invalid" + ) + if not config["enabled"]: + raise SipManagementError( + 409, "TRUNK_DISABLED", "disabled config cannot be published" + ) + if resume: + if self.mode != "real": + raise SipManagementError( + 409, + "PUBLISH_IN_PROGRESS", + "mock publication cannot be resumed as a Cell delivery", + ) + cells = self._staged_publication_cells( + db, trunk_id, expected_revision, config + ) + else: + cells = self._compatible_cell_records(db, config) + if not cells: + raise SipManagementError( + 409, + "NO_CAPABLE_CELL", + "no healthy Cell supports the Trunk egress pool and codec policy", + ) + now = _now() + db.execute( + "UPDATE trunk_versions SET state='publishing' WHERE trunk_id=? AND revision=?", + (trunk_id, expected_revision), + ) + db.execute( + "DELETE FROM publications WHERE trunk_id=? AND revision=?", + (trunk_id, expected_revision), + ) + for cell in cells: + db.execute( + "INSERT INTO publications(trunk_id,revision,cell_id,status,error_code,updated_at) VALUES(?,?,?,?,?,?)", + ( + trunk_id, + expected_revision, + cell["cell_id"], + "pending", + None, + now, + ), + ) + failures = self._deliver_publication( + trunk_id, expected_revision, config, cells, request_id + ) + result = self._complete_publication( + trunk_id, + expected_revision, + config, + cells, + failures, + action="publish", + actor=actor, + request_id=request_id, + ) + self._raise_delivery_failure("publish", failures) + return result + + def disable_trunk( + self, + trunk_id: str, + *, + expected_revision: int, + actor: str, + request_id: str, + ) -> dict[str, Any]: + trunk_id = _id(trunk_id, "trunk_id") + _integer(expected_revision, "expected_revision") + with self.store.write() as db: + trunk = db.execute( + "SELECT * FROM trunks WHERE trunk_id=?", (trunk_id,) + ).fetchone() + if not trunk: + raise SipManagementError(404, "TRUNK_NOT_FOUND", "trunk does not exist") + if trunk["latest_revision"] != expected_revision: + raise SipManagementError( + 409, + "REVISION_CONFLICT", + "trunk revision changed", + { + "expected_revision": expected_revision, + "current_revision": trunk["latest_revision"], + }, + ) + latest = db.execute( + "SELECT * FROM trunk_versions WHERE trunk_id=? AND revision=?", + (trunk_id, expected_revision), + ).fetchone() + if not latest: + raise SipManagementError( + 409, "VERSION_NOT_FOUND", "trunk version does not exist" + ) + resume = latest["state"] == "publishing" + if resume: + config = _decode_json( + latest["config_json"], "stored Trunk config is invalid" + ) + if config["enabled"]: + raise SipManagementError( + 409, + "PUBLISH_IN_PROGRESS", + "another publication is still in progress", + ) + cells = self._staged_publication_cells( + db, trunk_id, expected_revision, config + ) + revision = expected_revision + else: + if trunk["active_status"] == "disabled": + return self._trunk_summary(db, trunk_id, admin=True) + config = _decode_json( + latest["config_json"], "stored Trunk config is invalid" + ) + config["enabled"] = False + config = _validate_trunk(trunk_id, config) + cells = self._compatible_cell_records(db, config, healthy_only=False) + if self.mode == "real" and not cells: + raise SipManagementError( + 409, + "NO_CAPABLE_CELL", + "no Cell can receive the disable publication", + ) + revision = expected_revision + 1 + now = _now() + db.execute( + "UPDATE trunks SET latest_revision=?,updated_at=? WHERE trunk_id=?", + (revision, now, trunk_id), + ) + db.execute( + "INSERT INTO trunk_versions(trunk_id,revision,config_json,state,created_at,created_by) VALUES(?,?,?,?,?,?)", + (trunk_id, revision, _json(config), "publishing", now, actor), + ) + for cell in cells: + db.execute( + "INSERT INTO publications(trunk_id,revision,cell_id,status,error_code,updated_at) VALUES(?,?,?,?,?,?)", + (trunk_id, revision, cell["cell_id"], "pending", None, now), + ) + failures = self._deliver_publication( + trunk_id, revision, config, cells, request_id + ) + result = self._complete_publication( + trunk_id, + revision, + config, + cells, + failures, + action="disable", + actor=actor, + request_id=request_id, + ) + self._raise_delivery_failure("disable", failures) + return result + + def rollback_trunk( + self, + trunk_id: str, + target_revision: int, + *, + expected_revision: int, + actor: str, + request_id: str, + ) -> dict[str, Any]: + trunk_id = _id(trunk_id, "trunk_id") + _integer(target_revision, "target_revision", minimum=1) + _integer(expected_revision, "expected_revision") + with self.store.write() as db: + trunk = db.execute( + "SELECT * FROM trunks WHERE trunk_id=?", (trunk_id,) + ).fetchone() + if not trunk: + raise SipManagementError(404, "TRUNK_NOT_FOUND", "trunk does not exist") + if trunk["latest_revision"] != expected_revision: + raise SipManagementError( + 409, + "REVISION_CONFLICT", + "trunk revision changed", + { + "expected_revision": expected_revision, + "current_revision": trunk["latest_revision"], + }, + ) + latest = db.execute( + "SELECT state,config_json FROM trunk_versions WHERE trunk_id=? AND revision=?", + (trunk_id, expected_revision), + ).fetchone() + resume = bool(latest and latest["state"] == "publishing") + source = db.execute( + "SELECT config_json FROM trunk_versions WHERE trunk_id=? AND revision=?", + (trunk_id, target_revision), + ).fetchone() + if not source: + raise SipManagementError( + 404, "VERSION_NOT_FOUND", "rollback target does not exist" + ) + config = _decode_json( + source["config_json"], "stored Trunk config is invalid" + ) + if not config["enabled"]: + raise SipManagementError( + 409, "TRUNK_DISABLED", "disabled config cannot be rolled back to" + ) + if resume: + staged = _decode_json( + latest["config_json"], "stored Trunk config is invalid" + ) + if _json(staged) != _json(config): + raise SipManagementError( + 409, + "REVISION_CONFLICT", + "latest publication is for a different rollback target", + ) + cells = self._staged_publication_cells( + db, trunk_id, expected_revision, config + ) + revision = expected_revision + else: + cells = self._compatible_cell_records(db, config) + if not cells: + raise SipManagementError( + 409, "NO_CAPABLE_CELL", "rollback target has no capable Cell" + ) + revision = expected_revision + 1 + now = _now() + db.execute( + "UPDATE trunks SET latest_revision=?,updated_at=? WHERE trunk_id=?", + (revision, now, trunk_id), + ) + db.execute( + "INSERT INTO trunk_versions(trunk_id,revision,config_json,state,created_at,created_by) VALUES(?,?,?,?,?,?)", + (trunk_id, revision, _json(config), "publishing", now, actor), + ) + for cell in cells: + db.execute( + "INSERT INTO publications(trunk_id,revision,cell_id,status,error_code,updated_at) VALUES(?,?,?,?,?,?)", + (trunk_id, revision, cell["cell_id"], "pending", None, now), + ) + failures = self._deliver_publication( + trunk_id, revision, config, cells, request_id + ) + result = self._complete_publication( + trunk_id, + revision, + config, + cells, + failures, + action="rollback", + actor=actor, + request_id=request_id, + details={"target_revision": target_revision}, + ) + self._raise_delivery_failure("rollback", failures) + return result + + def list_publications(self, trunk_id: str) -> list[dict[str, Any]]: + trunk_id = _id(trunk_id, "trunk_id") + with self.store.read() as db: + if not db.execute( + "SELECT 1 FROM trunks WHERE trunk_id=?", (trunk_id,) + ).fetchone(): + raise SipManagementError(404, "TRUNK_NOT_FOUND", "trunk does not exist") + return [ + dict(row) + for row in db.execute( + "SELECT trunk_id,revision,cell_id,status,error_code,updated_at FROM publications WHERE trunk_id=? ORDER BY revision,cell_id", + (trunk_id,), + ).fetchall() + ] + + def list_audit(self, trunk_id: str) -> list[dict[str, Any]]: + trunk_id = _id(trunk_id, "trunk_id") + with self.store.read() as db: + if not db.execute( + "SELECT 1 FROM trunks WHERE trunk_id=?", (trunk_id,) + ).fetchone(): + raise SipManagementError(404, "TRUNK_NOT_FOUND", "trunk does not exist") + return [ + dict(row) + for row in db.execute( + "SELECT audit_id,resource_type,resource_id,action,revision,actor,request_id,details_json,created_at FROM audit_log WHERE resource_type='trunk' AND resource_id=? ORDER BY rowid", + (trunk_id,), + ).fetchall() + ] + + +def _principal_map(raw: Mapping[str, Any], access_class: str) -> dict[str, Principal]: + result: dict[str, Principal] = {} + expected_audience = ADMIN_AUDIENCE if access_class == "admin" else READ_AUDIENCE + for token, value in raw.items(): + if not isinstance(token, str) or not token or not isinstance(value, dict): + raise ConfigurationError(f"invalid {access_class} token map") + subject = value.get("subject") + issuer = value.get("issuer") + audience = value.get("audience") + scopes = value.get("scopes") + trunk_ids = value.get("trunk_ids", []) + if ( + not isinstance(subject, str) + or not subject + or not isinstance(issuer, str) + or not issuer + ): + raise ConfigurationError(f"invalid {access_class} token identity") + if not isinstance(audience, str) or audience != expected_audience: + raise ConfigurationError(f"{access_class} token has wrong audience") + if not isinstance(scopes, list) or not all( + isinstance(item, str) for item in scopes + ): + raise ConfigurationError(f"invalid {access_class} token scopes") + if trunk_ids != "*" and ( + not isinstance(trunk_ids, list) + or not all(isinstance(item, str) for item in trunk_ids) + ): + raise ConfigurationError(f"invalid {access_class} token trunk_ids") + result[token] = Principal( + subject=subject, + issuer=issuer, + audience=audience, + scopes=frozenset(scopes), + trunk_ids=frozenset(trunk_ids if trunk_ids != "*" else ["*"]), + token_class=access_class, + ) + return result + + +def load_token_map(raw: str | None, access_class: str) -> dict[str, Principal]: + try: + value = json.loads(raw or "{}") + except json.JSONDecodeError as exc: + raise ConfigurationError(f"invalid {access_class} token JSON") from exc + if not isinstance(value, dict): + raise ConfigurationError(f"{access_class} token JSON must be an object") + return _principal_map(value, access_class) + + +def validate_token_separation( + admin_tokens: Mapping[str, Principal], + read_tokens: Mapping[str, Principal], + ordinary_tokens: Mapping[str, Any] | None = None, +) -> None: + ordinary_tokens = ordinary_tokens or {} + if set(admin_tokens) & set(read_tokens): + raise ConfigurationError("admin and read-only SIP tokens must be different") + if (set(admin_tokens) | set(read_tokens)) & set(ordinary_tokens): + raise ConfigurationError( + "SIP management tokens must differ from ordinary scheduling tokens" + ) + + +class SipManagementHandler(BaseHTTPRequestHandler): + server_version = "agent-call-sip-management/1" + + def __init__( + self, + request: Any, + client_address: Any, + server: Any, + *, + service: SipManagementService, + admin_tokens: Mapping[str, Principal], + read_tokens: Mapping[str, Principal], + ) -> None: + self.service = service + self.admin_tokens = admin_tokens + self.read_tokens = read_tokens + super().__init__(request, client_address, server) + + def log_message(self, format: str, *_args: Any) -> None: + return + + def _send(self, status: int, payload: Mapping[str, Any]) -> None: + body = json.dumps(payload, ensure_ascii=False).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def _error(self, exc: SipManagementError) -> None: + self._send(exc.status, exc.response()) + + def _path(self) -> list[str]: + path = urlsplit(self.path).path + parts = [unquote(part) for part in path.split("/") if part] + if any(part in {".", ".."} for part in parts): + raise SipManagementError(400, "INVALID_PATH", "path is invalid") + return parts + + def _token(self, tokens: Mapping[str, Principal], scope: str) -> Principal: + header = self.headers.get("Authorization", "") + scheme, _, token = header.partition(" ") + principal = ( + tokens.get(token) + if scheme == "Bearer" and token and not token.isspace() + else None + ) + if principal is None: + raise SipManagementError(401, "UNAUTHORIZED", "valid bearer token required") + if scope not in principal.scopes and "*" not in principal.scopes: + raise SipManagementError(403, "FORBIDDEN", "token lacks required scope") + return principal + + def _request_id(self) -> str: + value = self.headers.get("X-Request-ID") or self.headers.get("X-Request-Id") + if ( + not value + or len(value) > 128 + or value != value.strip() + or any(ord(char) < 32 for char in value) + ): + raise SipManagementError( + 400, "MISSING_REQUEST_ID", "X-Request-ID is required" + ) + return value + + def _expected_revision(self) -> int: + raw = self.headers.get("If-Match") + if raw is None: + raise SipManagementError( + 428, "PRECONDITION_REQUIRED", "If-Match revision is required" + ) + raw = raw.strip().strip('"') + try: + value = int(raw) + except ValueError as exc: + raise SipManagementError( + 400, "INVALID_REVISION", "If-Match must be an integer revision" + ) from exc + if value < 0: + raise SipManagementError( + 400, "INVALID_REVISION", "revision must be non-negative" + ) + return value + + def _body(self) -> Any: + raw_length = self.headers.get("Content-Length") + try: + length = int(raw_length or "-1") + except ValueError as exc: + raise SipManagementError( + 400, "INVALID_CONTENT_LENGTH", "Content-Length is invalid" + ) from exc + if length < 0 or length > MAX_BODY_BYTES: + raise SipManagementError( + 413, "PAYLOAD_TOO_LARGE", "request body is too large" + ) + try: + return json.loads(self.rfile.read(length).decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SipManagementError( + 400, "INVALID_JSON", "request body must be JSON" + ) from exc + + def do_GET(self) -> None: + try: + parts = self._path() + if parts == ["healthz", "live"]: + self._send(200, {"status": "ok", "mode": self.service.mode}) + return + if parts[:2] == ["admin", "v1"]: + if parts == ["admin", "v1", "cells"]: + self._token(self.admin_tokens, "sip.cell.read") + self._send( + 200, + {"mode": self.service.mode, "cells": self.service.list_cells()}, + ) + return + self._token(self.admin_tokens, "sip.trunk.read") + if parts == ["admin", "v1", "trunks"]: + self._send( + 200, + { + "mode": self.service.mode, + "trunks": self.service.list_trunks(), + }, + ) + elif len(parts) == 4 and parts[2] == "trunks": + self._send(200, self.service.get_trunk(parts[3])) + elif ( + len(parts) == 5 + and parts[2] == "trunks" + and parts[4] == "publications" + ): + self._send( + 200, + { + "mode": self.service.mode, + "publications": self.service.list_publications(parts[3]), + }, + ) + elif len(parts) == 5 and parts[2] == "trunks" and parts[4] == "audit": + self._send( + 200, + { + "mode": self.service.mode, + "audit": self.service.list_audit(parts[3]), + }, + ) + else: + raise SipManagementError( + 404, "NOT_FOUND", "endpoint does not exist" + ) + return + if parts[:4] == ["readonly", "v1", "sip", "trunks"]: + principal = self._token(self.read_tokens, "sip.trunk.read") + if len(parts) == 4: + self._send( + 200, + { + "mode": self.service.mode, + "trunks": self.service.list_readonly_trunks( + principal.trunk_ids + ), + }, + ) + elif len(parts) == 5: + self._send( + 200, + self.service.get_readonly_trunk(parts[4], principal.trunk_ids), + ) + else: + raise SipManagementError( + 404, "NOT_FOUND", "endpoint does not exist" + ) + return + raise SipManagementError(404, "NOT_FOUND", "endpoint does not exist") + except SipManagementError as exc: + self._error(exc) + + def do_PUT(self) -> None: + try: + parts = self._path() + if parts[:3] == ["admin", "v1", "cells"] and len(parts) == 4: + principal = self._token(self.admin_tokens, "sip.cell.write") + request_id = self._request_id() + try: + self.service.get_cell(parts[3]) + status = 200 + except SipManagementError as exc: + if exc.code != "CELL_NOT_FOUND": + raise + status = 201 + self._send( + status, + self.service.upsert_cell( + parts[3], + self._body(), + expected_revision=self._expected_revision(), + actor=principal.subject, + request_id=request_id, + ), + ) + return + if parts[:3] == ["admin", "v1", "trunks"] and len(parts) == 4: + principal = self._token(self.admin_tokens, "sip.trunk.write") + request_id = self._request_id() + try: + self.service.get_trunk(parts[3]) + status = 200 + except SipManagementError as exc: + if exc.code != "TRUNK_NOT_FOUND": + raise + status = 201 + self._send( + status, + self.service.upsert_trunk( + parts[3], + self._body(), + expected_revision=self._expected_revision(), + actor=principal.subject, + request_id=request_id, + ), + ) + return + raise SipManagementError(404, "NOT_FOUND", "endpoint does not exist") + except SipManagementError as exc: + self._error(exc) + + def do_POST(self) -> None: + try: + parts = self._path() + if len(parts) == 5 and parts[:3] == ["admin", "v1", "trunks"]: + action = parts[4] + principal = self._token( + self.admin_tokens, + { + "publish": "sip.trunk.publish", + "disable": "sip.trunk.write", + "rollback": "sip.trunk.rollback", + }.get(action, "sip.trunk.write"), + ) + request_id = self._request_id() + expected = self._expected_revision() + if action == "publish": + result = self.service.publish_trunk( + parts[3], + expected_revision=expected, + actor=principal.subject, + request_id=request_id, + ) + elif action == "disable": + result = self.service.disable_trunk( + parts[3], + expected_revision=expected, + actor=principal.subject, + request_id=request_id, + ) + elif action == "rollback": + body = self._body() + if not isinstance(body, dict) or set(body) != {"target_revision"}: + raise SipManagementError( + 400, "INVALID_ROLLBACK", "rollback requires target_revision" + ) + result = self.service.rollback_trunk( + parts[3], + body["target_revision"], + expected_revision=expected, + actor=principal.subject, + request_id=request_id, + ) + else: + raise SipManagementError( + 404, "NOT_FOUND", "endpoint does not exist" + ) + self._send(200, result) + return + if parts[:4] == ["readonly", "v1", "sip", "trunks"]: + raise SipManagementError( + 405, "READ_ONLY", "SaaS Trunk API is read-only" + ) + raise SipManagementError(404, "NOT_FOUND", "endpoint does not exist") + except SipManagementError as exc: + self._error(exc) + + +class _Server(ThreadingHTTPServer): + daemon_threads = True + allow_reuse_address = True + + +def make_server( + service: SipManagementService, + host: str = "127.0.0.1", + port: int = 8090, + *, + admin_tokens: Mapping[str, Principal], + read_tokens: Mapping[str, Principal], +) -> ThreadingHTTPServer: + if not admin_tokens or not read_tokens: + raise ConfigurationError("admin and read-only SIP token maps are required") + validate_token_separation(admin_tokens, read_tokens) + handler = partial( + SipManagementHandler, + service=service, + admin_tokens=admin_tokens, + read_tokens=read_tokens, + ) + return _Server((host, port), handler) + + +def build_from_env() -> tuple[SipManagementService, ThreadingHTTPServer]: + admin_tokens = load_token_map(os.environ.get("SIP_ADMIN_TOKENS"), "admin") + read_tokens = load_token_map(os.environ.get("SIP_READ_TOKENS"), "read") + ordinary_tokens = _json_object( + os.environ.get("HTTP_TOKENS"), "HTTP_TOKENS must be an object" + ) + if not admin_tokens or not read_tokens: + raise ConfigurationError("SIP_ADMIN_TOKENS and SIP_READ_TOKENS are required") + validate_token_separation(admin_tokens, read_tokens, ordinary_tokens) + mode = os.environ.get("SIP_MANAGEMENT_MODE", "mock") + publisher: MtlsCellPublisher | None = None + if mode == "real": + ca_file = os.environ.get("SIP_CELL_TLS_CA_FILE") + cert_file = os.environ.get("SIP_CELL_TLS_CERT_FILE") + key_file = os.environ.get("SIP_CELL_TLS_KEY_FILE") + if not ca_file or not cert_file or not key_file: + raise ConfigurationError( + "real mode requires SIP_CELL_TLS_CA_FILE, SIP_CELL_TLS_CERT_FILE and SIP_CELL_TLS_KEY_FILE" + ) + try: + timeout = float(os.environ.get("SIP_CELL_PUBLISH_TIMEOUT_SECONDS", "15")) + except ValueError as exc: + raise ConfigurationError( + "SIP_CELL_PUBLISH_TIMEOUT_SECONDS must be a number" + ) from exc + publisher = MtlsCellPublisher( + ca_file=ca_file, + cert_file=cert_file, + key_file=key_file, + timeout=timeout, + ) + service = SipManagementService( + os.environ.get("SIP_MANAGEMENT_DB", "data/sip-management.sqlite3"), + mode=mode, + publisher=publisher, + ) + try: + port = int(os.environ.get("SIP_MANAGEMENT_PORT", "8090")) + except ValueError as exc: + service.close() + raise ConfigurationError("SIP_MANAGEMENT_PORT must be an integer") from exc + server = make_server( + service, + os.environ.get("SIP_MANAGEMENT_HOST", "127.0.0.1"), + port, + admin_tokens=admin_tokens, + read_tokens=read_tokens, + ) + return service, server + + +__all__ = [ + "ADMIN_AUDIENCE", + "CODEC_TO_ASTERISK", + "READ_AUDIENCE", + "CellPublishError", + "ConfigurationError", + "MtlsCellPublisher", + "Principal", + "SipManagementError", + "SipManagementService", + "SipManagementStore", + "asterisk_codec_allow", + "build_from_env", + "load_token_map", + "make_server", + "validate_token_separation", +] diff --git a/agent_call/sip_management_main.py b/agent_call/sip_management_main.py new file mode 100644 index 0000000..595612b --- /dev/null +++ b/agent_call/sip_management_main.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +from .sip_management import ConfigurationError, build_from_env + + +def main() -> None: + service, server = build_from_env() + try: + server.serve_forever() + except KeyboardInterrupt: + return + finally: + server.shutdown() + server.server_close() + service.close() + + +if __name__ == "__main__": + try: + main() + except ConfigurationError as exc: + raise SystemExit(f"configuration error: {exc}") from exc diff --git a/agent_call/sip_readonly.py b/agent_call/sip_readonly.py new file mode 100644 index 0000000..93f8cf4 --- /dev/null +++ b/agent_call/sip_readonly.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import json +import ssl +from http.client import HTTPSConnection +from typing import Any +from urllib.parse import quote, urlsplit + +MAX_RESPONSE_BYTES = 64 * 1024 + + +class SipReadonlyError(Exception): + def __init__(self, code: str, detail: str, *, retryable: bool = False) -> None: + super().__init__(detail) + self.code = code + self.detail = detail + self.retryable = retryable + + +class SipReadonlyClient: + def __init__( + self, + base_url: str, + token: str, + *, + ca_file: str | None = None, + timeout: float = 5.0, + ) -> None: + if not token or len(token) > 4096 or any(ord(char) < 32 for char in token): + raise ValueError("SIP read-only token is invalid") + parsed = urlsplit(base_url.rstrip("/")) + try: + port = parsed.port + except ValueError as exc: + raise ValueError("SIP read-only URL has an invalid port") from exc + if ( + parsed.scheme != "https" + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + or (port is not None and not 1 <= port <= 65535) + ): + raise ValueError( + "SIP read-only URL must be HTTPS without credentials or query data" + ) + if not timeout > 0: + raise ValueError("SIP_READONLY_TIMEOUT_SECONDS must be positive") + try: + context = ssl.create_default_context(cafile=ca_file) + except (OSError, ssl.SSLError) as exc: + raise ValueError("SIP read-only CA file could not be loaded") from exc + context.minimum_version = ssl.TLSVersion.TLSv1_2 + context.check_hostname = True + self._host = parsed.hostname + self._port = port or 443 + self._base_path = parsed.path.rstrip("/") + self._token = token + self._context = context + self._timeout = timeout + + def get_trunk(self, trunk_id: str) -> dict[str, Any]: + path = f"{self._base_path}/readonly/v1/sip/trunks/{quote(trunk_id, safe='')}" + connection: HTTPSConnection | None = None + try: + connection = HTTPSConnection( + self._host, + self._port, + context=self._context, + timeout=self._timeout, + ) + connection.request( + "GET", + path, + headers={ + "Accept": "application/json", + "Authorization": f"Bearer {self._token}", + }, + ) + response = connection.getresponse() + status = response.status + raw = response.read(MAX_RESPONSE_BYTES + 1) + except (OSError, ValueError) as exc: + raise SipReadonlyError( + "SIP_DIRECTORY_UNAVAILABLE", + "SIP read-only directory is unavailable", + retryable=True, + ) from exc + finally: + if connection is not None: + connection.close() + if status == 404: + raise SipReadonlyError( + "SIP_TRUNK_NOT_PUBLISHED", "SIP Trunk is not published" + ) + if status < 200 or status >= 300: + raise SipReadonlyError( + "SIP_DIRECTORY_UNAVAILABLE", + "SIP read-only directory rejected the request", + retryable=status >= 500 or status in {408, 429}, + ) + if len(raw) > MAX_RESPONSE_BYTES: + raise SipReadonlyError( + "SIP_DIRECTORY_INVALID", "SIP read-only response is too large" + ) + try: + payload = json.loads(raw.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError) as exc: + raise SipReadonlyError( + "SIP_DIRECTORY_INVALID", "SIP read-only response is invalid JSON" + ) from exc + if not isinstance(payload, dict) or payload.get("trunk_id") != trunk_id: + raise SipReadonlyError( + "SIP_DIRECTORY_INVALID", "SIP read-only response has the wrong Trunk" + ) + if payload.get("status") != "published" or not isinstance( + payload.get("config"), dict + ): + raise SipReadonlyError( + "SIP_TRUNK_NOT_PUBLISHED", "SIP Trunk is not published" + ) + config = payload["config"] + enabled = config.get("enabled") + if not isinstance(enabled, bool) or not enabled: + raise SipReadonlyError("SIP_TRUNK_NOT_PUBLISHED", "SIP Trunk is disabled") + for field in ( + "codec_profile", + "caller_ids", + "dial_prefix", + "egress_pool_id", + "max_concurrency", + "max_cps", + ): + if field not in config: + raise SipReadonlyError( + "SIP_DIRECTORY_INVALID", "SIP Trunk config is incomplete" + ) + return payload + + +__all__ = ["SipReadonlyClient", "SipReadonlyError"] diff --git a/compose.yaml b/compose.yaml index 6fb1876..74ff5e5 100644 --- a/compose.yaml +++ b/compose.yaml @@ -38,6 +38,12 @@ services: BROKER_MODE: "rabbit" RABBITMQ_URL: "${RABBITMQ_URL:?Set RABBITMQ_URL}" HTTP_TOKENS: "${HTTP_TOKENS:?Set HTTP_TOKENS}" + AGENT_CALL_SIP_READONLY_URL: "${AGENT_CALL_SIP_READONLY_URL:-}" + AGENT_CALL_SIP_READONLY_TOKEN: "${AGENT_CALL_SIP_READONLY_TOKEN:-}" + AGENT_CALL_SIP_READONLY_CA_FILE: >- + ${AGENT_CALL_SIP_READONLY_CA_FILE:-} + AGENT_CALL_SIP_READONLY_TIMEOUT_SECONDS: >- + ${AGENT_CALL_SIP_READONLY_TIMEOUT_SECONDS:-5} volumes: - agent_call_data:/data read_only: false @@ -50,6 +56,59 @@ services: driver: json-file options: {max-size: "10m", max-file: "3"} + sip-management: + build: + context: . + dockerfile: Dockerfile + profiles: ["sip-management"] + restart: unless-stopped + init: true + entrypoint: ["python", "-m", "agent_call.sip_management_main"] + ports: + - "127.0.0.1:${SIP_MANAGEMENT_PORT:-18090}:8090" + environment: + SIP_MANAGEMENT_MODE: "${SIP_MANAGEMENT_MODE:-mock}" + SIP_MANAGEMENT_HOST: "0.0.0.0" + SIP_MANAGEMENT_PORT: "8090" + SIP_MANAGEMENT_DB: "/data/sip-management.sqlite3" + SIP_ADMIN_TOKENS: "${SIP_ADMIN_TOKENS:?Set SIP_ADMIN_TOKENS}" + SIP_READ_TOKENS: "${SIP_READ_TOKENS:?Set SIP_READ_TOKENS}" + SIP_CELL_TLS_CA_FILE: "${SIP_CELL_TLS_CA_FILE:-/run/sip-tls/ca.pem}" + SIP_CELL_TLS_CERT_FILE: >- + ${SIP_CELL_TLS_CERT_FILE:-/run/sip-tls/client.pem} + SIP_CELL_TLS_KEY_FILE: >- + ${SIP_CELL_TLS_KEY_FILE:-/run/sip-tls/client-key.pem} + SIP_CELL_PUBLISH_TIMEOUT_SECONDS: >- + ${SIP_CELL_PUBLISH_TIMEOUT_SECONDS:-15} + PYTHONDONTWRITEBYTECODE: "1" + volumes: + - sip_management_data:/data + - "${SIP_CELL_TLS_DIR:-./secrets/sip-management}:/run/sip-tls:ro" + tmpfs: + - /tmp:rw,noexec,nosuid,size=16m + read_only: true + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 128 + mem_limit: 256m + cpus: 1 + healthcheck: + test: + - CMD + - python + - -c + - >- + import urllib.request; + urllib.request.urlopen( + 'http://127.0.0.1:8090/healthz/live', timeout=2 + ) + interval: 5s + timeout: 3s + retries: 12 + logging: + driver: json-file + options: {max-size: "10m", max-file: "3"} + asr-web: build: context: ./services/asr-web @@ -86,3 +145,4 @@ services: volumes: rabbitmq_data: agent_call_data: + sip_management_data: diff --git a/deploy/asterisk.example.json b/deploy/asterisk.example.json index e7302f6..a38112e 100644 --- a/deploy/asterisk.example.json +++ b/deploy/asterisk.example.json @@ -3,6 +3,6 @@ "transport": "udp", "local_net": "", "ari_bind": "127.0.0.1", - "primary": {"host": "", "port": 5060, "auth_mode": "ip", "username": "", "register": false}, - "backup": {"host": "", "port": 5060, "auth_mode": "ip", "username": "", "register": false} + "primary": {"host": "", "port": 5060, "auth_mode": "ip", "username": "", "register": false, "codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"}}, + "backup": {"host": "", "port": 5060, "auth_mode": "ip", "username": "", "register": false, "codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"}} } diff --git a/deploy/asterisk_bootstrap.sh b/deploy/asterisk_bootstrap.sh index 5876739..06bafac 100644 --- a/deploy/asterisk_bootstrap.sh +++ b/deploy/asterisk_bootstrap.sh @@ -76,6 +76,7 @@ if [[ "$ready" != 1 ]]; then echo 'Asterisk did not become CLI-ready; inspect docker logs' >&2 exit 1 fi +docker exec agent-call-asterisk asterisk -rx 'pjsip set logger on' docker exec agent-call-asterisk asterisk -rx 'pjsip show endpoint provider-primary' install -d -m 0750 /var/lib/agent-call date -u +%Y-%m-%dT%H:%M:%SZ >/var/lib/agent-call/bootstrap.done diff --git a/deploy/cell-agent.env.example b/deploy/cell-agent.env.example new file mode 100644 index 0000000..f9e51fb --- /dev/null +++ b/deploy/cell-agent.env.example @@ -0,0 +1,10 @@ +# Real Cell Agent settings. Certificate files are injected by the host PKI/secret manager. +CELL_ID=cell-beijing-01 +CELL_AGENT_HOST=10.0.0.21 +CELL_AGENT_PORT=9443 +CELL_AGENT_DB=/var/lib/agent-call/cell-agent.sqlite3 +CELL_ASTERISK_CONFIG_DIR=/etc/asterisk/pjsip.d +CELL_ASTERISK_CLI=/usr/sbin/asterisk +CELL_TLS_CA_FILE=/etc/agent-call/cell-tls/ca.pem +CELL_TLS_CERT_FILE=/etc/agent-call/cell-tls/server.pem +CELL_TLS_KEY_FILE=/etc/agent-call/cell-tls/server-key.pem diff --git a/deploy/mock.env.example b/deploy/mock.env.example index d6244b6..af320cc 100644 --- a/deploy/mock.env.example +++ b/deploy/mock.env.example @@ -2,8 +2,13 @@ RABBITMQ_DEFAULT_USER=agent_call_mock RABBITMQ_DEFAULT_PASS=replace-before-start RABBITMQ_URL=amqp://agent_call_mock:replace-before-start@rabbitmq:5672/%2f +ASR_WEB_TOKEN=local-asr-test # JSON map: token -> tenant IDs and scopes. Inject through a secret manager in real deployments. HTTP_TOKENS={"local":{"tenant_ids":["tenant-demo"],"scopes":["outbound.read","outbound.control","recording.upload","recording.complete"]}} +# Independent SIP management credentials; synthetic local values only. +SIP_MANAGEMENT_PORT=18090 +SIP_ADMIN_TOKENS={"admin-local":{"subject":"ops","issuer":"local","audience":"agent-call.sip-admin","scopes":["*"],"trunk_ids":"*"}} +SIP_READ_TOKENS={"saas-local":{"subject":"saas","issuer":"local","audience":"agent-call.sip-read","scopes":["sip.trunk.read"],"trunk_ids":["trunk-mock"]}} AGENT_CALL_MODE=mock BROKER_MODE=rabbit AGENT_CALL_PORT=18080 diff --git a/deploy/render_asterisk.py b/deploy/render_asterisk.py index 53da0d2..206cd79 100755 --- a/deploy/render_asterisk.py +++ b/deploy/render_asterisk.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Render an explicit, outbound-only UDP/PCMA baseline. Does not start services.""" +"""Render an explicit, outbound-only UDP config from the Trunk codec profile.""" import argparse import ipaddress @@ -12,6 +12,7 @@ import tempfile from pathlib import Path PUBLIC_IP = "123.56.71.98" +CODEC_TO_ASTERISK = {"PCMA": "alaw", "PCMU": "ulaw"} def scalar(value, name, secret=False): @@ -29,6 +30,32 @@ def scalar(value, name, secret=False): return value +def codec_allow(name, data): + profile = data.get("codec_profile") + if not isinstance(profile, dict): + raise ValueError( # noqa: TRY004 - render() exposes one config-error type + f"{name}.codec_profile must be explicit" + ) + allowed = profile.get("allowed") + if not isinstance(allowed, list) or not allowed: + raise ValueError(f"{name}.codec_profile.allowed must be a non-empty list") + normalized = [] + for codec in allowed: + if not isinstance(codec, str) or codec.upper() not in CODEC_TO_ASTERISK: + raise ValueError(f"{name}.codec_profile contains an unsupported codec") + codec = codec.upper() + if codec in normalized: + raise ValueError(f"{name}.codec_profile contains a duplicate codec") + normalized.append(codec) + preferred = profile.get("preferred", normalized[0]) + if not isinstance(preferred, str) or preferred.upper() not in normalized: + raise ValueError(f"{name}.codec_profile.preferred must be allowed") + ordered = [preferred.upper()] + [ + codec for codec in normalized if codec != preferred.upper() + ] + return ",".join(CODEC_TO_ASTERISK[codec] for codec in ordered) + + def endpoint(name, data, env): host = scalar(data.get("host"), name + ".host") if ( @@ -48,7 +75,8 @@ def endpoint(name, data, env): registration = data.get("register", False) if not isinstance(registration, bool) or registration and mode != "digest": raise ValueError("registration requires explicit digest authentication") - text = f"[{name}]\ntype=endpoint\ntransport=transport-udp\ncontext=deny-inbound\ndisallow=all\nallow=alaw\ndirect_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\naors={name}-aor\n" + allow = codec_allow(name, data) + text = f"[{name}]\ntype=endpoint\ntransport=transport-udp\ncontext=deny-inbound\ndisallow=all\nallow={allow}\ndirect_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\naors={name}-aor\n" from_user = data.get("from_user") if from_user is not None: if not isinstance(from_user, str) or not re.fullmatch( @@ -69,12 +97,16 @@ def endpoint(name, data, env): user = scalar(data.get("username"), name + ".username") if not re.fullmatch(r"[A-Za-z0-9_.+-]+", user): raise ValueError("SIP username must be a plain user identifier") - password_key = "SIP_" + name.removeprefix("provider-").upper() + "_PASSWORD" - password = scalar(env.get(password_key), password_key, secret=True) + credential_env_name = ( + "SIP_" + name.removeprefix("provider-").upper() + "_PASSWORD" + ) + credential_value = scalar( + env.get(credential_env_name), credential_env_name, secret=True + ) text += f"outbound_auth={name}-auth\n" if from_user is None: text += f"from_user={user}\n" - auth = f"\n[{name}-auth]\ntype=auth\nauth_type=userpass\nusername={user}\npassword={password}\n" + auth = f"\n[{name}-auth]\ntype=auth\nauth_type=userpass\nusername={user}\npassword={credential_value}\n" if registration: auth += f"\n[{name}-registration]\ntype=registration\ntransport=transport-udp\noutbound_auth={name}-auth\nserver_uri=sip:{host}:{port}\nclient_uri=sip:{user}@{host}:{port}\nretry_interval=60\n" text += ( diff --git a/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md b/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md index f9b0407..4afdb4a 100644 --- a/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md +++ b/docs/SaaS交互_OpenAPI与MQ契约规划_v0.1.md @@ -85,7 +85,7 @@ ## 4. HTTP OpenAPI 规划 -两个服务分别使用自己的 Base URL,不共享服务实现;下表路径均为相对各自服务根路径。 +业务两个服务分别使用自己的 Base URL,不共享服务实现;SIP 管理后台另有独立管理 Base URL,不纳入下表普通业务路径。下表路径均为相对对应服务根路径。 | 提供方 | 方法与路径 | 最小权限 | 结果 | | --- | --- | --- | --- | @@ -635,7 +635,7 @@ HTTP 错误码与 MQ reason_code 共用词汇但不是一一映射;MQ 没有 H | 核验项 | 已接受规则/待核验参数 | 责任方 | | --- | --- | --- | -| 接口归属及域名 | 两份 OpenAPI;SaaS 是否已有可复用资产服务、测试 Base URL | 用户、SaaS | +| 接口归属及域名 | 业务两份 OpenAPI;SIP 管理后台使用独立管理 Base URL,SaaS 仅访问其只读 Trunk 目录/状态 | 用户、SaaS、运维 | | 服务身份 | 现有服务令牌体系优先;issuer/audience/scope、租户映射、轮换和 mTLS | 双方、运维 | | 任务绑定与版本 | 初始版本 1、CAS 控制、停止不可恢复、可信任务归属来源 | 用户、SaaS | | 租户绑定与传输(已确认) | 一对一/不原地变更/保留期不复用,224字节预算及超限停发;Mock注册可先开发,真实租户资料和传输能力接入时核验 | SaaS、运维 | @@ -645,6 +645,7 @@ HTTP 错误码与 MQ reason_code 共用词汇但不是一一映射;MQ 没有 H | 执行幂等 | 新增 execution_id,与 command_id 分离;业务重新外呼许可及去重保留 | 用户、SaaS | | 停止语义 | drain/hangup 显式选择;挂断权限与多 Cell 生效判据 | 用户、SaaS | | 线路/AI 配置 | route/caller/agent 引用及同步来源;LLM/TTS 新规范、失败兜底 | 用户、供应方 | +| Asterisk/SIP 管理后台与 SaaS 只读目录(Mock 已实现,生产待核验) | 不纳入普通业务七条路径;由独立管理后台统一写入并发布多机器 Trunk/codec_profile、主叫/被叫规则、出口池、并发/CPS,提供版本、CAS、审计、校验、发布、停用和回滚;SaaS 仅读取脱敏 Trunk 配置/能力/版本/健康状态;管理写接口、SaaS 只读接口和普通调度接口隔离认证凭据、issuer/audience/权限域,普通调度 Token 必须拒绝管理写接口 | 用户、SaaS、运维 | | MQ 环境 | 租户独立命令队列已确认;采用 `agent-call` 命名空间、direct 命令 exchange、`agent-call.tenant.{tenant_key}.call.execute` 路由及 `tenant_key` 原样透传;冻结精确绑定、生命周期、队列数上限、quorum/HA、ACL、重试/DLQ 与死信可靠性 | 运维、双方 | | 租户公平与背压 | 公平调度架构已确认;冻结轮转批量/周期、活跃队列发现、权重、prefetch、接收窗口、并发/CPS、发布速率/积压上限、拒绝发布策略、多实例协调及等待指标 | 用户、双方、运维 | | 保底与借用 | 默认不承诺固定开始时限;如需 SLA,确认保底资源、借用/归还边界及可满足的租户总承诺 | 用户、业务/运维 | diff --git a/docs/contracts/cell-agent.openapi.yaml b/docs/contracts/cell-agent.openapi.yaml new file mode 100644 index 0000000..f929a1b --- /dev/null +++ b/docs/contracts/cell-agent.openapi.yaml @@ -0,0 +1,225 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Cell Agent API + version: 1.0.0 + description: >- + Restricted mTLS API used by the SIP management backend to deliver a + versioned Trunk snapshot to one voice Cell. The Cell validates the SHA-256 + snapshot, applies it with an atomic file replacement, reloads Asterisk, + restores the previous file on reload failure, and returns applied only + after the reload succeeds. A disabled snapshot removes the Cell-local + Trunk fragment and reloads Asterisk. +servers: + - url: https://cell.internal:9443 + description: Cell management network only +tags: + - name: health + - name: trunk-apply +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Cell Agent is alive + content: + application/json: + schema: {$ref: '#/components/schemas/Health'} + /v1/sip/trunks/{trunk_id}/apply: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [trunk-apply] + operationId: applyTrunk + security: [{CellManagementMtls: []}] + parameters: + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/Publication'} + responses: + '200': + description: Asterisk has loaded the exact snapshot + content: + application/json: + schema: {$ref: '#/components/schemas/Acknowledgement'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '409': + description: Revision is stale or has a gap + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + '502': + description: Asterisk rejected the apply or reload + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + /v1/sip/trunks/{trunk_id}/state: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [trunk-apply] + operationId: getTrunkState + security: [{CellManagementMtls: []}] + responses: + '200': + description: Durable Cell apply state + content: + application/json: + schema: {$ref: '#/components/schemas/State'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + CellManagementMtls: + type: mutualTLS + description: Management backend client certificate signed by the Cell CA. + parameters: + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + schemas: + Health: + type: object + additionalProperties: false + required: [status, mode, cell_id] + properties: + status: {type: string, const: ok} + mode: {type: string, const: real} + cell_id: {type: string} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register, credential_ref] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: [string, 'null'] + description: Secret-store reference only; plaintext is forbidden. + TrunkConfig: + type: object + additionalProperties: false + required: + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: {type: string} + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + Publication: + type: object + additionalProperties: false + required: [mode, cell_id, trunk_id, revision, config, config_sha256] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + config: {$ref: '#/components/schemas/TrunkConfig'} + config_sha256: + type: string + pattern: '^[0-9a-f]{64}$' + Acknowledgement: + type: object + additionalProperties: false + required: + [mode, cell_id, trunk_id, revision, config_sha256, status, idempotent] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + config_sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + status: {type: string, const: applied} + idempotent: {type: boolean} + State: + type: object + additionalProperties: false + required: + [ + mode, + cell_id, + trunk_id, + desired_revision, + applied_revision, + status, + updated_at, + ] + properties: + mode: {type: string, const: real} + cell_id: {type: string} + trunk_id: {type: string} + desired_revision: {type: integer, minimum: 1} + applied_revision: {type: integer, minimum: 0} + status: {type: string, enum: [applying, applied, failed]} + last_error: {type: [string, 'null']} + updated_at: {type: string, format: date-time} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} + responses: + BadRequest: + description: Invalid publication or hash + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Certificate or Cell identity is not authorized + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: State does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} diff --git a/docs/contracts/sip-management.openapi.yaml b/docs/contracts/sip-management.openapi.yaml new file mode 100644 index 0000000..0bf0bd6 --- /dev/null +++ b/docs/contracts/sip-management.openapi.yaml @@ -0,0 +1,564 @@ +# yaml-language-server: $schema=https://json-schema.org/draft/2020-12/schema +openapi: 3.1.0 +info: + title: agent-call Asterisk/SIP Management API + version: 1.0.0 + description: >- + Independent Asterisk/SIP management backend. Admin write operations are + separate from the SaaS read-only Trunk directory and from ordinary + scheduling APIs. In mock mode publication records are intents only. In + real mode a publication is successful only after every selected Cell Agent + returns a matching mTLS acknowledgement. +servers: + - url: https://sip-admin.internal + description: Restricted operator management network + - url: https://sip-read.internal + description: SaaS read-only service network +tags: + - name: health + - name: admin-trunks + - name: admin-cells + - name: saas-readonly +paths: + /healthz/live: + get: + tags: [health] + operationId: live + responses: + '200': + description: Service is alive + content: + application/json: + schema: + $ref: '#/components/schemas/Health' + /admin/v1/trunks: + get: + tags: [admin-trunks] + operationId: listAdminTrunks + security: [{SipAdminBearer: []}] + responses: + '200': + description: All Trunks, including unpublished revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + '403': {$ref: '#/components/responses/Forbidden'} + /admin/v1/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: getAdminTrunk + security: [{SipAdminBearer: []}] + responses: + '200': + description: Trunk configuration and revisions + content: + application/json: + schema: + $ref: '#/components/schemas/AdminTrunk' + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + put: + tags: [admin-trunks] + operationId: createTrunkRevision + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrunkConfig' + responses: + '200': + description: New draft revision for an existing Trunk + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '201': + description: New Trunk with its first draft revision + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '400': {$ref: '#/components/responses/BadRequest'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publish: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: publishTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: >- + Published revision after all selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/disable: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: disableTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + responses: + '200': + description: Trunk disabled after selected Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/rollback: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + post: + tags: [admin-trunks] + operationId: rollbackTrunk + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: + type: object + additionalProperties: false + required: [target_revision] + properties: + target_revision: {type: integer, minimum: 1} + responses: + '200': + description: >- + New revision copied from the target after Cell acknowledgements + content: + application/json: + schema: {$ref: '#/components/schemas/AdminTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /admin/v1/trunks/{trunk_id}/publications: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkPublications + security: [{SipAdminBearer: []}] + responses: + '200': + description: Per-Cell publication intents + content: + application/json: + schema: + type: object + required: [mode, publications] + properties: + mode: {$ref: '#/components/schemas/Mode'} + publications: + type: array + items: {$ref: '#/components/schemas/Publication'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/trunks/{trunk_id}/audit: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [admin-trunks] + operationId: listTrunkAudit + security: [{SipAdminBearer: []}] + responses: + '200': + description: Immutable management audit entries + content: + application/json: + schema: + type: object + required: [mode, audit] + properties: + mode: {$ref: '#/components/schemas/Mode'} + audit: + type: array + items: {$ref: '#/components/schemas/AuditEntry'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} + /admin/v1/cells: + get: + tags: [admin-cells] + operationId: listCells + security: [{SipAdminBearer: []}] + responses: + '200': + description: Registered multi-machine voice Cells + content: + application/json: + schema: + type: object + required: [mode, cells] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cells: + type: array + items: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + /admin/v1/cells/{cell_id}: + parameters: + - {$ref: '#/components/parameters/CellId'} + put: + tags: [admin-cells] + operationId: registerCell + security: [{SipAdminBearer: []}] + parameters: + - {$ref: '#/components/parameters/IfMatch'} + - {$ref: '#/components/parameters/RequestId'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/CellConfig'} + responses: + '200': + description: Updated Cell revision + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '201': + description: Registered Cell + content: + application/json: + schema: {$ref: '#/components/schemas/Cell'} + '401': {$ref: '#/components/responses/Unauthorized'} + '409': {$ref: '#/components/responses/Conflict'} + /readonly/v1/sip/trunks: + get: + tags: [saas-readonly] + operationId: listAuthorizedTrunks + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published Trunks authorized for this SaaS principal + content: + application/json: + schema: + $ref: '#/components/schemas/ReadonlyTrunkList' + '401': {$ref: '#/components/responses/Unauthorized'} + /readonly/v1/sip/trunks/{trunk_id}: + parameters: + - {$ref: '#/components/parameters/TrunkId'} + get: + tags: [saas-readonly] + operationId: getAuthorizedTrunk + security: [{SaasTrunkReadBearer: []}] + responses: + '200': + description: Published, sanitized Trunk metadata + content: + application/json: + schema: {$ref: '#/components/schemas/ReadonlyTrunk'} + '401': {$ref: '#/components/responses/Unauthorized'} + '404': {$ref: '#/components/responses/NotFound'} +components: + securitySchemes: + SipAdminBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated operator/backend credential for SIP management writes. It is + not accepted by the SaaS read-only API or ordinary scheduling API. + SaasTrunkReadBearer: + type: http + scheme: bearer + bearerFormat: opaque + description: >- + Dedicated SaaS read-only credential. It cannot publish, modify, disable, + rollback, or access Cell management. + parameters: + TrunkId: + name: trunk_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + CellId: + name: cell_id + in: path + required: true + schema: {type: string, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$'} + IfMatch: + name: If-Match + in: header + required: true + description: Exact latest revision required for CAS; quotes are accepted. + schema: {type: integer, minimum: 0} + RequestId: + name: X-Request-ID + in: header + required: true + schema: {type: string, minLength: 1, maxLength: 128} + responses: + BadRequest: + description: Invalid configuration or request + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Unauthorized: + description: Missing or wrong authentication domain + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Forbidden: + description: Credential lacks the required scope + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + Conflict: + description: CAS conflict or no compatible Cell + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + NotFound: + description: Resource is not visible or does not exist + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} + schemas: + Mode: + type: string + enum: [mock, real] + Health: + type: object + additionalProperties: false + required: [status, mode] + properties: + status: {type: string, const: ok} + mode: {$ref: '#/components/schemas/Mode'} + CodecProfile: + type: object + additionalProperties: false + required: [allowed, preferred] + properties: + allowed: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + preferred: {type: string, enum: [PCMA, PCMU]} + SipConfig: + type: object + additionalProperties: false + required: [host, port, transport, auth_mode, register] + properties: + host: {type: string, minLength: 1, maxLength: 253} + port: {type: integer, minimum: 1, maximum: 65535} + transport: {type: string, enum: [udp, tcp, tls]} + auth_mode: {type: string, enum: [ip, digest]} + register: {type: boolean} + credential_ref: + type: string + writeOnly: true + description: >- + Secret-store reference only; plaintext credentials are forbidden. + TrunkConfig: + type: object + additionalProperties: false + required: + - display_name + - enabled + - sip + - codec_profile + - caller_ids + - dial_prefix + - egress_pool_id + - max_concurrency + - max_cps + properties: + display_name: {type: string, minLength: 1, maxLength: 256} + enabled: {type: boolean} + sip: {$ref: '#/components/schemas/SipConfig'} + codec_profile: {$ref: '#/components/schemas/CodecProfile'} + caller_ids: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, minLength: 1, maxLength: 128} + dial_prefix: {type: string, maxLength: 32} + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + max_concurrency: {type: integer, minimum: 1} + max_cps: {type: integer, minimum: 1} + CellConfig: + type: object + additionalProperties: false + required: [egress_pool_id, codec_capabilities, status, max_concurrency] + properties: + egress_pool_id: + type: string + pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$' + codec_capabilities: + type: array + minItems: 1 + uniqueItems: true + items: {type: string, enum: [PCMA, PCMU]} + status: {type: string, enum: [healthy, draining, disabled]} + max_concurrency: {type: integer, minimum: 1} + management_url: + type: string + format: uri + pattern: '^https://' + description: >- + mTLS Cell Agent endpoint. Required when mode=real; credentials and + query strings are not allowed. + RevisionInfo: + type: object + additionalProperties: false + required: [revision, state, created_at, created_by] + properties: + revision: {type: integer, minimum: 1} + state: {type: string, enum: [draft, publishing, published, superseded]} + created_at: {type: string, format: date-time} + created_by: {type: string} + AdminTrunk: + type: object + required: + - mode + - trunk_id + - latest_revision + - active_revision + - status + - compatible_cell_ids + - latest + - active + - versions + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + latest_revision: {type: integer, minimum: 1} + active_revision: {type: integer, minimum: 0} + status: {type: string, enum: [draft, published, disabled]} + updated_at: {type: string, format: date-time} + compatible_cell_ids: {type: array, items: {type: string}} + latest: {$ref: '#/components/schemas/TrunkView'} + active: {$ref: '#/components/schemas/TrunkView'} + versions: + type: array + items: + $ref: '#/components/schemas/RevisionInfo' + TrunkView: + allOf: + - {$ref: '#/components/schemas/TrunkConfig'} + - type: object + properties: + trunk_id: {type: string} + credential_configured: {type: boolean} + asterisk_allow: + type: array + items: {type: string, enum: [alaw, ulaw]} + ReadonlyTrunk: + type: object + required: + - mode + - trunk_id + - revision + - status + - config + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + status: {type: string, const: published} + updated_at: {type: string, format: date-time} + config: {$ref: '#/components/schemas/TrunkView'} + AdminTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: {type: array, items: {$ref: '#/components/schemas/AdminTrunk'}} + ReadonlyTrunkList: + type: object + required: [mode, trunks] + properties: + mode: {$ref: '#/components/schemas/Mode'} + trunks: + type: array + items: + $ref: '#/components/schemas/ReadonlyTrunk' + Cell: + type: object + required: [mode, cell_id, revision, config, updated_at, updated_by] + properties: + mode: {$ref: '#/components/schemas/Mode'} + cell_id: {type: string} + revision: {type: integer, minimum: 1} + config: {$ref: '#/components/schemas/CellConfig'} + updated_at: {type: string, format: date-time} + updated_by: {type: string} + Publication: + type: object + required: [trunk_id, revision, cell_id, status, updated_at] + properties: + trunk_id: {type: string} + revision: {type: integer, minimum: 1} + cell_id: {type: string} + status: {type: string, enum: [pending, applied, failed]} + error_code: {type: [string, 'null']} + updated_at: {type: string, format: date-time} + AuditEntry: + type: object + required: + - audit_id + - resource_type + - resource_id + - action + - revision + - actor + - details_json + - created_at + properties: + audit_id: {type: string} + resource_type: {type: string, const: trunk} + resource_id: {type: string} + action: + type: string + enum: + [ + upsert, + publish, + publish_failed, + disable, + disable_failed, + rollback, + rollback_failed, + ] + revision: {type: integer, minimum: 0} + actor: {type: string} + request_id: {type: [string, 'null']} + details_json: {type: string} + created_at: {type: string, format: date-time} + ErrorResponse: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: {type: string} + message: {type: string} diff --git a/docs/最终开发部署监控与验收计划_v1.0.md b/docs/最终开发部署监控与验收计划_v1.0.md index ae5f0c8..4380144 100644 --- a/docs/最终开发部署监控与验收计划_v1.0.md +++ b/docs/最终开发部署监控与验收计划_v1.0.md @@ -300,7 +300,9 @@ bash -n deploy/asterisk.sh 下一实施入口是整改计划F0→F1~F4,对D01~D11补齐模式保护、租户隔离、可靠性、协议媒体与验收证据;真实SIP排障及D12外部协调并行,不以线路不通阻塞本地整改。真实替换仍须取得SaaS、SIP/Asterisk、ASR/LLM/TTS、OSS、出口、容量和运维证据;无需重新讨论已接受方案。具体供应商差异单独登记,不让Mock假成功掩盖缺口。 -**新增待办(SaaS 对接前,归入 D04/D12):SIP 编解码配置化。** 不得把 `ulaw`/`alaw` 写死在全局生成器或逐呼配置中;在供应商/Trunk 或受控 `codec_profile` 中维护允许编码及优先顺序,对外使用 `PCMA`/`PCMU` 等规范名称,Asterisk 渲染为 `alaw`/`ulaw`。SaaS 呼叫消息只可选择已授权的 `trunk_id`/`codec_profile_id`,不能任意注入 codec;调度时校验 Trunk 与 Cell/出口能力交集,无交集即拒绝发起,不静默回退、不因编码失败自动重拨,并记录实际 SDP 协商编码。实现前同步补充配置校验、OpenAPI/MQ 字段(如确需暴露)、迁移/发布流程及回归验收。 +**已实现 Mock 垂直切片(SaaS 对接前,归入 D04/D12):SIP 编解码配置化。** 编码不再写死在逐呼配置中;管理后台按供应商/Trunk 的受控 `codec_profile` 维护允许编码及优先顺序,对外使用 `PCMA`/`PCMU`,Asterisk 渲染为 `alaw`/`ulaw`。SaaS 呼叫消息只可引用已授权的 `trunk_id`/`codec_profile_id`,不能任意注入 codec;发布时校验 Trunk 与 Cell/出口能力交集,无交集拒绝,不静默回退、不因编码失败自动重拨,并保留实际 SDP 编码字段。当前实现位于 `agent_call/sip_management.py`,契约为 `docs/contracts/sip-management.openapi.yaml`,回归覆盖见 `tests/test_sip_management.py`;真实 Asterisk 配置适配、Cell 应用回执和生产 codec 能力登记仍待完成。 + +**已实现 Mock 垂直切片(多机器 SIP 管理,归入 D01/D04/D10/D12):独立 Asterisk/SIP 管理后台。** 独立管理后台作为 SIP Trunk、`codec_profile`、主叫/被叫规则、出口池、并发/CPS 及多机器 Cell 发布的唯一写入面,已提供持久化配置、版本/CAS、审计、校验、发布、停用、回滚、Cell 能力登记和每 Cell 发布意图;发布失败保留旧版本,新配置只影响新呼叫。SaaS 不拥有线路配置写权限,仅通过独立只读接口读取已发布 Trunk 的脱敏配置、能力、版本和健康状态;普通调度 Token、SaaS 只读凭据均不能访问管理写接口。管理写接口、SaaS 只读接口和普通调度接口使用隔离的认证凭据/issuer/audience/权限域;不返回 SIP/ARI 明文凭据,只接受受控 `secret_ref`。实现文件为 `agent_call/sip_management.py`、`agent_call/sip_management_main.py`,契约为 `docs/contracts/sip-management.openapi.yaml`,Compose 使用 `sip-management` profile;仍待真实 Asterisk/Cell 应用适配、TLS/网络隔离、生产身份提供方接入和多机故障验收。 ### 10.1 历史执行记录(2026-09-12~13,非当前完整验收结论) diff --git a/docs/部署接入_运行说明.md b/docs/部署接入_运行说明.md index ecd2ad6..7cab4c1 100644 --- a/docs/部署接入_运行说明.md +++ b/docs/部署接入_运行说明.md @@ -91,6 +91,51 @@ python3 deploy/aliyun_host.py --config .local/aliyun.json --apply - `.local/aliyun-host.json`和锁文件是恢复依据,不能在出错后直接删除来强行重试。多个控制机必须共用明确的操作责任,不能各自用独立状态并行创建。 - 该脚本只准备ECS/EIP,不安装Docker、不上传SSH私钥、不配置DNS/HTTPS、不迁移活动通话。测试完成后先询问用户是否清理;未获明确清理指令时保留唯一 `project=agent-call` 可用/运行实例,不自动删除或创建第二台。用户明确要求清理后,只停止并删除本次创建的实例;竞价回收后的监控/自动恢复尚未实现。 +### 3.4 独立 Asterisk/SIP 管理后台(Mock/real) + +SIP Trunk、codec、主叫/被叫规则、出口池和多机器 Cell 配置只能由独立管理后台写入;SaaS 只访问只读 Trunk 目录/状态。管理后台使用独立 SQLite 持久化、版本/CAS、审计、发布、停用和回滚接口,契约见 `docs/contracts/sip-management.openapi.yaml`。 + +```bash +# 只在受控环境注入两套不同的 opaque token;不要使用普通 HTTP_TOKENS。 +export SIP_ADMIN_TOKENS='{"replace-admin-token":{"subject":"ops","issuer":"ops","audience":"agent-call.sip-admin","scopes":["*"],"trunk_ids":"*"}}' +export SIP_READ_TOKENS='{"replace-saas-read-token":{"subject":"saas","issuer":"saas","audience":"agent-call.sip-read","scopes":["sip.trunk.read"],"trunk_ids":["trunk-primary"]}}' +# Mock 仅登记持久化发布意图;real 还要求下列 mTLS 文件已注入。 +export SIP_MANAGEMENT_MODE=real +export SIP_CELL_TLS_DIR=/etc/agent-call/sip-management-tls +export SIP_CELL_TLS_CA_FILE=/run/sip-tls/ca.pem +export SIP_CELL_TLS_CERT_FILE=/run/sip-tls/client.pem +export SIP_CELL_TLS_KEY_FILE=/run/sip-tls/client-key.pem +docker compose --profile sip-management up -d --build sip-management +curl --fail http://127.0.0.1:18090/healthz/live +``` + +- 管理写接口为 `/admin/v1/...`;SaaS 只读接口为 `/readonly/v1/sip/trunks...`,不提供 SaaS 写入线路的路径。 +- 所有变更写接口要求 `If-Match` 和 `X-Request-ID`;发布只接受存在健康 Cell 且 Trunk 编码能力有交集的配置。没有交集拒绝发布,不静默回退或自动重拨。 +- `mock` 模式的 `pending` publication 只表示待 Cell 应用的持久意图;`real` 模式只有所有选定 Cell Agent 返回匹配的版本/SHA-256 `applied` 回执后才激活版本。任一 Cell 失败时保留旧 active revision,并保留逐 Cell 失败证据。 + +### 3.5 Cell Agent(真实 Asterisk Cell) + +Cell Agent 与管理后台部署在管理网,通过 mTLS HTTPS 接收完整 Trunk 快照;契约见 `docs/contracts/cell-agent.openapi.yaml`。Cell 本地必须预先让 Asterisk 主配置 include `/etc/asterisk/pjsip.d/*.conf`,之后每次发布只原子替换对应片段并执行受控 `asterisk -rx 'pjsip reload'`,不能逐呼改写或重载共享配置。 + +```bash +# 在真实 Asterisk Cell 上运行;证书文件由受控 secret/PKI 注入,不能提交仓库。 +export CELL_ID=cell-beijing-01 +export CELL_AGENT_HOST=10.0.0.21 +export CELL_AGENT_PORT=9443 +export CELL_AGENT_DB=/var/lib/agent-call/cell-agent.sqlite3 +export CELL_ASTERISK_CONFIG_DIR=/etc/asterisk/pjsip.d +export CELL_ASTERISK_CLI=/usr/sbin/asterisk +export CELL_TLS_CA_FILE=/etc/agent-call/cell-tls/ca.pem +export CELL_TLS_CERT_FILE=/etc/agent-call/cell-tls/server.pem +export CELL_TLS_KEY_FILE=/etc/agent-call/cell-tls/server-key.pem +python3 -m agent_call.cell_agent_main +``` + +- Cell Agent 默认只监听回环;生产必须显式绑定管理网地址并在安全组仅允许管理后台 mTLS 流量,不能暴露公网。 +- `management_url` 必须是 HTTPS 且不包含凭据、查询串或片段;real 模式登记 Cell 时缺失该字段会拒绝。 +- 当前 Cell Agent 对 `auth_mode=digest` 失败关闭,直到 Cell 本地 secret resolver 经过单独审核;不会把 `secret_ref` 渲染成明文,也不会静默改用 IP 鉴权。 +- 以上代码路径和 Mock/故障测试已完成;真实证书、实际多机 Cell/Asterisk reload、网络隔离和供应商线路回执仍必须在目标环境逐项验收,不能由 Mock 通过替代。 + ## 4. ASR Web启动与浏览器验证 ### 4.1 配置 diff --git a/tests/test_agent_call.py b/tests/test_agent_call.py index cbf02ae..33314bb 100644 --- a/tests/test_agent_call.py +++ b/tests/test_agent_call.py @@ -338,6 +338,38 @@ class AgentCallTests(unittest.TestCase): "agent-call.commands.v1", routing_key("tenant#c"), {"message": 2} ) + def test_published_sip_directory_overrides_static_route(self) -> None: + class Directory: + def get_trunk(self, trunk_id: str) -> dict: + return { + "trunk_id": trunk_id, + "status": "published", + "revision": 7, + "config": { + "enabled": True, + "codec_profile": { + "allowed": ["PCMA"], + "preferred": "PCMA", + }, + "caller_ids": ["BD93205882"], + "dial_prefix": "9001", + "egress_pool_id": "egress-mock", + "max_concurrency": 12, + "max_cps": 4, + "sip": {"transport": "udp"}, + }, + } + + self.service.sip_readonly = Directory() + route = self.service._route_config("route_policy_test") + self.assertEqual(route["trunk_revision"], 7) + self.assertEqual(route["dial_prefix"], "9001") + self.assertEqual(route["max_concurrency"], 12) + cell = self.service._resource_cell(route) + self.assertIsNotNone(cell) + if cell is not None: + self.assertEqual(cell[0], "cell-a") + def test_profile_never_claims_real_ai(self) -> None: metrics = self.service.metrics() self.assertIn( diff --git a/tests/test_cell_agent.py b/tests/test_cell_agent.py new file mode 100644 index 0000000..103d0a5 --- /dev/null +++ b/tests/test_cell_agent.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +import hashlib +import json +import threading +import unittest +from http.client import HTTPConnection +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any +from urllib.parse import quote + +from agent_call.cell_agent import ( + AsteriskConfigApplier, + CellAgentError, + CellAgentService, + make_server, +) +from agent_call.sip_management import ( + SipManagementError, + SipManagementService, + _validate_trunk, +) + + +class FakeApplier: + def __init__(self) -> None: + self.calls: list[tuple[str, dict[str, Any]]] = [] + self.fail = False + + def apply(self, trunk_id: str, config: dict[str, Any]) -> None: + if self.fail: + raise CellAgentError(502, "ASTERISK_RELOAD_FAILED", "reload failed") + self.calls.append((trunk_id, config)) + + +class CellAgentTests(unittest.TestCase): + def test_revision_hash_idempotency_and_gap_protection(self) -> None: + with TemporaryDirectory() as directory: + applier = FakeApplier() + service = CellAgentService( + Path(directory) / "cell.sqlite3", "cell-a", applier + ) + try: + body = self._publication("cell-a", "trunk:a", 1) + first = service.apply(body, "request-1") + second = service.apply(body, "request-1-retry") + self.assertFalse(first["idempotent"]) + self.assertTrue(second["idempotent"]) + self.assertEqual(len(applier.calls), 1) + with self.assertRaisesRegex(CellAgentError, "next revision"): + service.apply( + self._publication("cell-a", "trunk:a", 3), "request-3" + ) + bad = self._publication("cell-a", "trunk:a", 2) + bad["config_sha256"] = "0" * 64 + with self.assertRaisesRegex(CellAgentError, "does not match"): + service.apply(bad, "request-bad") + finally: + service.close() + + def test_http_endpoint_unquotes_path_and_rejects_mismatch(self) -> None: + with TemporaryDirectory() as directory: + service = CellAgentService( + Path(directory) / "cell.sqlite3", "cell-a", FakeApplier() + ) + server = make_server(service, "127.0.0.1", 0) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + body = self._publication("cell-a", "trunk:a", 1) + response = self._request( + server.server_port, + f"/v1/sip/trunks/{quote('trunk:a', safe='')}/apply", + body, + ) + self.assertEqual(response.status, 200) + mismatch = self._publication("cell-a", "other", 2) + response = self._request( + server.server_port, + "/v1/sip/trunks/trunk%3Aa/apply", + mismatch, + ) + self.assertEqual(response.status, 400) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + service.close() + + def test_asterisk_applier_writes_codec_and_restores_on_reload_failure(self) -> None: + with TemporaryDirectory() as directory: + + class RecordingApplier(AsteriskConfigApplier): + def __init__(self, config_dir: str) -> None: + super().__init__(config_dir) + self.fail = False + + def _reload(self) -> None: + if self.fail: + raise CellAgentError( + 502, "ASTERISK_RELOAD_FAILED", "reload failed" + ) + + applier = RecordingApplier(directory) + config = self._config() + applier.apply("trunk:a", config) + target = next(Path(directory).glob("*.conf")) + original = target.read_text(encoding="utf-8") + self.assertIn("allow=alaw", original) + self.assertIn("from_user=BD93205882", original) + applier.fail = True + with self.assertRaises(CellAgentError): + applier.apply( + "trunk:a", + { + **config, + "codec_profile": {"allowed": ["PCMU"], "preferred": "PCMU"}, + }, + ) + self.assertEqual(target.read_text(encoding="utf-8"), original) + + def test_real_management_activates_only_after_cell_ack(self) -> None: + class Publisher: + def __init__(self) -> None: + self.fail = False + self.calls: list[int] = [] + + def publish( + self, + cell: dict[str, Any], + trunk_id: str, + revision: int, + config: dict[str, Any], + request_id: str, + ) -> dict[str, Any]: + self.calls.append(revision) + if self.fail: + raise OSError("offline") + digest = hashlib.sha256( + json.dumps( + config, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + return { + "mode": "real", + "cell_id": cell["cell_id"], + "trunk_id": trunk_id, + "revision": revision, + "config_sha256": digest, + "status": "applied", + } + + with TemporaryDirectory() as directory: + publisher = Publisher() + service = SipManagementService( + Path(directory) / "sip.sqlite3", mode="real", publisher=publisher + ) + try: + service.upsert_cell( + "cell-a", + { + "egress_pool_id": "egress-a", + "codec_capabilities": ["PCMA"], + "status": "healthy", + "max_concurrency": 10, + "management_url": "https://cell-a.internal:9443", + }, + expected_revision=0, + actor="ops", + request_id="cell-1", + ) + service.upsert_trunk( + "trunk:a", + self._config(), + expected_revision=0, + actor="ops", + request_id="trunk-1", + ) + published = service.publish_trunk( + "trunk:a", expected_revision=1, actor="ops", request_id="pub-1" + ) + self.assertEqual(published["active_revision"], 1) + self.assertEqual( + service.list_publications("trunk:a")[0]["status"], "applied" + ) + publisher.fail = True + service.upsert_trunk( + "trunk:a", + {**self._config(), "display_name": "Provider B"}, + expected_revision=1, + actor="ops", + request_id="trunk-2", + ) + with self.assertRaisesRegex(SipManagementError, "every Cell"): + service.publish_trunk( + "trunk:a", expected_revision=2, actor="ops", request_id="pub-2" + ) + self.assertEqual(service.get_trunk("trunk:a")["active_revision"], 1) + self.assertEqual( + service.list_publications("trunk:a")[0]["status"], "applied" + ) + self.assertEqual( + service.list_publications("trunk:a")[1]["status"], "failed" + ) + finally: + service.close() + + def test_real_publication_resumes_after_process_crash(self) -> None: + class CrashOncePublisher: + def __init__(self) -> None: + self.crashed = False + + def publish( + self, + cell: dict[str, Any], + trunk_id: str, + revision: int, + config: dict[str, Any], + request_id: str, + ) -> dict[str, Any]: + if not self.crashed: + self.crashed = True + raise KeyboardInterrupt + digest = hashlib.sha256( + json.dumps( + config, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + ).encode() + ).hexdigest() + return { + "mode": "real", + "cell_id": cell["cell_id"], + "trunk_id": trunk_id, + "revision": revision, + "config_sha256": digest, + "status": "applied", + } + + with TemporaryDirectory() as directory: + publisher = CrashOncePublisher() + service = SipManagementService( + Path(directory) / "sip.sqlite3", mode="real", publisher=publisher + ) + try: + service.upsert_cell( + "cell-a", + { + "egress_pool_id": "egress-a", + "codec_capabilities": ["PCMA"], + "status": "healthy", + "max_concurrency": 10, + "management_url": "https://cell-a.internal:9443", + }, + expected_revision=0, + actor="ops", + request_id="cell-1", + ) + service.upsert_trunk( + "trunk:a", + self._config(), + expected_revision=0, + actor="ops", + request_id="trunk-1", + ) + with self.assertRaises(KeyboardInterrupt): + service.publish_trunk( + "trunk:a", expected_revision=1, actor="ops", request_id="pub-1" + ) + self.assertEqual(service.get_trunk("trunk:a")["active_revision"], 0) + resumed = service.publish_trunk( + "trunk:a", expected_revision=1, actor="ops", request_id="pub-2" + ) + self.assertEqual(resumed["active_revision"], 1) + self.assertEqual( + service.list_publications("trunk:a")[0]["status"], "applied" + ) + finally: + service.close() + + @staticmethod + def _config() -> dict[str, Any]: + return { + "display_name": "Provider A", + "enabled": True, + "sip": { + "host": "61.132.228.221", + "port": 5060, + "transport": "udp", + "auth_mode": "ip", + "register": False, + }, + "codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"}, + "caller_ids": ["BD93205882"], + "dial_prefix": "7089", + "egress_pool_id": "egress-a", + "max_concurrency": 100, + "max_cps": 10, + } + + @classmethod + def _publication(cls, cell_id: str, trunk_id: str, revision: int) -> dict[str, Any]: + config = _validate_trunk(trunk_id, cls._config()) + digest = hashlib.sha256( + json.dumps( + config, ensure_ascii=False, sort_keys=True, separators=(",", ":") + ).encode() + ).hexdigest() + return { + "mode": "real", + "cell_id": cell_id, + "trunk_id": trunk_id, + "revision": revision, + "config": config, + "config_sha256": digest, + } + + @staticmethod + def _request(port: int, path: str, body: dict[str, Any]): + encoded = json.dumps(body, ensure_ascii=False).encode() + connection = HTTPConnection("127.0.0.1", port, timeout=3) + connection.request( + "POST", + path, + body=encoded, + headers={ + "Content-Type": "application/json", + "Content-Length": str(len(encoded)), + "X-Request-ID": "http-test", + }, + ) + response = connection.getresponse() + response.read() + connection.close() + return response + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_contracts.py b/tests/test_contracts.py index a3e8017..1607d8c 100644 --- a/tests/test_contracts.py +++ b/tests/test_contracts.py @@ -22,8 +22,22 @@ class ContractTests(unittest.TestCase): encoding="utf-8" ) saas = (ROOT / "docs/contracts/saas.openapi.yaml").read_text(encoding="utf-8") + sip_management = ( + ROOT / "docs/contracts/sip-management.openapi.yaml" + ).read_text(encoding="utf-8") self.assertIn("openapi: 3.1.0", executor) self.assertIn("openapi: 3.1.0", saas) + cell_agent = (ROOT / "docs/contracts/cell-agent.openapi.yaml").read_text( + encoding="utf-8" + ) + self.assertIn("openapi: 3.1.0", sip_management) + self.assertIn("openapi: 3.1.0", cell_agent) + self.assertIn("CellManagementMtls", cell_agent) + self.assertIn("/v1/sip/trunks/{trunk_id}/apply:", cell_agent) + self.assertIn("SipAdminBearer", sip_management) + self.assertIn("SaasTrunkReadBearer", sip_management) + self.assertIn("/admin/v1/trunks/{trunk_id}/publish:", sip_management) + self.assertIn("/readonly/v1/sip/trunks:", sip_management) self.assertNotIn("call.execute", executor) self.assertIn("/internal/v1/outbound/tasks/{task_id}/controls:", executor) self.assertIn( diff --git a/tests/test_deployment.py b/tests/test_deployment.py index 01ffff5..3175634 100644 --- a/tests/test_deployment.py +++ b/tests/test_deployment.py @@ -247,8 +247,16 @@ class AsteriskTests(unittest.TestCase): "public_ip": cloud.PUBLIC_IP, "transport": "udp", "local_net": "10.1.0.0/16", - "primary": {"host": "sip-a.test", "auth_mode": "ip"}, - "backup": {"host": "sip-b.test", "auth_mode": "ip"}, + "primary": { + "host": "sip-a.test", + "auth_mode": "ip", + "codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"}, + }, + "backup": { + "host": "sip-b.test", + "auth_mode": "ip", + "codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"}, + }, } def test_build_user_data_pins_image_and_does_not_print_secret(self): @@ -258,11 +266,27 @@ class AsteriskTests(unittest.TestCase): "x" * 32, ) self.assertIn("ASTERISK_IMAGE=registry.example/asterisk@sha256:", content) + self.assertIn("pjsip set logger on", content) self.assertIn("HTTP_CONF_B64=", content) self.assertNotIn("password=" + "x" * 32, content) with self.assertRaises(ValueError): user_data.immutable_image("registry.example/asterisk:latest") + def test_codec_profile_is_rendered_without_global_fallback(self): + cfg = self.cfg() + cfg["primary"]["codec_profile"] = { + "allowed": ["PCMU", "PCMA"], + "preferred": "PCMA", + } + files = ast.render(cfg, {"ARI_PASSWORD": "x" * 32}) + self.assertIn("allow=alaw,ulaw", files["pjsip.conf"]) + cfg["primary"]["codec_profile"] = { + "allowed": ["G729"], + "preferred": "G729", + } + with self.assertRaises(ValueError): + ast.render(cfg, {"ARI_PASSWORD": "x" * 32}) + def test_private_ari_fixed_nat_and_recording_config(self): files = ast.render(self.cfg(), {"ARI_PASSWORD": "x" * 32}) self.assertEqual(len(files), 5) diff --git a/tests/test_sip_management.py b/tests/test_sip_management.py new file mode 100644 index 0000000..1ed2d85 --- /dev/null +++ b/tests/test_sip_management.py @@ -0,0 +1,456 @@ +from __future__ import annotations + +import json +import threading +import unittest +from http.client import HTTPConnection +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Any +from urllib.parse import urlsplit + +from agent_call.sip_management import ( + ADMIN_AUDIENCE, + READ_AUDIENCE, + ConfigurationError, + Principal, + SipManagementService, + load_token_map, + make_server, + validate_token_separation, +) + +ROOT = Path(__file__).resolve().parents[1] +ADMIN_BEARER = "admin-test" +READ_BEARER = "saas-test" + + +class SipManagementTests(unittest.TestCase): + def test_codec_mapping_and_token_domains_are_separate(self) -> None: + admin = load_token_map( + json.dumps( + { + ADMIN_BEARER: { + "subject": "ops", + "issuer": "ops-issuer", + "audience": ADMIN_AUDIENCE, + "scopes": ["*"], + "trunk_ids": "*", + } + } + ), + "admin", + ) + read = load_token_map( + json.dumps( + { + READ_BEARER: { + "subject": "saas", + "issuer": "saas-issuer", + "audience": READ_AUDIENCE, + "scopes": ["sip.trunk.read"], + "trunk_ids": ["trunk-a"], + } + } + ), + "read", + ) + validate_token_separation(admin, read, {"scheduler-test": {}}) + self.assertNotEqual(set(admin), set(read)) + with self.assertRaises(ConfigurationError): + validate_token_separation(admin, read, {ADMIN_BEARER: {}}) + + def test_persistent_versioned_publish_readonly_and_rollback(self) -> None: + with TemporaryDirectory() as directory: + service = SipManagementService(Path(directory) / "sip.sqlite3") + try: + service.upsert_cell( + "cell-a", + { + "egress_pool_id": "egress-a", + "codec_capabilities": ["PCMA", "PCMU"], + "status": "healthy", + "max_concurrency": 100, + }, + expected_revision=0, + actor="ops", + request_id="cell-1", + ) + created = service.upsert_trunk( + "trunk-a", + self._trunk_payload(["PCMA"]), + expected_revision=0, + actor="ops", + request_id="trunk-1", + ) + self.assertEqual(created["latest_revision"], 1) + published = service.publish_trunk( + "trunk-a", expected_revision=1, actor="ops", request_id="publish-1" + ) + self.assertEqual(published["status"], "published") + self.assertEqual(published["compatible_cell_ids"], ["cell-a"]) + self.assertEqual( + service.list_publications("trunk-a")[0]["status"], "pending" + ) + + readonly = service.get_readonly_trunk("trunk-a", frozenset({"trunk-a"})) + self.assertEqual( + readonly["config"]["codec_profile"]["allowed"], ["PCMA"] + ) + self.assertNotIn("credential_ref", readonly["config"]["sip"]) + + updated = service.upsert_trunk( + "trunk-a", + self._trunk_payload(["PCMU"]), + expected_revision=1, + actor="ops", + request_id="trunk-2", + ) + self.assertEqual(updated["latest_revision"], 2) + with self.assertRaisesRegex(Exception, "revision changed"): + service.publish_trunk( + "trunk-a", + expected_revision=1, + actor="ops", + request_id="publish-old", + ) + service.publish_trunk( + "trunk-a", expected_revision=2, actor="ops", request_id="publish-2" + ) + rolled_back = service.rollback_trunk( + "trunk-a", + 1, + expected_revision=2, + actor="ops", + request_id="rollback-1", + ) + self.assertEqual(rolled_back["active_revision"], 3) + self.assertEqual( + rolled_back["active"]["codec_profile"]["allowed"], ["PCMA"] + ) + self.assertEqual( + [entry["action"] for entry in service.list_audit("trunk-a")], + ["upsert", "publish", "upsert", "publish", "rollback"], + ) + finally: + service.close() + + def test_state_survives_restart_and_secret_refs_are_not_exposed(self) -> None: + with TemporaryDirectory() as directory: + database = Path(directory) / "sip.sqlite3" + service = SipManagementService(database) + service.upsert_cell( + "cell-a", + { + "egress_pool_id": "egress-a", + "codec_capabilities": ["PCMA"], + "status": "healthy", + "max_concurrency": 10, + }, + expected_revision=0, + actor="ops", + request_id="cell-1", + ) + service.upsert_trunk( + "trunk-a", + { + **self._trunk_payload(["PCMA"]), + "sip": { + **self._trunk_payload(["PCMA"])["sip"], + "auth_mode": "digest", + "credential_ref": "secret://provider-a", + }, + }, + expected_revision=0, + actor="ops", + request_id="trunk-1", + ) + service.publish_trunk( + "trunk-a", expected_revision=1, actor="ops", request_id="publish-1" + ) + service.close() + + reopened = SipManagementService(database) + try: + admin = reopened.get_trunk("trunk-a") + readonly = reopened.get_readonly_trunk( + "trunk-a", frozenset({"trunk-a"}) + ) + self.assertTrue(admin["active"]["credential_configured"]) + self.assertNotIn("credential_ref", admin["active"]["sip"]) + self.assertNotIn("credential_ref", readonly["config"]["sip"]) + finally: + reopened.close() + + def test_openapi_separates_admin_and_saas_readonly_surfaces(self) -> None: + contract = (ROOT / "docs/contracts/sip-management.openapi.yaml").read_text( + encoding="utf-8" + ) + self.assertIn("SipAdminBearer", contract) + self.assertIn("SaasTrunkReadBearer", contract) + self.assertIn("/admin/v1/trunks/{trunk_id}/publish:", contract) + self.assertIn("/readonly/v1/sip/trunks:", contract) + self.assertNotIn("HTTP_TOKENS", contract) + + def test_publish_rejects_without_compatible_cell(self) -> None: + with TemporaryDirectory() as directory: + service = SipManagementService(Path(directory) / "sip.sqlite3") + try: + service.upsert_cell( + "cell-pcmu", + { + "egress_pool_id": "egress-a", + "codec_capabilities": ["PCMU"], + "status": "healthy", + "max_concurrency": 10, + }, + expected_revision=0, + actor="ops", + request_id="cell-1", + ) + service.upsert_trunk( + "trunk-a", + self._trunk_payload(["PCMA"]), + expected_revision=0, + actor="ops", + request_id="trunk-1", + ) + with self.assertRaisesRegex(Exception, "no healthy Cell"): + service.publish_trunk( + "trunk-a", + expected_revision=1, + actor="ops", + request_id="publish-1", + ) + finally: + service.close() + + def test_http_readonly_token_cannot_write_or_read_admin_api(self) -> None: + with TemporaryDirectory() as directory: + service = SipManagementService(Path(directory) / "sip.sqlite3") + admin_tokens = { + ADMIN_BEARER: Principal( + "ops", + "ops-issuer", + ADMIN_AUDIENCE, + frozenset({"*"}), + frozenset({"*"}), + "admin", + ) + } + read_tokens = { + READ_BEARER: Principal( + "saas", + "saas-issuer", + READ_AUDIENCE, + frozenset({"sip.trunk.read"}), + frozenset({"trunk-a"}), + "read", + ) + } + server = make_server( + service, + "127.0.0.1", + 0, + admin_tokens=admin_tokens, + read_tokens=read_tokens, + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_port}" + try: + status, body = self._request( + base, + "GET", + "/healthz/live", + bearer=None, + ) + self.assertEqual(status, 200) + self.assertEqual(body["mode"], "mock") + + status, _ = self._request( + base, + "PUT", + "/admin/v1/trunks/trunk-a", + bearer=READ_BEARER, + headers={"If-Match": "0", "X-Request-ID": "write-1"}, + body=self._trunk_payload(["PCMA"]), + ) + self.assertEqual(status, 401) + status, _ = self._request( + base, + "GET", + "/readonly/v1/sip/trunks", + bearer=ADMIN_BEARER, + ) + self.assertEqual(status, 401) + status, _ = self._request( + base, + "POST", + "/readonly/v1/sip/trunks/trunk-a", + bearer=READ_BEARER, + body={}, + ) + self.assertEqual(status, 405) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + service.close() + + def test_http_admin_publish_and_readonly_uses_active_revision(self) -> None: + with TemporaryDirectory() as directory: + service = SipManagementService(Path(directory) / "sip.sqlite3") + admin_tokens = { + ADMIN_BEARER: Principal( + "ops", + "ops-issuer", + ADMIN_AUDIENCE, + frozenset({"*"}), + frozenset({"*"}), + "admin", + ) + } + read_tokens = { + READ_BEARER: Principal( + "saas", + "saas-issuer", + READ_AUDIENCE, + frozenset({"sip.trunk.read"}), + frozenset({"trunk-a"}), + "read", + ) + } + server = make_server( + service, + "127.0.0.1", + 0, + admin_tokens=admin_tokens, + read_tokens=read_tokens, + ) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + base = f"http://127.0.0.1:{server.server_port}" + try: + status, _ = self._request( + base, + "PUT", + "/admin/v1/cells/cell-a", + bearer=ADMIN_BEARER, + headers={"If-Match": "0", "X-Request-ID": "cell-1"}, + body={ + "egress_pool_id": "egress-a", + "codec_capabilities": ["PCMA", "PCMU"], + "status": "healthy", + "max_concurrency": 10, + }, + ) + self.assertEqual(status, 201) + status, _ = self._request( + base, + "PUT", + "/admin/v1/trunks/trunk-a", + bearer=ADMIN_BEARER, + headers={"If-Match": "0", "X-Request-ID": "trunk-1"}, + body=self._trunk_payload(["PCMA"]), + ) + self.assertEqual(status, 201) + status, _ = self._request( + base, + "POST", + "/admin/v1/trunks/trunk-a/publish", + bearer=ADMIN_BEARER, + headers={"If-Match": "1", "X-Request-ID": "publish-1"}, + ) + self.assertEqual(status, 200) + status, body = self._request( + base, + "GET", + "/readonly/v1/sip/trunks/trunk-a", + bearer=READ_BEARER, + ) + self.assertEqual(status, 200) + self.assertEqual(body["revision"], 1) + self.assertEqual(body["config"]["codec_profile"]["allowed"], ["PCMA"]) + self.assertNotIn("latest", body) + self.assertNotIn("compatible_cell_ids", body) + + status, _ = self._request( + base, + "PUT", + "/admin/v1/trunks/trunk-a", + bearer=ADMIN_BEARER, + headers={"If-Match": "1", "X-Request-ID": "trunk-2"}, + body=self._trunk_payload(["PCMU"]), + ) + self.assertEqual(status, 200) + status, body = self._request( + base, + "GET", + "/readonly/v1/sip/trunks/trunk-a", + bearer=READ_BEARER, + ) + self.assertEqual(status, 200) + self.assertEqual(body["revision"], 1) + self.assertEqual(body["config"]["codec_profile"]["allowed"], ["PCMA"]) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + service.close() + + @staticmethod + def _trunk_payload(codecs: list[str]) -> dict[str, Any]: + return { + "display_name": "Provider A", + "enabled": True, + "sip": { + "host": "61.132.228.221", + "port": 5060, + "transport": "udp", + "auth_mode": "ip", + "register": False, + }, + "codec_profile": {"allowed": codecs, "preferred": codecs[0]}, + "caller_ids": ["BD93205882"], + "dial_prefix": "7089", + "egress_pool_id": "egress-a", + "max_concurrency": 100, + "max_cps": 10, + } + + @staticmethod + def _request( + base: str, + method: str, + path: str, + *, + bearer: str | None, + headers: dict[str, str] | None = None, + body: Any | None = None, + ) -> tuple[int, dict[str, Any]]: + request_headers = {"Accept": "application/json"} + if bearer: + request_headers["Authorization"] = f"Bearer {bearer}" + if headers: + request_headers.update(headers) + data = None + if body is not None: + data = json.dumps(body).encode("utf-8") + request_headers["Content-Type"] = "application/json" + parsed = urlsplit(base) + host = parsed.hostname + port = parsed.port + if parsed.scheme != "http" or host != "127.0.0.1" or port is None: + raise AssertionError("test helper only permits a local HTTP server") + connection = HTTPConnection(host, port, timeout=3) + try: + connection.request(method, path, body=data, headers=request_headers) + response = connection.getresponse() + return response.status, json.loads(response.read()) + finally: + connection.close() + + +if __name__ == "__main__": + unittest.main()