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:
@@ -13,7 +13,8 @@
|
||||
"@b2bcall/shared": "workspace:*",
|
||||
"@b2bcall/telephony": "workspace:*",
|
||||
"@fastify/formbody": "^8.0.1",
|
||||
"fastify": "5.12.1"
|
||||
"fastify": "5.12.1",
|
||||
"ioredis": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import Fastify from "fastify";
|
||||
import formbody from "@fastify/formbody";
|
||||
import Redis from "ioredis";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { decryptSecret } from "@b2bcall/shared";
|
||||
import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
|
||||
import { createLogger } from "@b2bcall/shared";
|
||||
import { syncTrunks } from "./trunk-sync";
|
||||
|
||||
const logger = createLogger("b2bcall-fs-config");
|
||||
|
||||
const TRUNKS_SYNC_CHANNEL = "b2bcall:trunks:sync";
|
||||
|
||||
function requireEnv(name: string): string {
|
||||
const value = process.env[name];
|
||||
if (!value) {
|
||||
@@ -120,6 +124,18 @@ async function main() {
|
||||
const port = Number(process.env.PORT ?? 8080);
|
||||
await app.listen({ port, host: "0.0.0.0" });
|
||||
logger.info(`b2bcall-fs-config ouvindo na porta ${port}`);
|
||||
|
||||
// apps/api publica no canal apos criar/editar/apagar um trunk (agente.md
|
||||
// secao 41-42). Roda uma sincronizacao inicial tambem, pra cobrir trunks
|
||||
// criados enquanto este servico estava fora do ar.
|
||||
const subscriber = new Redis(process.env.REDIS_URL!);
|
||||
subscriber.on("error", (err) => logger.error("erro na conexao Redis (subscriber)", { error: String(err) }));
|
||||
await subscriber.subscribe(TRUNKS_SYNC_CHANNEL);
|
||||
subscriber.on("message", (_channel, _msg) => {
|
||||
syncTrunks().catch((err) => logger.error("falha ao sincronizar trunks", { error: String(err) }));
|
||||
});
|
||||
|
||||
syncTrunks().catch((err) => logger.error("falha na sincronizacao inicial de trunks", { error: String(err) }));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
|
||||
100
apps/freeswitch-config/src/trunk-sync.ts
Normal file
100
apps/freeswitch-config/src/trunk-sync.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { mkdir, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { decryptSecret } from "@b2bcall/shared";
|
||||
import { buildGatewayXml, FreeSwitchTelephonyProvider } from "@b2bcall/telephony";
|
||||
import { createLogger } from "@b2bcall/shared";
|
||||
|
||||
const logger = createLogger("b2bcall-fs-config");
|
||||
|
||||
const GATEWAYS_DIR = process.env.SOFIA_EXTERNAL_GATEWAYS_DIR ?? "/gateways";
|
||||
|
||||
let provider: FreeSwitchTelephonyProvider | undefined;
|
||||
|
||||
function getProvider(): FreeSwitchTelephonyProvider {
|
||||
if (!provider) {
|
||||
provider = new FreeSwitchTelephonyProvider({
|
||||
host: process.env.ESL_HOST ?? "freeswitch",
|
||||
port: Number(process.env.ESL_PORT ?? 8021),
|
||||
password: process.env.ESL_PASSWORD ?? "",
|
||||
logger: {
|
||||
debug: () => {},
|
||||
info: (msg) => logger.debug(msg),
|
||||
error: (msg, data) => logger.error(msg, { detail: data }),
|
||||
},
|
||||
});
|
||||
provider.connect();
|
||||
}
|
||||
return provider;
|
||||
}
|
||||
|
||||
/**
|
||||
* Regera todos os arquivos de gateway (um por trunk habilitado, em todos os
|
||||
* tenants) e manda o FreeSWITCH reler o profile "external" — agente.md
|
||||
* secao 41-42. Reescreve o diretório inteiro a cada sync (mais simples e
|
||||
* correto que tentar diffar incrementalmente).
|
||||
*/
|
||||
export async function syncTrunks(): Promise<void> {
|
||||
const prisma = getPrismaClient();
|
||||
|
||||
const tenants = await prisma.tenant.findMany({ where: { status: "ACTIVE" } });
|
||||
|
||||
const files: Array<{ name: string; xml: string }> = [];
|
||||
for (const tenant of tenants) {
|
||||
const trunks = await withTenantContext(prisma, tenant.id, (tx) =>
|
||||
tx.trunk.findMany({ where: { tenantId: tenant.id, enabled: true, deletedAt: null } }),
|
||||
);
|
||||
for (const trunk of trunks) {
|
||||
const xml = buildGatewayXml({
|
||||
gatewayName: trunk.id,
|
||||
host: trunk.host,
|
||||
proxy: trunk.proxy ?? undefined,
|
||||
realm: trunk.realm ?? undefined,
|
||||
register: trunk.register,
|
||||
username: trunk.username ?? undefined,
|
||||
password: trunk.passwordEnc ? decryptSecret(trunk.passwordEnc) : undefined,
|
||||
fromUser: trunk.fromUser ?? undefined,
|
||||
fromDomain: trunk.fromDomain ?? undefined,
|
||||
registerProxy: trunk.registerProxy ?? undefined,
|
||||
outboundProxy: trunk.outboundProxy ?? undefined,
|
||||
expireSeconds: trunk.expireSeconds,
|
||||
retrySeconds: trunk.retrySeconds,
|
||||
callerIdName: trunk.callerIdName ?? undefined,
|
||||
callerIdNumber: trunk.callerIdNumber ?? undefined,
|
||||
dtmfMode: trunk.dtmfMode,
|
||||
ping: trunk.ping,
|
||||
pingFrequency: trunk.pingFrequency,
|
||||
transport: trunk.transport,
|
||||
});
|
||||
files.push({ name: `${trunk.id}.xml`, xml });
|
||||
}
|
||||
}
|
||||
|
||||
await mkdir(GATEWAYS_DIR, { recursive: true });
|
||||
const existing = await readdir(GATEWAYS_DIR).catch(() => [] as string[]);
|
||||
const wanted = new Set(files.map((f) => f.name));
|
||||
|
||||
await Promise.all(
|
||||
existing.filter((f) => !wanted.has(f)).map((f) => rm(join(GATEWAYS_DIR, f))),
|
||||
);
|
||||
await Promise.all(files.map((f) => writeFile(join(GATEWAYS_DIR, f.name), f.xml, "utf8")));
|
||||
|
||||
logger.info("gateways sincronizados", { count: files.length });
|
||||
|
||||
try {
|
||||
const provider = getProvider();
|
||||
// No boot, o primeiro sync pode correr antes da conexao ESL terminar
|
||||
// de se estabelecer — espera um pouco em vez de falhar na hora.
|
||||
const connected = await provider.waitUntilConnected(5000);
|
||||
if (!connected) {
|
||||
logger.warn("ESL ainda nao conectado, rescan sera tentado na proxima sincronizacao");
|
||||
return;
|
||||
}
|
||||
// "rescan" relê os arquivos de gateway do profile sem derrubar chamadas
|
||||
// em andamento (diferente de "restart").
|
||||
const result = await provider.runApi("sofia profile external rescan");
|
||||
logger.info("sofia profile external rescan executado", { result: result.trim() });
|
||||
} catch (err) {
|
||||
logger.error("falha ao rodar rescan no FreeSWITCH apos sync de trunks", { error: String(err) });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user