feat: deliver versioned SIP config to Cells

This commit is contained in:
2026-09-14 14:38:15 +08:00
parent 3a47914a6c
commit c8716608a2
26 changed files with 4665 additions and 21 deletions
+2 -2
View File
@@ -3,6 +3,6 @@
"transport": "udp",
"local_net": "",
"ari_bind": "127.0.0.1",
"primary": {"host": "", "port": 5060, "auth_mode": "ip", "username": "", "register": false},
"backup": {"host": "", "port": 5060, "auth_mode": "ip", "username": "", "register": false}
"primary": {"host": "", "port": 5060, "auth_mode": "ip", "username": "", "register": false, "codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"}},
"backup": {"host": "", "port": 5060, "auth_mode": "ip", "username": "", "register": false, "codec_profile": {"allowed": ["PCMA"], "preferred": "PCMA"}}
}
+1
View File
@@ -76,6 +76,7 @@ if [[ "$ready" != 1 ]]; then
echo 'Asterisk did not become CLI-ready; inspect docker logs' >&2
exit 1
fi
docker exec agent-call-asterisk asterisk -rx 'pjsip set logger on'
docker exec agent-call-asterisk asterisk -rx 'pjsip show endpoint provider-primary'
install -d -m 0750 /var/lib/agent-call
date -u +%Y-%m-%dT%H:%M:%SZ >/var/lib/agent-call/bootstrap.done
+10
View File
@@ -0,0 +1,10 @@
# Real Cell Agent settings. Certificate files are injected by the host PKI/secret manager.
CELL_ID=cell-beijing-01
CELL_AGENT_HOST=10.0.0.21
CELL_AGENT_PORT=9443
CELL_AGENT_DB=/var/lib/agent-call/cell-agent.sqlite3
CELL_ASTERISK_CONFIG_DIR=/etc/asterisk/pjsip.d
CELL_ASTERISK_CLI=/usr/sbin/asterisk
CELL_TLS_CA_FILE=/etc/agent-call/cell-tls/ca.pem
CELL_TLS_CERT_FILE=/etc/agent-call/cell-tls/server.pem
CELL_TLS_KEY_FILE=/etc/agent-call/cell-tls/server-key.pem
+5
View File
@@ -2,8 +2,13 @@
RABBITMQ_DEFAULT_USER=agent_call_mock
RABBITMQ_DEFAULT_PASS=replace-before-start
RABBITMQ_URL=amqp://agent_call_mock:replace-before-start@rabbitmq:5672/%2f
ASR_WEB_TOKEN=local-asr-test
# JSON map: token -> tenant IDs and scopes. Inject through a secret manager in real deployments.
HTTP_TOKENS={"local":{"tenant_ids":["tenant-demo"],"scopes":["outbound.read","outbound.control","recording.upload","recording.complete"]}}
# Independent SIP management credentials; synthetic local values only.
SIP_MANAGEMENT_PORT=18090
SIP_ADMIN_TOKENS={"admin-local":{"subject":"ops","issuer":"local","audience":"agent-call.sip-admin","scopes":["*"],"trunk_ids":"*"}}
SIP_READ_TOKENS={"saas-local":{"subject":"saas","issuer":"local","audience":"agent-call.sip-read","scopes":["sip.trunk.read"],"trunk_ids":["trunk-mock"]}}
AGENT_CALL_MODE=mock
BROKER_MODE=rabbit
AGENT_CALL_PORT=18080
+37 -5
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Render an explicit, outbound-only UDP/PCMA baseline. Does not start services."""
"""Render an explicit, outbound-only UDP config from the Trunk codec profile."""
import argparse
import ipaddress
@@ -12,6 +12,7 @@ import tempfile
from pathlib import Path
PUBLIC_IP = "123.56.71.98"
CODEC_TO_ASTERISK = {"PCMA": "alaw", "PCMU": "ulaw"}
def scalar(value, name, secret=False):
@@ -29,6 +30,32 @@ def scalar(value, name, secret=False):
return value
def codec_allow(name, data):
profile = data.get("codec_profile")
if not isinstance(profile, dict):
raise ValueError( # noqa: TRY004 - render() exposes one config-error type
f"{name}.codec_profile must be explicit"
)
allowed = profile.get("allowed")
if not isinstance(allowed, list) or not allowed:
raise ValueError(f"{name}.codec_profile.allowed must be a non-empty list")
normalized = []
for codec in allowed:
if not isinstance(codec, str) or codec.upper() not in CODEC_TO_ASTERISK:
raise ValueError(f"{name}.codec_profile contains an unsupported codec")
codec = codec.upper()
if codec in normalized:
raise ValueError(f"{name}.codec_profile contains a duplicate codec")
normalized.append(codec)
preferred = profile.get("preferred", normalized[0])
if not isinstance(preferred, str) or preferred.upper() not in normalized:
raise ValueError(f"{name}.codec_profile.preferred must be allowed")
ordered = [preferred.upper()] + [
codec for codec in normalized if codec != preferred.upper()
]
return ",".join(CODEC_TO_ASTERISK[codec] for codec in ordered)
def endpoint(name, data, env):
host = scalar(data.get("host"), name + ".host")
if (
@@ -48,7 +75,8 @@ def endpoint(name, data, env):
registration = data.get("register", False)
if not isinstance(registration, bool) or registration and mode != "digest":
raise ValueError("registration requires explicit digest authentication")
text = f"[{name}]\ntype=endpoint\ntransport=transport-udp\ncontext=deny-inbound\ndisallow=all\nallow=alaw\ndirect_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\naors={name}-aor\n"
allow = codec_allow(name, data)
text = f"[{name}]\ntype=endpoint\ntransport=transport-udp\ncontext=deny-inbound\ndisallow=all\nallow={allow}\ndirect_media=no\nrtp_symmetric=yes\nforce_rport=yes\nrewrite_contact=yes\naors={name}-aor\n"
from_user = data.get("from_user")
if from_user is not None:
if not isinstance(from_user, str) or not re.fullmatch(
@@ -69,12 +97,16 @@ def endpoint(name, data, env):
user = scalar(data.get("username"), name + ".username")
if not re.fullmatch(r"[A-Za-z0-9_.+-]+", user):
raise ValueError("SIP username must be a plain user identifier")
password_key = "SIP_" + name.removeprefix("provider-").upper() + "_PASSWORD"
password = scalar(env.get(password_key), password_key, secret=True)
credential_env_name = (
"SIP_" + name.removeprefix("provider-").upper() + "_PASSWORD"
)
credential_value = scalar(
env.get(credential_env_name), credential_env_name, secret=True
)
text += f"outbound_auth={name}-auth\n"
if from_user is None:
text += f"from_user={user}\n"
auth = f"\n[{name}-auth]\ntype=auth\nauth_type=userpass\nusername={user}\npassword={password}\n"
auth = f"\n[{name}-auth]\ntype=auth\nauth_type=userpass\nusername={user}\npassword={credential_value}\n"
if registration:
auth += f"\n[{name}-registration]\ntype=registration\ntransport=transport-udp\noutbound_auth={name}-auth\nserver_uri=sip:{host}:{port}\nclient_uri=sip:{user}@{host}:{port}\nretry_interval=60\n"
text += (