feat: deliver versioned SIP config to Cells

This commit is contained in:
2026-09-14 14:38:15 +08:00
parent 3a47914a6c
commit c8716608a2
26 changed files with 4665 additions and 21 deletions
+18
View File
@@ -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
+1
View File
@@ -11,6 +11,7 @@ __pycache__/
deploy/state/
data/*.sqlite3*
deploy/asterisk/generated/
secrets/
services/asr-web/asr-web
*.test
coverage.out
+1
View File
@@ -53,6 +53,7 @@
- 供应商/线路层预先维护独立 trunk:服务端、协议、鉴权、注册、允许的主叫、被叫改写规则和并发额度。不同供应商不能共用并逐呼覆盖同一份线路配置。
- 呼叫层只从服务端已配置且获授权的线路中选择,按该线路规则设置本次主叫与被叫;**不为每个号码重写 pjsip.conf、重载 Asterisk 或重建注册**。
- MQ 指令不能让调用方任意注入 SIP 地址、认证凭据或越权主叫;线路选择对应的具体消息字段仍由用户制定,不擅自定接口。
- SIP Trunk、codec、主叫/被叫规则、出口池及多机器 Cell 发布由独立 Asterisk/SIP 管理后台作为唯一写入面统一管理;SaaS 仅可读取受控 Trunk 目录/状态,不直接访问 Asterisk/ARI 管理面或写入 SIP 配置。
- 切换线路时从原始被叫重新应用目标线路规则,不能沿用上一家的前缀或主叫。重试/切换条件需按业务契约另行实现,不能默认重复拨打已接通的电话。
- 现有生成器仅支持固定 primary/backup,尚非完整多供应商路由;后续支持单线路启动、可扩展线路列表及逐呼选路,不为凑齐主备而虚构供应商。
+667
View File
@@ -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",
]
+22
View File
@@ -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
+67 -9
View File
@@ -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()
+27
View File
@@ -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,
)
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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
+142
View File
@@ -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"]
+60
View File
@@ -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:
+2 -2
View File
@@ -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"}}
}
+1
View File
@@ -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
+10
View File
@@ -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
+5
View File
@@ -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
+37 -5
View File
@@ -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 += (
@@ -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 |
| 接口归属及域名 | 业务两份 OpenAPISIP 管理后台使用独立管理 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,确认保底资源、借用/归还边界及可满足的租户总承诺 | 用户、业务/运维 |
+225
View File
@@ -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'}
+564
View File
@@ -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}
@@ -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,非当前完整验收结论)
+45
View File
@@ -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 配置
+32
View File
@@ -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(
+344
View File
@@ -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()
+14
View File
@@ -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(
+26 -2
View File
@@ -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)
+456
View File
@@ -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()