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

@@ -2,8 +2,9 @@ import { Module } from "@nestjs/common";
import { HealthModule } from "./health/health.module";
import { AuthModule } from "./auth/auth.module";
import { ExtensionsModule } from "./extensions/extensions.module";
import { TrunksModule } from "./trunks/trunks.module";
@Module({
imports: [HealthModule, AuthModule, ExtensionsModule],
imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule],
})
export class AppModule {}

View File

@@ -0,0 +1,124 @@
import {
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Max,
MaxLength,
Min,
} from "class-validator";
export class CreateTrunkDto {
@IsString()
@MaxLength(80)
name!: string;
@IsOptional()
@IsString()
@MaxLength(255)
description?: string;
@IsString()
@MaxLength(255)
host!: string;
@IsOptional()
@IsString()
@MaxLength(255)
proxy?: string;
@IsOptional()
@IsString()
@MaxLength(255)
realm?: string;
@IsOptional()
@IsBoolean()
register?: boolean;
@IsOptional()
@IsString()
@MaxLength(120)
username?: string;
@IsOptional()
@IsString()
@MaxLength(255)
password?: string;
@IsOptional()
@IsString()
@MaxLength(120)
fromUser?: string;
@IsOptional()
@IsString()
@MaxLength(255)
fromDomain?: string;
@IsOptional()
@IsString()
@MaxLength(255)
registerProxy?: string;
@IsOptional()
@IsString()
@MaxLength(255)
outboundProxy?: string;
@IsOptional()
@IsInt()
@Min(60)
@Max(86400)
expireSeconds?: number;
@IsOptional()
@IsInt()
@Min(5)
@Max(3600)
retrySeconds?: number;
@IsOptional()
@IsString()
@MaxLength(80)
callerIdName?: string;
@IsOptional()
@IsString()
@MaxLength(20)
callerIdNumber?: string;
@IsOptional()
@IsIn(["RFC2833", "INFO", "INBAND"])
dtmfMode?: "RFC2833" | "INFO" | "INBAND";
@IsOptional()
@IsBoolean()
ping?: boolean;
@IsOptional()
@IsInt()
@Min(5)
@Max(600)
pingFrequency?: number;
@IsOptional()
@IsIn(["UDP", "TCP", "TLS"])
transport?: "UDP" | "TCP" | "TLS";
@IsOptional()
@IsString()
@MaxLength(80)
inboundContext?: string;
@IsOptional()
@IsInt()
@Min(1)
maxCps?: number;
@IsOptional()
@IsInt()
@Min(1)
maxChannels?: number;
}

View File

@@ -0,0 +1,172 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { encryptSecret } from "@b2bcall/shared";
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
import { PermissionGuard } from "../common/guards/permission.guard";
import { RequirePermission } from "../common/decorators/require-permission.decorator";
import { CurrentUser } from "../common/decorators/current-user.decorator";
import { getRedisClient } from "../common/redis";
import { CreateTrunkDto } from "./dto/create-trunk.dto";
const TRUNKS_SYNC_CHANNEL = "b2bcall:trunks:sync";
function toPublicTrunk(trunk: {
id: string;
name: string;
description: string | null;
sofiaProfile: string;
host: string;
proxy: string | null;
realm: string | null;
register: boolean;
username: string | null;
passwordEnc: string | null;
fromUser: string | null;
fromDomain: string | null;
registerProxy: string | null;
outboundProxy: string | null;
expireSeconds: number;
retrySeconds: number;
callerIdName: string | null;
callerIdNumber: string | null;
codecs: string;
dtmfMode: string;
ping: boolean;
pingFrequency: number;
transport: string;
inboundContext: string;
maxCps: number | null;
maxChannels: number | null;
enabled: boolean;
status: string;
statusUpdatedAt: Date | null;
createdAt: Date;
}) {
// password NUNCA sai daqui — mesmo padrao de Extension (agente.md secao 178).
const { passwordEnc: _passwordEnc, ...rest } = trunk;
return rest;
}
async function notifyTrunksChanged(): Promise<void> {
await getRedisClient().publish(TRUNKS_SYNC_CHANNEL, "sync");
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("trunks")
export class TrunksController {
@RequirePermission("trunks.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateTrunkDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const trunk = await withTenantContext(prisma, tenantId, (tx) =>
tx.trunk.create({
data: {
tenantId,
name: dto.name,
description: dto.description,
host: dto.host,
proxy: dto.proxy,
realm: dto.realm,
register: dto.register ?? true,
username: dto.username,
passwordEnc: dto.password ? encryptSecret(dto.password) : null,
fromUser: dto.fromUser,
fromDomain: dto.fromDomain,
registerProxy: dto.registerProxy,
outboundProxy: dto.outboundProxy,
expireSeconds: dto.expireSeconds ?? 3600,
retrySeconds: dto.retrySeconds ?? 30,
callerIdName: dto.callerIdName,
callerIdNumber: dto.callerIdNumber,
dtmfMode: dto.dtmfMode ?? "RFC2833",
ping: dto.ping ?? true,
pingFrequency: dto.pingFrequency ?? 30,
transport: dto.transport ?? "UDP",
inboundContext: dto.inboundContext ?? "default",
maxCps: dto.maxCps,
maxChannels: dto.maxChannels,
},
}),
);
await recordAuditEvent(prisma, {
action: "TRUNK_CREATE",
tenantId,
userId: user.sub,
entityType: "trunk",
entityId: trunk.id,
after: { name: trunk.name, host: trunk.host },
});
await notifyTrunksChanged();
return toPublicTrunk(trunk);
}
@RequirePermission("trunks.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const trunks = await withTenantContext(prisma, tenantId, (tx) =>
tx.trunk.findMany({ where: { deletedAt: null }, orderBy: { name: "asc" } }),
);
return trunks.map(toPublicTrunk);
}
@RequirePermission("trunks.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const trunk = await withTenantContext(prisma, tenantId, (tx) =>
tx.trunk.findFirst({ where: { id, deletedAt: null } }),
);
if (!trunk) {
throw new NotFoundException();
}
return toPublicTrunk(trunk);
}
@RequirePermission("trunks.manage")
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const result = await withTenantContext(prisma, tenantId, (tx) =>
tx.trunk.updateMany({
where: { id, deletedAt: null },
data: { deletedAt: new Date(), enabled: false },
}),
);
if (result.count === 0) {
throw new NotFoundException();
}
await recordAuditEvent(prisma, {
action: "TRUNK_DELETE",
tenantId,
userId: user.sub,
entityType: "trunk",
entityId: id,
});
await notifyTrunksChanged();
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from "@nestjs/common";
import { TrunksController } from "./trunks.controller";
@Module({
controllers: [TrunksController],
})
export class TrunksModule {}

