support task-selectable SIP trunks

This commit is contained in:
2026-09-17 15:23:46 +08:00
parent 84d86319a3
commit e65cfbe3af
15 changed files with 452 additions and 39 deletions
+46
View File
@@ -0,0 +1,46 @@
{
"public_ip": "123.56.71.98",
"transport": "udp",
"local_net": "172.16.0.0/12",
"trunks": [
{
"trunk_id": "provider-primary",
"host": "61.132.228.221",
"port": 5060,
"auth_mode": "ip",
"register": false,
"from_user": "BD93205882",
"caller_id": "BD93205882",
"codec_profile": {
"allowed": ["PCMA"],
"preferred": "PCMA"
}
},
{
"trunk_id": "provider-second",
"host": "60.171.24.90",
"port": 5060,
"auth_mode": "ip",
"register": false,
"from_user": "mbkq",
"caller_id": "mbkq",
"codec_profile": {
"allowed": ["PCMA"],
"preferred": "PCMA"
}
},
{
"trunk_id": "provider-third",
"host": "160.202.254.79",
"port": 5060,
"auth_mode": "ip",
"register": false,
"from_user": "KQ91526",
"caller_id": "KQ91526",
"codec_profile": {
"allowed": ["PCMA"],
"preferred": "PCMA"
}
}
]
}
+20
View File
@@ -0,0 +1,20 @@
{
"route_policy_sip_first": {
"caller_profile_id": "caller_profile_sip_first",
"trunk_id": "provider-primary",
"caller_id": "BD93205882",
"dial_prefix": "7089"
},
"route_policy_sip_second": {
"caller_profile_id": "caller_profile_sip_second",
"trunk_id": "provider-second",
"caller_id": "mbkq",
"dial_prefix": ""
},
"route_policy_sip_third": {
"caller_profile_id": "caller_profile_sip_third",
"trunk_id": "provider-third",
"caller_id": "KQ91526",
"dial_prefix": "mka755"
}
}
+2
View File
@@ -32,5 +32,7 @@ CELL_ARI_PASSWORD=replace-before-start
CELL_ARI_APP=agent-call
CELL_SIP_TRUNK_ID=provider-primary
CELL_CALLER_ID=BD93205882
# Optional trusted JSON route map for a real Cell; leave empty in mock mode.
CELL_ROUTE_MAP_JSON=
CELL_RTP_BIND_HOST=127.0.0.1
CELL_RECORDING_DIR=/data/recordings
+49 -19
View File
@@ -13,6 +13,7 @@ from pathlib import Path
PUBLIC_IP = "123.56.71.98"
CODEC_TO_ASTERISK = {"PCMA": "alaw", "PCMU": "ulaw"}
TRUNK_ID_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9_.-]{0,127}")
def scalar(value, name, secret=False):
@@ -115,6 +116,47 @@ def endpoint(name, data, env):
return text + auth
def _trunks(cfg):
if "trunks" not in cfg:
primary_data = cfg.get("primary", {})
if not isinstance(primary_data, dict):
raise ValueError("primary must be an object")
rows = [("provider-primary", primary_data)]
backup_data = cfg.get("backup")
if backup_data is not None and not isinstance(backup_data, dict):
raise ValueError("backup must be an object when configured")
if backup_data:
rows.append(("provider-backup", backup_data))
if (primary_data.get("host"), primary_data.get("port", 5060)) == (
backup_data.get("host"),
backup_data.get("port", 5060),
):
raise ValueError(
"primary and backup targets must be distinct; shared failure domains still require validation"
)
return rows
if "primary" in cfg or "backup" in cfg:
raise ValueError("use trunks instead of mixing arbitrary trunks with primary/backup")
raw = cfg["trunks"]
if not isinstance(raw, list) or not raw:
raise ValueError("trunks must be a non-empty list")
rows = []
seen = set()
for index, data in enumerate(raw):
if not isinstance(data, dict):
raise TypeError(f"trunks[{index}] must be an object")
name = data.get("trunk_id")
if not isinstance(name, str) or not TRUNK_ID_RE.fullmatch(name):
raise ValueError(
f"trunks[{index}].trunk_id must be a safe Asterisk endpoint identifier"
)
if name in seen:
raise ValueError(f"duplicate trunk_id: {name}")
seen.add(name)
rows.append((name, data))
return rows
def render(cfg, env):
if cfg.get("public_ip") != PUBLIC_IP or cfg.get("transport") != "udp":
raise ValueError(
@@ -138,32 +180,20 @@ def render(cfg, env):
password = scalar(env.get("ARI_PASSWORD"), "ARI_PASSWORD", secret=True)
if len(password) < 32:
raise ValueError("ARI_PASSWORD must have at least 32 characters")
primary_data = cfg.get("primary", {})
primary = endpoint("provider-primary", primary_data, env)
backup_data = cfg.get("backup")
if backup_data is not None and not isinstance(backup_data, dict):
raise ValueError("backup must be an object when configured")
sections = [primary]
if backup_data:
backup = endpoint("provider-backup", backup_data, env)
if (primary_data["host"], primary_data.get("port", 5060)) == (
backup_data["host"],
backup_data.get("port", 5060),
):
raise ValueError(
"primary and backup targets must be distinct; shared failure domains still require validation"
)
sections.append(backup)
trunks = _trunks(cfg)
sections = [endpoint(name, data, env) for name, data in trunks]
primary_name, primary_data = trunks[0]
transport = f"[global]\ntype=global\nuser_agent=agent-call\n\n[transport-udp]\ntype=transport\nprotocol=udp\nbind=0.0.0.0:5060\nlocal_net={network}\nexternal_signaling_address={PUBLIC_IP}\nexternal_media_address={PUBLIC_IP}\n\n"
caller = primary_data.get("caller_id") or primary_data.get("from_user")
extensions = "[deny-inbound]\nexten => s,1,Hangup()\nexten => _.,1,Hangup()\n"
if caller:
caller = scalar(caller, "primary.caller_id")
caller = scalar(caller, f"{primary_name}.caller_id")
extensions += (
"\n[outbound]\n"
"exten => _X.,1,NoOp(agent-call outbound)\n"
"; ARI Cell calls select an approved endpoint with PJSIP/<number>@<trunk_id>.\n"
"exten => _X.,1,NoOp(agent-call outbound fallback)\n"
f" same => n,Set(CALLERID(all)={caller})\n"
" same => n,Dial(PJSIP/${EXTEN}@provider-primary,60)\n"
f" same => n,Dial(PJSIP/${{EXTEN}}@{primary_name},60)\n"
" same => n,Hangup()\n"
)
return {