- trunks table (tenant-scoped, RLS): host/proxy/realm, register, username/password_enc (AES-256-GCM), dtmf_mode, ping, transport, and a status/status_updated_at pair meant to be driven by FreeSWITCH events - apps/api/src/trunks: CRUD (POST/GET/GET:id/DELETE), same RBAC/tenant pattern as Extensions, password never exposed in any GET - packages/telephony: buildGatewayXml() generates a Sofia gateway XML file - b2bcall-fs-config now writes sip_profiles/external/<trunk_id>.xml (shared Docker volume with FreeSWITCH -- the vanilla external profile already includes external/*.xml) and runs 'sofia profile external rescan' over ESL; syncs on boot and on demand via Redis pub/sub (b2bcall:trunks:sync), since apps/api runs on the host and fs-config has no port published to reach directly - added FreeSwitchTelephonyProvider.waitUntilConnected() to fix a startup race: the first sync ran before the ESL connection had settled, logging a harmless but noisy error - verified end-to-end with a fake host: create trunk -> gateway file written -> FreeSWITCH shows the real gateway (FAIL_WAIT, expected) -> delete -> file removed (cleanup also correctly swept the stale 'example.com' gateway that had been copied into the volume from the vanilla image) - apps/freeswitch-events/src/trunk-status.ts: written to update Trunk.status from sofia::gateway_state events, using the same normalizeEslEvent path already proven for CHANNEL_* events - KNOWN GAP, documented rather than glossed over: monitored fs-events for ~90s while the gateway visibly transitioned states in FreeSWITCH (FAIL_WAIT/DOWN) and no sofia::gateway_state event was observed arriving. CUSTOM/sofia::* events have not actually been proven working end-to-end in this session -- only CHANNEL_* events have been. Needs verification against a real SIP target before the status auto-update can be trusted in production. See docs/TRUNKS.md and TODO.md. - docs/TRUNKS.md
76 lines
2.3 KiB
TypeScript
76 lines
2.3 KiB
TypeScript
function xmlEscape(value: string): string {
|
|
return value
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, """)
|
|
.replace(/'/g, "'");
|
|
}
|
|
|
|
export interface GatewayParams {
|
|
gatewayName: string;
|
|
host: string;
|
|
proxy?: string;
|
|
realm?: string;
|
|
register: boolean;
|
|
username?: string;
|
|
password?: string;
|
|
fromUser?: string;
|
|
fromDomain?: string;
|
|
registerProxy?: string;
|
|
outboundProxy?: string;
|
|
expireSeconds: number;
|
|
retrySeconds: number;
|
|
callerIdName?: string;
|
|
callerIdNumber?: string;
|
|
dtmfMode: "RFC2833" | "INFO" | "INBAND";
|
|
ping: boolean;
|
|
pingFrequency: number;
|
|
transport: "UDP" | "TCP" | "TLS";
|
|
}
|
|
|
|
const DTMF_VALUES: Record<GatewayParams["dtmfMode"], string> = {
|
|
RFC2833: "rfc2833",
|
|
INFO: "info",
|
|
INBAND: "inband",
|
|
};
|
|
|
|
/**
|
|
* XML de gateway do Sofia (agente.md secao 41). Escrito em
|
|
* sip_profiles/external/<trunk_id>.xml pelo b2bcall-fs-config e carregado
|
|
* via `sofia profile external rescan` — o profile "external" já vem com
|
|
* `<X-PRE-PROCESS cmd="include" data="external/*.xml"/>` na config vanilla.
|
|
*/
|
|
export function buildGatewayXml(params: GatewayParams): string {
|
|
const lines: string[] = [];
|
|
const param = (name: string, value: string | number | boolean | undefined) => {
|
|
if (value === undefined || value === "") return;
|
|
lines.push(` <param name="${name}" value="${xmlEscape(String(value))}"/>`);
|
|
};
|
|
|
|
param("username", params.username);
|
|
param("password", params.password);
|
|
param("realm", params.realm ?? params.host);
|
|
param("proxy", params.proxy ?? params.host);
|
|
param("register", params.register);
|
|
param("expire-seconds", params.expireSeconds);
|
|
param("retry-seconds", params.retrySeconds);
|
|
param("from-user", params.fromUser ?? params.username);
|
|
param("from-domain", params.fromDomain ?? params.host);
|
|
param("register-proxy", params.registerProxy);
|
|
param("outbound-proxy", params.outboundProxy);
|
|
param("caller-id-in-from", true);
|
|
param("extension", params.fromUser ?? params.username);
|
|
param("dtmf-type", DTMF_VALUES[params.dtmfMode]);
|
|
param("register-transport", params.transport.toLowerCase());
|
|
if (params.ping) {
|
|
param("ping", params.pingFrequency);
|
|
}
|
|
|
|
return `<include>
|
|
<gateway name="${xmlEscape(params.gatewayName)}">
|
|
${lines.join("\n")}
|
|
</gateway>
|
|
</include>`;
|
|
}
|