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:
33
TODO.md
33
TODO.md
@@ -109,7 +109,38 @@
|
|||||||
- [ ] Quota de ramais — depende de Plans/Entitlements (não existe ainda)
|
- [ ] 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
|
- [ ] 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/<trunk_id>.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)
|
Predictive Dialer, Recordings, AI, Billing, Frontend, Reports, Security, Tests)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -2,8 +2,9 @@ import { Module } from "@nestjs/common";
|
|||||||
import { HealthModule } from "./health/health.module";
|
import { HealthModule } from "./health/health.module";
|
||||||
import { AuthModule } from "./auth/auth.module";
|
import { AuthModule } from "./auth/auth.module";
|
||||||
import { ExtensionsModule } from "./extensions/extensions.module";
|
import { ExtensionsModule } from "./extensions/extensions.module";
|
||||||
|
import { TrunksModule } from "./trunks/trunks.module";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HealthModule, AuthModule, ExtensionsModule],
|
imports: [HealthModule, AuthModule, ExtensionsModule, TrunksModule],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
124
apps/api/src/trunks/dto/create-trunk.dto.ts
Normal file
124
apps/api/src/trunks/dto/create-trunk.dto.ts
Normal 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;
|
||||||
|
}
|
||||||
172
apps/api/src/trunks/trunks.controller.ts
Normal file
172
apps/api/src/trunks/trunks.controller.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
7
apps/api/src/trunks/trunks.module.ts
Normal file
7
apps/api/src/trunks/trunks.module.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { Module } from "@nestjs/common";
|
||||||
|
import { TrunksController } from "./trunks.controller";
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
controllers: [TrunksController],
|
||||||
|
})
|
||||||
|
export class TrunksModule {}
|
||||||
@@ -13,7 +13,8 @@
|
|||||||
"@b2bcall/shared": "workspace:*",
|
"@b2bcall/shared": "workspace:*",
|
||||||
"@b2bcall/telephony": "workspace:*",
|
"@b2bcall/telephony": "workspace:*",
|
||||||
"@fastify/formbody": "^8.0.1",
|
"@fastify/formbody": "^8.0.1",
|
||||||
"fastify": "5.12.1"
|
"fastify": "5.12.1",
|
||||||
|
"ioredis": "^6.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.0.0",
|
"@types/node": "^22.0.0",
|
||||||
|
|||||||
@@ -1,13 +1,17 @@
|
|||||||
import { timingSafeEqual } from "node:crypto";
|
import { timingSafeEqual } from "node:crypto";
|
||||||
import Fastify from "fastify";
|
import Fastify from "fastify";
|
||||||
import formbody from "@fastify/formbody";
|
import formbody from "@fastify/formbody";
|
||||||
|
import Redis from "ioredis";
|
||||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||||
import { decryptSecret } from "@b2bcall/shared";
|
import { decryptSecret } from "@b2bcall/shared";
|
||||||
import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
|
import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
|
||||||
import { createLogger } from "@b2bcall/shared";
|
import { createLogger } from "@b2bcall/shared";
|
||||||
|
import { syncTrunks } from "./trunk-sync";
|
||||||
|
|
||||||
const logger = createLogger("b2bcall-fs-config");
|
const logger = createLogger("b2bcall-fs-config");
|
||||||
|
|
||||||
|
const TRUNKS_SYNC_CHANNEL = "b2bcall:trunks:sync";
|
||||||
|
|
||||||
function requireEnv(name: string): string {
|
function requireEnv(name: string): string {
|
||||||
const value = process.env[name];
|
const value = process.env[name];
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -120,6 +124,18 @@ async function main() {
|
|||||||
const port = Number(process.env.PORT ?? 8080);
|
const port = Number(process.env.PORT ?? 8080);
|
||||||
await app.listen({ port, host: "0.0.0.0" });
|
await app.listen({ port, host: "0.0.0.0" });
|
||||||
logger.info(`b2bcall-fs-config ouvindo na porta ${port}`);
|
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) => {
|
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) });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
# Build a partir da raiz do monorepo (context: .), só com os pacotes que
|
# 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`
|
# este serviço realmente usa. Roda via `tsx` direto (sem etapa de `tsc build`
|
||||||
# nem dist/): os pacotes internos (@b2bcall/shared, @b2bcall/telephony) ainda
|
# 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
|
FROM node:22-slim
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@11.24.0 --activate
|
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/types packages/types
|
||||||
COPY packages/shared packages/shared
|
COPY packages/shared packages/shared
|
||||||
COPY packages/telephony packages/telephony
|
COPY packages/telephony packages/telephony
|
||||||
|
COPY packages/database packages/database
|
||||||
COPY apps/freeswitch-events apps/freeswitch-events
|
COPY apps/freeswitch-events apps/freeswitch-events
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile --filter @b2bcall/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
|
WORKDIR /repo/apps/freeswitch-events
|
||||||
|
|
||||||
CMD ["pnpm", "exec", "tsx", "src/main.ts"]
|
CMD ["pnpm", "exec", "tsx", "src/main.ts"]
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@b2bcall/database": "workspace:*",
|
||||||
"@b2bcall/shared": "workspace:*",
|
"@b2bcall/shared": "workspace:*",
|
||||||
"@b2bcall/telephony": "workspace:*",
|
"@b2bcall/telephony": "workspace:*",
|
||||||
"esl": "11.2.1",
|
"esl": "11.2.1",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import Redis from "ioredis";
|
|||||||
import type { FreeSwitchEventData } from "esl";
|
import type { FreeSwitchEventData } from "esl";
|
||||||
import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony";
|
import { FreeSwitchTelephonyProvider, normalizeEslEvent } from "@b2bcall/telephony";
|
||||||
import { createLogger } from "@b2bcall/shared";
|
import { createLogger } from "@b2bcall/shared";
|
||||||
|
import { updateTrunkStatusFromGatewayEvent } from "./trunk-status";
|
||||||
|
|
||||||
const logger = createLogger("b2bcall-fs-events");
|
const logger = createLogger("b2bcall-fs-events");
|
||||||
|
|
||||||
@@ -92,6 +93,14 @@ async function main() {
|
|||||||
callUuid: normalized.callUuid,
|
callUuid: normalized.callUuid,
|
||||||
tenantId: normalized.tenantId,
|
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();
|
provider.connect();
|
||||||
|
|||||||
48
apps/freeswitch-events/src/trunk-status.ts
Normal file
48
apps/freeswitch-events/src/trunk-status.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,13 +40,24 @@ services:
|
|||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
- postgres
|
- postgres
|
||||||
|
- redis
|
||||||
environment:
|
environment:
|
||||||
# Hostname interno do compose (postgres), nao localhost — ver
|
# Hostname interno do compose (postgres/redis/freeswitch), nao
|
||||||
# docs/NETWORK_ARCHITECTURE.md.
|
# localhost — ver docs/NETWORK_ARCHITECTURE.md.
|
||||||
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
APP_DATABASE_URL: postgresql://${POSTGRES_APP_USER}:${POSTGRES_APP_PASSWORD}@postgres:5432/${POSTGRES_DB}?schema=public
|
||||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
ENCRYPTION_KEY: ${ENCRYPTION_KEY}
|
||||||
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
||||||
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
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.
|
# Sem porta publicada: so o FreeSWITCH (mesma rede do compose) chama isto.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:8080/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
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}
|
ESL_PASSWORD: ${ESL_PASSWORD}
|
||||||
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
FS_CONFIG_USER: ${FS_CONFIG_USER}
|
||||||
FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD}
|
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
|
# 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
|
# configurados, e o Event Socket (8021) só deve ser alcançável por outros
|
||||||
# containers na rede interna do compose (agente.md secao 22).
|
# containers na rede interna do compose (agente.md secao 22).
|
||||||
@@ -87,10 +100,12 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- freeswitch
|
- freeswitch
|
||||||
- redis
|
- redis
|
||||||
|
- postgres
|
||||||
environment:
|
environment:
|
||||||
ESL_HOST: freeswitch
|
ESL_HOST: freeswitch
|
||||||
ESL_PORT: "8021"
|
ESL_PORT: "8021"
|
||||||
ESL_PASSWORD: ${ESL_PASSWORD}
|
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
|
# Hostnames internos do compose (freeswitch/redis), diferente do
|
||||||
# REDIS_URL do .env que aponta pra localhost (uso pelo apps/api, que
|
# REDIS_URL do .env que aponta pra localhost (uso pelo apps/api, que
|
||||||
# ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md.
|
# ainda roda no host) — ver docs/NETWORK_ARCHITECTURE.md.
|
||||||
@@ -103,3 +118,4 @@ secrets:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
freeswitch_external_gateways:
|
||||||
|
|||||||
93
docs/TRUNKS.md
Normal file
93
docs/TRUNKS.md
Normal file
@@ -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 `<trunk_id>.xml` por trunk habilitado em
|
||||||
|
`sip_profiles/external/` (volume Docker compartilhado com o FreeSWITCH —
|
||||||
|
o profile `external` da config vanilla já tem
|
||||||
|
`<X-PRE-PROCESS cmd="include" data="external/*.xml"/>`, 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.
|
||||||
@@ -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);
|
||||||
@@ -35,6 +35,7 @@ model Tenant {
|
|||||||
memberships TenantMembership[]
|
memberships TenantMembership[]
|
||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
extensions Extension[]
|
extensions Extension[]
|
||||||
|
trunks Trunk[]
|
||||||
|
|
||||||
@@map("tenants")
|
@@map("tenants")
|
||||||
}
|
}
|
||||||
@@ -217,3 +218,96 @@ model Extension {
|
|||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@map("extensions")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,6 +54,22 @@ export class FreeSwitchTelephonyProvider implements TelephonyProvider {
|
|||||||
await this.client.end();
|
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 {
|
private call(): FreeSwitchResponse {
|
||||||
if (!this.current) {
|
if (!this.current) {
|
||||||
throw new Error("FreeSWITCH ESL nao conectado");
|
throw new Error("FreeSWITCH ESL nao conectado");
|
||||||
@@ -144,4 +160,15 @@ export class FreeSwitchTelephonyProvider implements TelephonyProvider {
|
|||||||
async reloadXml(): Promise<void> {
|
async reloadXml(): Promise<void> {
|
||||||
await this.call().api("reloadxml");
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
75
packages/telephony/src/gateway-xml.ts
Normal file
75
packages/telephony/src/gateway-xml.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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>`;
|
||||||
|
}
|
||||||
@@ -2,3 +2,4 @@ export * from "./types";
|
|||||||
export * from "./normalize-event";
|
export * from "./normalize-event";
|
||||||
export * from "./freeswitch-provider";
|
export * from "./freeswitch-provider";
|
||||||
export * from "./directory-xml";
|
export * from "./directory-xml";
|
||||||
|
export * from "./gateway-xml";
|
||||||
|
|||||||
6
pnpm-lock.yaml
generated
6
pnpm-lock.yaml
generated
@@ -87,6 +87,9 @@ importers:
|
|||||||
fastify:
|
fastify:
|
||||||
specifier: 5.12.1
|
specifier: 5.12.1
|
||||||
version: 5.12.1
|
version: 5.12.1
|
||||||
|
ioredis:
|
||||||
|
specifier: ^6.0.0
|
||||||
|
version: 6.0.0
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.0.0
|
specifier: ^22.0.0
|
||||||
@@ -100,6 +103,9 @@ importers:
|
|||||||
|
|
||||||
apps/freeswitch-events:
|
apps/freeswitch-events:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@b2bcall/database':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/database
|
||||||
'@b2bcall/shared':
|
'@b2bcall/shared':
|
||||||
specifier: workspace:*
|
specifier: workspace:*
|
||||||
version: link:../../packages/shared
|
version: link:../../packages/shared
|
||||||
|
|||||||
Reference in New Issue
Block a user