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:
@@ -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 {}
|
||||
|
||||
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 {}
|
||||
Reference in New Issue
Block a user