feat: implement Extensions with real FreeSWITCH directory integration

- extensions table (tenant-scoped, RLS): number, sip_password_enc
  (AES-256-GCM via packages/shared/src/crypto.ts), caller_id, context,
  sofia_profile, codecs, max_registrations
- apps/api/src/extensions: CRUD (POST/GET/GET:id/DELETE), protected by a
  new generic PermissionGuard (@RequirePermission decorator), tenant
  resolved only from the JWT (never trusted from the client)
- SIP password is returned in plaintext only once, in the create response;
  toPublicExtension() explicitly destructures the encrypted field out
  (not a spread) so it can't leak by accident
- b2bcall-fs-config now resolves real directory data: Tenant.telephonyDomain
  -> Extension.number, decrypts the password, builds proper directory XML
  including a dial-string param (missing it caused originate to fail with
  MANDATORY_IE_MISSING instead of the expected USER_NOT_REGISTERED)
- pinned FreeSWITCH's 357737{domain} to a stable value (b2bcall.local) via a
  vars.xml patch in the Dockerfile -- it previously used the container's
  dynamic IP, which could never match a stored telephony_domain
- added HTTP Basic auth between FreeSWITCH and fs-config
  (gateway-credentials, timingSafeEqual comparison) now that the service
  returns real secret data, closing the gap flagged as pending in the XML
  Curl phase instead of leaving it open
- found and fixed: PermissionGuard's constructor-injected Reflector came
  back undefined at runtime under tsx/esbuild (unreliable cross-file
  decorator metadata emission) -- fixed with an explicit @Inject(Reflector);
  worth watching for in future guards/services run via tsx
- verified end-to-end: create extension -> originate user/<ext> reports
  USER_NOT_REGISTERED (found, not registered) -> delete -> back to
  SUBSCRIBER_ABSENT (not found); password never reappears in any GET;
  unauthenticated fs-config requests get 401
- docs/EXTENSIONS.md
This commit is contained in:
2026-08-28 07:39:19 -03:00
parent d2ea83c06a
commit c03c6d4eaa
23 changed files with 734 additions and 24 deletions

View File

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

View File

@@ -0,0 +1,6 @@
import { SetMetadata } from "@nestjs/common";
export const PERMISSION_KEY = "permission";
/** Marca uma rota com a permission necessária (agente.md secao 145). */
export const RequirePermission = (permissionKey: string) => SetMetadata(PERMISSION_KEY, permissionKey);

View File

@@ -0,0 +1,43 @@
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { userHasPermission } from "@b2bcall/auth";
import { PERMISSION_KEY } from "../decorators/require-permission.decorator";
import type { AuthenticatedRequest } from "./jwt-auth.guard";
/**
* Roda depois do JwtAuthGuard. Exige que a rota tenha um tenant selecionado
* (agente.md secao 31: nunca confiar em tenant_id do frontend — aqui vem só
* do JWT, nunca do body/query) e que o usuário tenha a permission marcada
* via @RequirePermission() (secao 145/146).
*/
@Injectable()
export class PermissionGuard implements CanActivate {
// @Inject explicito: a metadata de tipos emitida via tsx/esbuild nao e'
// sempre confiavel o suficiente pra resolucao automatica de DI do Nest
// (esbuild nao faz checagem de tipos cross-file completa) — sem isso,
// `reflector` chega undefined em runtime.
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const permissionKey = this.reflector.get<string | undefined>(PERMISSION_KEY, context.getHandler());
if (!permissionKey) {
return true;
}
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
const user = request.user;
if (!user) {
throw new ForbiddenException("Nao autenticado");
}
if (!user.tenantId) {
throw new ForbiddenException("Nenhum tenant selecionado (use /auth/select-tenant)");
}
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId);
if (!allowed) {
throw new ForbiddenException(`Permissao necessaria: ${permissionKey}`);
}
return true;
}
}

View File

@@ -0,0 +1,36 @@
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator";
export class CreateExtensionDto {
@IsString()
@Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" })
number!: string;
@IsString()
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(80)
callerIdName?: string;
@IsOptional()
@IsString()
@Matches(/^[0-9]{2,20}$/)
callerIdNumber?: string;
@IsOptional()
@IsString()
@MaxLength(80)
context?: string;
@IsOptional()
@IsIn(["internal"])
sofiaProfile?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
maxRegistrations?: number;
}

