feat: implement Trunks with real FreeSWITCH gateway sync

- 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
This commit is contained in:
2026-08-28 08:01:56 -03:00
parent c03c6d4eaa
commit 4c638ad496
20 changed files with 896 additions and 6 deletions

View File

@@ -0,0 +1,63 @@
-- CreateEnum
CREATE TYPE "trunk_status" AS ENUM ('UP', 'DOWN', 'REGISTERED', 'TRYING', 'FAILED', 'UNREGISTERED', 'UNKNOWN');
-- CreateEnum
CREATE TYPE "dtmf_mode" AS ENUM ('RFC2833', 'INFO', 'INBAND');
-- CreateEnum
CREATE TYPE "sip_transport" AS ENUM ('UDP', 'TCP', 'TLS');
-- CreateTable
CREATE TABLE "trunks" (
"id" UUID NOT NULL,
"tenant_id" UUID NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"sofia_profile" TEXT NOT NULL DEFAULT 'external',
"host" TEXT NOT NULL,
"proxy" TEXT,
"realm" TEXT,
"register" BOOLEAN NOT NULL DEFAULT true,
"username" TEXT,
"password_enc" TEXT,
"from_user" TEXT,
"from_domain" TEXT,
"register_proxy" TEXT,
"outbound_proxy" TEXT,
"expire_seconds" INTEGER NOT NULL DEFAULT 3600,
"retry_seconds" INTEGER NOT NULL DEFAULT 30,
"caller_id_name" TEXT,
"caller_id_number" TEXT,
"codecs" TEXT NOT NULL DEFAULT 'PCMU,PCMA,OPUS',
"dtmf_mode" "dtmf_mode" NOT NULL DEFAULT 'RFC2833',
"ping" BOOLEAN NOT NULL DEFAULT true,
"ping_frequency" INTEGER NOT NULL DEFAULT 30,
"transport" "sip_transport" NOT NULL DEFAULT 'UDP',
"inbound_context" TEXT NOT NULL DEFAULT 'default',
"max_cps" INTEGER,
"max_channels" INTEGER,
"enabled" BOOLEAN NOT NULL DEFAULT true,
"status" "trunk_status" NOT NULL DEFAULT 'UNKNOWN',
"status_updated_at" TIMESTAMP(3),
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
"deleted_at" TIMESTAMP(3),
CONSTRAINT "trunks_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "trunks_tenant_id_idx" ON "trunks"("tenant_id");
-- CreateIndex
CREATE UNIQUE INDEX "trunks_tenant_id_name_key" ON "trunks"("tenant_id", "name");
-- AddForeignKey
ALTER TABLE "trunks" ADD CONSTRAINT "trunks_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- Tabela de negocio tenant-scoped: RLS obrigatorio (ver docs/TENANT_ISOLATION.md).
ALTER TABLE "trunks" ENABLE ROW LEVEL SECURITY;
ALTER TABLE "trunks" FORCE ROW LEVEL SECURITY;
CREATE POLICY "tenant_isolation" ON "trunks"
USING (tenant_id = NULLIF(current_setting('app.current_tenant_id', true), '')::uuid);

View File

@@ -35,6 +35,7 @@ model Tenant {
memberships TenantMembership[]
userRoles UserRole[]
extensions Extension[]
trunks Trunk[]
@@map("tenants")
}
@@ -217,3 +218,96 @@ model Extension {
@@index([tenantId])
@@map("extensions")
}
enum TrunkStatus {
UP
DOWN
REGISTERED
TRYING
FAILED
UNREGISTERED
UNKNOWN
@@map("trunk_status")
}
enum DtmfMode {
RFC2833
INFO
INBAND
@@map("dtmf_mode")
}
enum SipTransport {
UDP
TCP
TLS
@@map("sip_transport")
}
// Tabela tenant-scoped protegida por Row Level Security. passwordEnc guarda
// a senha do tronco cifrada (AES-256-GCM), como sipPasswordEnc em Extension
// (agente.md secao 41, 178).
model Trunk {
id String @id @default(uuid()) @db.Uuid
tenantId String @map("tenant_id") @db.Uuid
name String
description String?
sofiaProfile String @default("external") @map("sofia_profile")
host String
proxy String?
realm String?
register Boolean @default(true)
username String?
passwordEnc String? @map("password_enc")
fromUser String? @map("from_user")
fromDomain String? @map("from_domain")
registerProxy String? @map("register_proxy")
outboundProxy String? @map("outbound_proxy")
expireSeconds Int @default(3600) @map("expire_seconds")
retrySeconds Int @default(30) @map("retry_seconds")
callerIdName String? @map("caller_id_name")
callerIdNumber String? @map("caller_id_number")
codecs String @default("PCMU,PCMA,OPUS")
dtmfMode DtmfMode @default(RFC2833) @map("dtmf_mode")
ping Boolean @default(true)
pingFrequency Int @default(30) @map("ping_frequency")
transport SipTransport @default(UDP)
inboundContext String @default("default") @map("inbound_context")
maxCps Int? @map("max_cps")
maxChannels Int? @map("max_channels")
enabled Boolean @default(true)
// Refletido pelos eventos sofia::gateway_state (agente.md secao 42) —
// b2bcall-fs-events atualiza isso, nunca escrito manualmente pela API.
status TrunkStatus @default(UNKNOWN)
statusUpdatedAt DateTime? @map("status_updated_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
tenant Tenant @relation(fields: [tenantId], references: [id])
@@unique([tenantId, name])
@@index([tenantId])
@@map("trunks")
}

View File

@@ -54,6 +54,22 @@ export class FreeSwitchTelephonyProvider implements TelephonyProvider {
await this.client.end();
}
/**
* Espera a primeira conexão ficar pronta (ou já estar pronta), com
* timeout. Útil pra evitar corrida entre "acabei de chamar connect()" e
* "já quero mandar um comando" logo no boot do serviço.
*/
async waitUntilConnected(timeoutMs = 5000): Promise<boolean> {
if (this.current) return true;
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), timeoutMs);
this.client.once("connect", () => {
clearTimeout(timer);
resolve(true);
});
});
}
private call(): FreeSwitchResponse {
if (!this.current) {
throw new Error("FreeSWITCH ESL nao conectado");
@@ -144,4 +160,15 @@ export class FreeSwitchTelephonyProvider implements TelephonyProvider {
async reloadXml(): Promise<void> {
await this.call().api("reloadxml");
}
/**
* Escape hatch pra comandos `api` que não têm método dedicado na
* interface TelephonyProvider (ex.: `sofia profile external rescan`).
* Não faz parte da interface abstrata de proposito — usar com moderação,
* preferir os métodos tipados quando existirem.
*/
async runApi(command: string): Promise<string> {
const res = await this.call().api(command);
return res.body;
}
}

View File

@@ -0,0 +1,75 @@
function xmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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>`;
}

View File

@@ -2,3 +2,4 @@ export * from "./types";
export * from "./normalize-event";
export * from "./freeswitch-provider";
export * from "./directory-xml";
export * from "./gateway-xml";