diff --git a/TODO.md b/TODO.md index b9436a8..4ca5320 100644 --- a/TODO.md +++ b/TODO.md @@ -109,7 +109,38 @@ - [ ] Quota de ramais — depende de Plans/Entitlements (não existe ainda) - [ ] Multi-domínio real por tenant — hoje só um domínio fixo pra todos -## PHASE 09+ — ver `agente.md` seções 41 em diante (Trunks, Dialplan, Call Center, +## PHASE 09 — Trunks (agente.md secao 41-42) +- [x] Tabela `trunks` (tenant-scoped, RLS) — host/proxy/realm, register, + username/password_enc (AES-256-GCM), dtmf_mode, ping, transport, + status/status_updated_at +- [x] `apps/api/src/trunks`: CRUD (POST/GET/GET:id/DELETE), mesmo padrão de + RBAC/tenant de Extensions, senha nunca exposta em nenhum GET +- [x] `packages/telephony`: `buildGatewayXml()` (XML de gateway Sofia) +- [x] `b2bcall-fs-config`: gera `sip_profiles/external/.xml` (volume + Docker compartilhado com o FreeSWITCH) e roda `sofia profile external + rescan` via ESL — sincroniza no boot e sob demanda via Redis pub/sub + (`b2bcall:trunks:sync`, publicado pela API a cada create/delete) +- [x] Achado: 1º sync no boot corria antes da conexão ESL terminar de se + estabelecer (erro cosmético) — corrigido com + `FreeSwitchTelephonyProvider.waitUntilConnected()` +- [x] Testado ponta a ponta com host fake: criar trunk → arquivo gerado → + `sofia status gateway` mostra o gateway real (FAIL_WAIT, esperado) → + deletar → arquivo removido (limpeza também tirou o `example.com` da + vanilla que tinha sido copiado pro volume — comportamento correto) +- [ ] **Lacuna real, não resolvida**: `Trunk.status` deveria ser atualizado + via eventos `sofia::gateway_state` (código escrito em + `apps/freeswitch-events/src/trunk-status.ts`, baseado no mesmo + `normalizeEslEvent` já testado pra eventos CHANNEL_*), mas o evento + **não foi observado chegando** em ~90s de monitoramento mesmo com o + gateway mudando de estado de verdade no FreeSWITCH (FAIL_WAIT/DOWN). + Os eventos `sofia::*` (CUSTOM) nunca foram provados funcionando nesta + sessão — só CHANNEL_* foi verificado de ponta a ponta até agora. + Precisa de investigação com um alvo SIP real (outro FreeSWITCH, por + exemplo) antes de confiar em atualização automática de status em + produção. Ver docs/TRUNKS.md. +- [ ] Quota de troncos — depende de Plans/Entitlements (não existe ainda) + +## PHASE 10+ — ver `agente.md` seções 43 em diante (Dialplan, Call Center, Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests) --- diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 70f924f..cbed7a9 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/trunks/dto/create-trunk.dto.ts b/apps/api/src/trunks/dto/create-trunk.dto.ts new file mode 100644 index 0000000..09e61bc --- /dev/null +++ b/apps/api/src/trunks/dto/create-trunk.dto.ts @@ -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; +} diff --git a/apps/api/src/trunks/trunks.controller.ts b/apps/api/src/trunks/trunks.controller.ts new file mode 100644 index 0000000..febf6f4 --- /dev/null +++ b/apps/api/src/trunks/trunks.controller.ts @@ -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 { + 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(); + } +} diff --git a/apps/api/src/trunks/trunks.module.ts b/apps/api/src/trunks/trunks.module.ts new file mode 100644 index 0000000..4922345 --- /dev/null +++ b/apps/api/src/trunks/trunks.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { TrunksController } from "./trunks.controller"; + +@Module({ + controllers: [TrunksController], +}) +export class TrunksModule {} diff --git a/apps/freeswitch-config/package.json b/apps/freeswitch-config/package.json index a3a2e72..bb15fc0 100644 --- a/apps/freeswitch-config/package.json +++ b/apps/freeswitch-config/package.json @@ -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", diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts index 10503c5..71f9e74 100644 --- a/apps/freeswitch-config/src/main.ts +++ b/apps/freeswitch-config/src/main.ts @@ -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) => { diff --git a/apps/freeswitch-config/src/trunk-sync.ts b/apps/freeswitch-config/src/trunk-sync.ts new file mode 100644 index 0000000..dab0818 --- /dev/null +++ b/apps/freeswitch-config/src/trunk-sync.ts @@ -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 { + 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) }); + } +} diff --git a/apps/freeswitch-events/Dockerfile b/apps/freeswitch-events/Dockerfile index 92f4da5..82579de 100644 --- a/apps/freeswitch-events/Dockerfile +++ b/apps/freeswitch-events/Dockerfile @@ -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"] diff --git a/apps/freeswitch-events/package.json b/apps/freeswitch-events/package.json index 0896444..019840a 100644 --- a/apps/freeswitch-events/package.json +++ b/apps/freeswitch-events/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@b2bcall/database": "workspace:*", "@b2bcall/shared": "workspace:*", "@b2bcall/telephony": "workspace:*", "esl": "11.2.1", diff --git a/apps/freeswitch-events/src/main.ts b/apps/freeswitch-events/src/main.ts index cb18a1a..3ebfde5 100644 --- a/apps/freeswitch-events/src/main.ts +++ b/apps/freeswitch-events/src/main.ts @@ -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(); diff --git a/apps/freeswitch-events/src/trunk-status.ts b/apps/freeswitch-events/src/trunk-status.ts new file mode 100644 index 0000000..c8399cd --- /dev/null +++ b/apps/freeswitch-events/src/trunk-status.ts @@ -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 = { + 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 { + 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; + } + } +} diff --git a/docker-compose.yml b/docker-compose.yml index e9bbe44..ffed3e0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -40,13 +40,24 @@ services: restart: unless-stopped depends_on: - postgres + - redis environment: - # Hostname interno do compose (postgres), nao localhost — ver - # docs/NETWORK_ARCHITECTURE.md. + # Hostname interno do compose (postgres/redis/freeswitch), nao + # localhost — ver docs/NETWORK_ARCHITECTURE.md. APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public ENCRYPTION_KEY: ${ENCRYPTION_KEY} FS_CONFIG_USER: ${FS_CONFIG_USER} FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD} + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + ESL_HOST: freeswitch + ESL_PORT: "8021" + ESL_PASSWORD: ${ESL_PASSWORD} + SOFIA_EXTERNAL_GATEWAYS_DIR: /gateways + volumes: + # Compartilhado com o FreeSWITCH (agente.md secao 41-42): fs-config + # escreve os XML de gateway aqui, o profile "external" ja inclui + # sip_profiles/external/*.xml automaticamente (config vanilla). + - freeswitch_external_gateways:/gateways # Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto. healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"] @@ -68,6 +79,8 @@ services: ESL_PASSWORD: ${ESL_PASSWORD} FS_CONFIG_USER: ${FS_CONFIG_USER} FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD} + volumes: + - freeswitch_external_gateways:/etc/freeswitch/sip_profiles/external # Nenhuma porta publicada no host: SIP/RTP ainda não têm troncos reais # configurados, e o Event Socket (8021) só deve ser alcançável por outros # containers na rede interna do compose (agente.md secao 22). @@ -87,10 +100,12 @@ services: depends_on: - freeswitch - redis + - postgres environment: ESL_HOST: freeswitch ESL_PORT: "8021" ESL_PASSWORD: ${ESL_PASSWORD} + APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public # Hostnames internos do compose (freeswitch/redis), diferente do # REDIS_URL do .env que aponta pra localhost (uso pelo apps/api, que # ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md. @@ -103,3 +118,4 @@ secrets: volumes: postgres_data: redis_data: + freeswitch_external_gateways: diff --git a/docs/TRUNKS.md b/docs/TRUNKS.md new file mode 100644 index 0000000..dbed45a --- /dev/null +++ b/docs/TRUNKS.md @@ -0,0 +1,93 @@ +# Trunks (Troncos SIP) + +Agente.md seções 41-42. Primeiro recurso que grava configuração real no +filesystem do FreeSWITCH (gateways Sofia) em vez de só responder via +`mod_xml_curl`. + +## Modelo + +`trunks` (tenant-scoped, RLS): `host`, `proxy`, `realm`, `register`, +`username`/`password_enc` (AES-256-GCM, mesmo padrão de `sip_password_enc`), +`from_user`/`from_domain`, `register_proxy`/`outbound_proxy`, +`expire_seconds`, `retry_seconds`, `dtmf_mode`, `ping`/`ping_frequency`, +`transport`, `max_cps`/`max_channels`, e `status`/`status_updated_at` +(refletidos pelos eventos do FreeSWITCH, nunca escritos manualmente pela API). + +## Mecanismo: por que arquivo + rescan, e não XML Curl "configuration" + +Ao contrário de Extensions (resolvido 100% via `mod_xml_curl` na hora do +lookup), gateways Sofia não têm um binding XML Curl limpo e específico sem +reativar a seção `configuration` inteira — e isso já causou problemas reais +na fase XML Curl (chamadas HTTP desnecessárias no boot pra configs de outros +módulos). Em vez disso, `b2bcall-fs-config`: + +1. Gera um arquivo `.xml` por trunk habilitado em + `sip_profiles/external/` (volume Docker compartilhado com o FreeSWITCH — + o profile `external` da config vanilla já tem + ``, então não precisou + editar a config do profile). +2. Roda `sofia profile external rescan` via ESL — relê os gateways sem + derrubar chamadas em andamento (diferente de `restart`). + +## Gatilho de sincronização + +`apps/api` não roda no Docker (ainda está no host) e `fs-config` não expõe +porta pro host — então a notificação "algo mudou, resincroniza" vai por +Redis pub/sub (`b2bcall:trunks:sync`), o mesmo mecanismo já usado pra eventos +normalizados. `apps/api` publica depois de criar/apagar um trunk; +`fs-config` também roda uma sincronização completa ao subir (cobre trunks +criados enquanto ele estava fora do ar). + +**Achado real**: a primeira sincronização no boot corria antes da conexão +ESL do `fs-config` terminar de se estabelecer, gerando um erro cosmético +("FreeSWITCH ESL nao conectado") — a escrita dos arquivos funcionava, só o +rescan falhava. Corrigido com `FreeSwitchTelephonyProvider.waitUntilConnected()` +(timeout de 5s) antes de tentar o rescan. + +## Status do trunk (secao 42) + +`b2bcall-fs-events` já escutava `sofia::gateway_state` desde a fase Event +Socket (normalizado pra `GATEWAY_UP`/`GATEWAY_DOWN`); nesta fase, ele passou +a também escrever esse estado de volta em `Trunk.status`. Como o nome do +gateway no FreeSWITCH é o `Trunk.id` (UUID) e o evento não diz de qual +tenant é, a busca percorre os tenants ativos (`packages/database`'s +`withTenantContext`) até achar o trunk dono daquele id — aceitável dado que +mudança de estado de trunk é rara, não é um evento de alto volume por +chamada. + +Mapeamento de estados brutos do Sofia pro enum interno +(`apps/freeswitch-events/src/trunk-status.ts`): + +``` +UP, REGED → REGISTERED/UP +TRYING, REGISTER → TRYING +FAILED, FAIL_WAIT → FAILED +DOWN → DOWN +NOREG, UNREGED → UNREGISTERED +qualquer outro → UNKNOWN +``` + +## Verificado + +Criei um trunk de teste apontando pra um host inexistente +(`sip.trunk-inexistente.invalid`, nunca resolve — RFC 2606) via API: + +- `fs-config` sincronizou (`count: 1`), escreveu o arquivo `.xml`, rodou o + rescan com sucesso. +- `sofia status gateway` no FreeSWITCH mostrou o gateway real, estado + `FAIL_WAIT` (esperado — host não existe). +- Confirma o pipeline `API → Postgres → fs-config → arquivo XML → rescan → + FreeSWITCH` funcionando ponta a ponta sem precisar de nenhum tronco/ + credencial real. + +## O que falta + +- Propagação de `GATEWAY_UP`/`DOWN` → `Trunk.status` não confirmada com + evento real disparado por uma mudança de estado ao vivo nesta sessão de + testes (o gateway ficou em `FAIL_WAIT` por falha de DNS, que pode não + disparar o mesmo ciclo de eventos que uma rejeição SIP normal) — vale + reverificar com um destino que responda de verdade (outro FreeSWITCH, por + exemplo) antes de confiar nisso em produção. +- Quota de troncos (`max_trunks`, secao 59) — depende de Plans/Entitlements. +- `GET /trunks` não mostra `sofia status gateway` ao vivo, só o último + status conhecido no banco — suficiente por enquanto, sem WebSocket ainda. diff --git a/packages/database/prisma/migrations/20260828104233_trunks/migration.sql b/packages/database/prisma/migrations/20260828104233_trunks/migration.sql new file mode 100644 index 0000000..9211b1c --- /dev/null +++ b/packages/database/prisma/migrations/20260828104233_trunks/migration.sql @@ -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); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index f49ef43..c963b0d 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -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") +} diff --git a/packages/telephony/src/freeswitch-provider.ts b/packages/telephony/src/freeswitch-provider.ts index a0ca4ae..1f02d03 100644 --- a/packages/telephony/src/freeswitch-provider.ts +++ b/packages/telephony/src/freeswitch-provider.ts @@ -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 { + 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 { 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 { + const res = await this.call().api(command); + return res.body; + } } diff --git a/packages/telephony/src/gateway-xml.ts b/packages/telephony/src/gateway-xml.ts new file mode 100644 index 0000000..330322f --- /dev/null +++ b/packages/telephony/src/gateway-xml.ts @@ -0,0 +1,75 @@ +function xmlEscape(value: string): string { + return value + .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 = { + RFC2833: "rfc2833", + INFO: "info", + INBAND: "inband", +}; + +/** + * XML de gateway do Sofia (agente.md secao 41). Escrito em + * sip_profiles/external/.xml pelo b2bcall-fs-config e carregado + * via `sofia profile external rescan` — o profile "external" já vem com + * `` 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("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 ` + +${lines.join("\n")} + +`; +} diff --git a/packages/telephony/src/index.ts b/packages/telephony/src/index.ts index f4dcefa..ef5f363 100644 --- a/packages/telephony/src/index.ts +++ b/packages/telephony/src/index.ts @@ -2,3 +2,4 @@ export * from "./types"; export * from "./normalize-event"; export * from "./freeswitch-provider"; export * from "./directory-xml"; +export * from "./gateway-xml"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7a545a9..d72a534 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -87,6 +87,9 @@ importers: fastify: specifier: 5.12.1 version: 5.12.1 + ioredis: + specifier: ^6.0.0 + version: 6.0.0 devDependencies: '@types/node': specifier: ^22.0.0 @@ -100,6 +103,9 @@ importers: apps/freeswitch-events: dependencies: + '@b2bcall/database': + specifier: workspace:* + version: link:../../packages/database '@b2bcall/shared': specifier: workspace:* version: link:../../packages/shared