View File

@@ -0,0 +1,148 @@
import {
BadRequestException,
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
NotFoundException,
Param,
Post,
UseGuards,
} from "@nestjs/common";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { generateStrongPassword, 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 { CreateExtensionDto } from "./dto/create-extension.dto";
function toPublicExtension(ext: {
id: string;
number: string;
name: string;
domain: string;
sipPasswordEnc: string;
callerIdName: string | null;
callerIdNumber: string | null;
context: string;
sofiaProfile: string;
codecs: string;
maxRegistrations: number;
enabled: boolean;
createdAt: Date;
}) {
// sipPasswordEnc NUNCA sai daqui — agente.md secao 39: "Nunca mostrar
// novamente a senha inteira". Destructuring explícito (não spread) pra
// garantir que o campo é de fato removido, não só "esquecido" no tipo.
const { sipPasswordEnc: _sipPasswordEnc, ...rest } = ext;
return rest;
}
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("extensions")
export class ExtensionsController {
@RequirePermission("extensions.manage")
@Post()
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateExtensionDto) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const tenant = await withTenantContext(prisma, tenantId, (tx) =>
tx.tenant.findUniqueOrThrow({ where: { id: tenantId } }),
);
if (!tenant.telephonyDomain) {
throw new BadRequestException(
"Tenant ainda nao tem telephony_domain configurado — necessario antes de criar ramais",
);
}
const plainPassword = generateStrongPassword();
const extension = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.create({
data: {
tenantId,
number: dto.number,
name: dto.name,
domain: tenant.telephonyDomain!,
sipPasswordEnc: encryptSecret(plainPassword),
callerIdName: dto.callerIdName,
callerIdNumber: dto.callerIdNumber,
context: dto.context ?? "default",
sofiaProfile: dto.sofiaProfile ?? "internal",
maxRegistrations: dto.maxRegistrations ?? 1,
},
}),
);
await recordAuditEvent(prisma, {
action: "EXTENSION_CREATE",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: extension.id,
after: { number: extension.number, name: extension.name },
});
return {
...toPublicExtension(extension),
// Só aqui, uma unica vez, na resposta da criacao.
sipPassword: plainPassword,
};
}
@RequirePermission("extensions.view")
@Get()
async list(@CurrentUser() user: AccessTokenClaims) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const extensions = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.findMany({ where: { deletedAt: null }, orderBy: { number: "asc" } }),
);
return extensions.map(toPublicExtension);
}
@RequirePermission("extensions.view")
@Get(":id")
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
const prisma = getPrismaClient();
const tenantId = user.tenantId!;
const extension = await withTenantContext(prisma, tenantId, (tx) =>
tx.extension.findFirst({ where: { id, deletedAt: null } }),
);
if (!extension) {
throw new NotFoundException();
}
return toPublicExtension(extension);
}
@RequirePermission("extensions.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.extension.updateMany({
where: { id, deletedAt: null },
data: { deletedAt: new Date(), enabled: false },
}),
);
if (result.count === 0) {
throw new NotFoundException();
}
await recordAuditEvent(prisma, {
action: "EXTENSION_DELETE",
tenantId,
userId: user.sub,
entityType: "extension",
entityId: id,
});
}
}

View File

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

View File

@@ -8,10 +8,17 @@ WORKDIR /repo
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-config apps/freeswitch-config
RUN pnpm install --frozen-lockfile --filter @b2bcall/freeswitch-config...
# `prisma generate` só precisa do schema, não de uma conexão real — o valor
# aqui é só um placeholder pra satisfazer o carregamento do prisma.config.ts.
ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder"
RUN pnpm --filter @b2bcall/database exec prisma generate
WORKDIR /repo/apps/freeswitch-config
CMD ["pnpm", "exec", "tsx", "src/main.ts"]

View File

@@ -9,7 +9,9 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@b2bcall/database": "workspace:*",
"@b2bcall/shared": "workspace:*",
"@b2bcall/telephony": "workspace:*",
"@fastify/formbody": "^8.0.1",
"fastify": "5.12.1"
},

View File