View File

@@ -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",

View File

@@ -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) => {

View 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) });
}
}

View File

@@ -3,7 +3,7 @@
# Build a partir da raiz do monorepo (context: .), só com os pacotes que
# este serviço realmente usa. Roda via `tsx` direto (sem etapa de `tsc build`
# nem dist/): os pacotes internos (@b2bcall/shared, @b2bcall/telephony) ainda
# não tem pipeline de build próprio — ver docs/FREESWITCH_EVENTS.md.
# não tem pipeline de build próprio — ver docs/EVENT_SOCKET.md.
FROM node:22-slim
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
@@ -14,10 +14,15 @@ COPY pnpm-workspace.yaml package.json pnpm-lock.yaml tsconfig.base.json ./
COPY packages/types packages/types
COPY packages/shared packages/shared
COPY packages/telephony packages/telephony
COPY packages/database packages/database
COPY apps/freeswitch-events apps/freeswitch-events
RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-events...
# `prisma generate` só precisa do schema, não de uma conexão real.
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
RUN pnpm --filter @b2bcall/database exec prisma generate
WORKDIR /repo/apps/freeswitch-events
CMD ["pnpm", "exec", "tsx", "src/main.ts"]

View File

@@ -9,6 +9,7 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@b2bcall/database": "workspace:*",
"@b2bcall/shared": "workspace:*",
"@b2bcall/telephony": "workspace:*",
"esl": "11.2.1",

View File

@@ -2,6 +2,7 @@ import Redis from "ioredis";
import type { FreeSwitchEventData } from "esl";
import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony";
import { createLogger } from "@b2bcall/shared";
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
const logger = createLogger("b2bcall-fs-events");
@@ -92,6 +93,14 @@ async function main() {
callUuid: normalized.callUuid,
tenantId: normalized.tenantId,
});
if (normalized.type === "GATEWAY_UP" || normalized.type === "GATEWAY_DOWN") {
const gateway = normalized.data.gateway as string | undefined;
const state = normalized.data.state as string | undefined;
updateTrunkStatusFromGatewayEvent(gateway, state).catch((err) => {
logger.error("falha ao atualizar status do trunk", { error: String(err), gateway });
});
}
}
provider.connect();

View File

@@ -0,0 +1,48 @@
import { getPrismaClient, withTenantContext, type TrunkStatus } from "@b2bcall/database";
import { createLogger } from "@b2bcall/shared";
const logger = createLogger("b2bcall-fs-events");
const GATEWAY_STATE_MAP: Record<string, TrunkStatus> = {
UP: "UP",
REGED: "REGISTERED",
TRYING: "TRYING",
REGISTER: "TRYING",
FAILED: "FAILED",
FAIL_WAIT: "FAILED",
DOWN: "DOWN",
NOREG: "UNREGISTERED",
UNREGED: "UNREGISTERED",
};
/**
* O gateway name no FreeSWITCH é o Trunk.id (ver buildGatewayXml em
* packages/telephony) — mas RLS exige contexto de tenant, e o evento não
* diz de qual tenant é. Como o número de tenants é pequeno, procura em
* cada um até achar (mesmo padrão de fan-out usado em fs-config para
* sincronizar trunks).
*/
export async function updateTrunkStatusFromGatewayEvent(
gatewayName: string | undefined,
rawState: string | undefined,
): Promise<void> {
if (!gatewayName || !rawState) return;
const status = GATEWAY_STATE_MAP[rawState.toUpperCase()] ?? "UNKNOWN";
const prisma = getPrismaClient();
const tenants = await prisma.tenant.findMany({ where: { status: "ACTIVE" }, select: { id: true } });
for (const tenant of tenants) {
const result = await withTenantContext(prisma, tenant.id, (tx) =>
tx.trunk.updateMany({
where: { id: gatewayName, tenantId: tenant.id },
data: { status, statusUpdatedAt: new Date() },
}),
);
if (result.count > 0) {
logger.info("status do trunk atualizado", { trunkId: gatewayName, status, rawState });
return;
}
}
}