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