@@ -1,47 +1,117 @@
import { timingSafeEqual } from "node:crypto";
import Fastify from "fastify";
import formbody from "@fastify/formbody";
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
import { decryptSecret } from "@b2bcall/shared";
import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony";
import { createLogger } from "@b2bcall/shared";
const logger = createLogger("b2bcall-fs-config");
/**
* Resposta padrão do protocolo XML Curl pra "não achei nada aqui" — o
* FreeSWITCH cai de volta pras outras fontes XML registradas (a config
* estática vanilla continua funcionando normalmente).
*/
const NOT_FOUND_XML = `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="result">
<result status="not found"/>
</section>
</document>`;
function requireEnv(name: string): string {
const value = process.env[name];
if (!value) {
throw new Error(`${name} nao definido no ambiente`);
}
return value;
}
function safeEqual(a: string, b: string): boolean {
const bufA = Buffer.from(a);
const bufB = Buffer.from(b);
if (bufA.length !== bufB.length) return false;
return timingSafeEqual(bufA, bufB);
}
interface XmlCurlBody {
section?: string;
tag_name?: string;
key_name?: string;
key_value?: string;
purpose?: string;
user?: string;
domain?: string;
[key: string]: unknown;
}
async function resolveDirectoryXml(user: string | undefined, domain: string | undefined): Promise<string> {
if (!user || !domain) {
return NOT_FOUND_XML;
}
const prisma = getPrismaClient();
// tenants nao e' tenant-scoped (e' o proprio registro de tenants — sem
// RLS, ver docs/TENANT_ISOLATION.md), mas so devolvemos dados de UM
// tenant especifico depois de achar o dono do dominio.
const tenant = await prisma.tenant.findFirst({ where: { telephonyDomain: domain, status: "ACTIVE" } });
if (!tenant) {
return NOT_FOUND_XML;
}
const extension = await withTenantContext(prisma, tenant.id, (tx) =>
tx.extension.findFirst({ where: { tenantId: tenant.id, number: user, enabled: true, deletedAt: null } }),
);
if (!extension) {
return NOT_FOUND_XML;
}
return buildDirectoryUserXml({
domain: extension.domain,
extensionNumber: extension.number,
extensionName: extension.name,
sipPassword: decryptSecret(extension.sipPasswordEnc),
context: extension.context,
callerIdName: extension.callerIdName ?? undefined,
callerIdNumber: extension.callerIdNumber ?? undefined,
tenantId: extension.tenantId,
extensionId: extension.id,
});
}
async function main() {
const expectedUser = requireEnv("FS_CONFIG_USER");
const expectedPassword = requireEnv("FS_CONFIG_PASSWORD");
const app = Fastify({ logger: false });
await app.register(formbody);
// Agora que este servico devolve dados reais (senha SIP decifrada), o
// FreeSWITCH precisa se autenticar — configurado via `gateway-credentials`
// em xml_curl.conf.xml (agente.md secao 26 + docs/EXTENSIONS.md).
app.addHook("preHandler", async (request, reply) => {
if (request.url === "/health") return;
const header = request.headers.authorization;
if (!header?.startsWith("Basic ")) {
reply.code(401).header("WWW-Authenticate", "Basic").send();
return reply;
}
const [user, password] = Buffer.from(header.slice("Basic ".length), "base64")
.toString("utf8")
.split(":");
if (!user || !password || !safeEqual(user, expectedUser) || !safeEqual(password, expectedPassword)) {
logger.warn("tentativa de acesso com credenciais invalidas");
reply.code(401).header("WWW-Authenticate", "Basic").send();
return reply;
}
});
app.post<{ Body: XmlCurlBody }>("/", async (request, reply) => {
const { section, purpose, user, domain } = request.body ?? {};
logger.info("requisicao xml_curl recebida", { section, purpose, user, domain });
// Nenhuma tabela de extensions/dialplan existe ainda (fases Extensions/
// Trunks/Dialplan, agente.md secao 232). Por enquanto respondemos
// sempre "not found" — prova o encanamento (FreeSWITCH -> mod_xml_curl
// -> este servico -> XML valido) sem afetar a config estatica vanilla,
// que continua sendo consultada como fallback.
reply.header("Content-Type", "text/xml");
if (section === "directory") {
try {
return await resolveDirectoryXml(user, domain);
} catch (err) {
logger.error("erro resolvendo directory", { error: String(err) });
return NOT_FOUND_XML;
}
}
// dialplan dinamico ainda nao existe (fase Dialplan) — a config
// estatica vanilla continua respondendo por enquanto.
return NOT_FOUND_XML;
});