From c03c6d4eaa9b123010c5d6025ef416ea9fff1224 Mon Sep 17 00:00:00 2001 From: Matheus Date: Fri, 28 Aug 2026 07:39:19 -0300 Subject: [PATCH] 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/ 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 --- .env.example | 2 + TODO.md | 26 ++- apps/api/src/app.module.ts | 3 +- .../require-permission.decorator.ts | 6 + .../api/src/common/guards/permission.guard.ts | 43 +++++ .../extensions/dto/create-extension.dto.ts | 36 +++++ .../src/extensions/extensions.controller.ts | 148 ++++++++++++++++++ apps/api/src/extensions/extensions.module.ts | 7 + apps/freeswitch-config/Dockerfile | 7 + apps/freeswitch-config/package.json | 2 + apps/freeswitch-config/src/main.ts | 108 ++++++++++--- docker-compose.yml | 11 ++ docs/EXTENSIONS.md | 115 ++++++++++++++ infrastructure/freeswitch/Dockerfile | 9 ++ infrastructure/freeswitch/entrypoint.sh | 7 + .../autoload_configs/xml_curl.conf.xml | 10 +- .../20260828102043_extensions/migration.sql | 37 +++++ packages/database/prisma/schema.prisma | 36 +++++ packages/shared/src/crypto.ts | 65 ++++++++ packages/shared/src/index.ts | 1 + packages/telephony/src/directory-xml.ts | 72 +++++++++ packages/telephony/src/index.ts | 1 + pnpm-lock.yaml | 6 + 23 files changed, 734 insertions(+), 24 deletions(-) create mode 100644 apps/api/src/common/decorators/require-permission.decorator.ts create mode 100644 apps/api/src/common/guards/permission.guard.ts create mode 100644 apps/api/src/extensions/dto/create-extension.dto.ts create mode 100644 apps/api/src/extensions/extensions.controller.ts create mode 100644 apps/api/src/extensions/extensions.module.ts create mode 100644 docs/EXTENSIONS.md create mode 100644 packages/database/prisma/migrations/20260828102043_extensions/migration.sql create mode 100644 packages/shared/src/crypto.ts create mode 100644 packages/telephony/src/directory-xml.ts diff --git a/.env.example b/.env.example index 3fcb69a..f4950cb 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,5 @@ POSTGRES_APP_USER= POSTGRES_APP_PASSWORD= APP_DATABASE_URL=postgresql://user:password@localhost:5432/b2bcall?schema=public REDIS_URL=redis://:password@localhost:6379 +FS_CONFIG_USER= +FS_CONFIG_PASSWORD= diff --git a/TODO.md b/TODO.md index 737cc11..b9436a8 100644 --- a/TODO.md +++ b/TODO.md @@ -85,7 +85,31 @@ — testado ponta a ponta com curl (login, refresh rotation, logout, RBAC, 401/403/429) - [ ] Password reset por e-mail — depende de SMTP configurado -## PHASE 08+ — ver `agente.md` seções 39 em diante (Extensions, Trunks, Dialplan, Call Center, +## PHASE 08 — Extensions (agente.md secao 39-40, 178) +- [x] Tabela `extensions` (tenant-scoped, RLS) — number, sip_password_enc, + caller_id, context, sofia_profile, codecs, max_registrations +- [x] `packages/shared/src/crypto.ts`: AES-256-GCM (senha SIP cifrada em + repouso), `generateStrongPassword()`, `maskSecret()` +- [x] `apps/api/src/extensions`: CRUD (POST/GET/GET:id/DELETE), RBAC via novo + `PermissionGuard` genérico (`@RequirePermission`), tenant só do JWT +- [x] Senha SIP só aparece em texto puro na resposta do POST, nunca depois + (destructuring explícito, não spread — evita vazamento por acidente) +- [x] `b2bcall-fs-config` resolve directory real: Tenant.telephonyDomain → + Extension.number, decifra a senha, monta XML com dial-string +- [x] `Tenant.telephonyDomain` fixo (`b2bcall.local`) via patch no `vars.xml` + do FreeSWITCH — antes usava o IP dinâmico do container, instável +- [x] HTTP Basic auth entre FreeSWITCH e fs-config (`gateway-credentials`, + timingSafeEqual) — adicionada nesta mesma fase, não deixada pendente +- [x] Testado ponta a ponta: criar ramal → `user/1500` dá USER_NOT_REGISTERED + (achou, sem telefone) → deletar → volta a SUBSCRIBER_ABSENT +- [x] Achado: `PermissionGuard` injetando `Reflector` via construtor dava + `undefined` em runtime rodando via `tsx`/esbuild (emissão de metadata + de tipo não é 100% confiável cross-file) — corrigido com `@Inject()` + explícito; atenção pra isso em guards/services futuros +- [ ] 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, 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 8fcb5db..70f924f 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/common/decorators/require-permission.decorator.ts b/apps/api/src/common/decorators/require-permission.decorator.ts new file mode 100644 index 0000000..5d06576 --- /dev/null +++ b/apps/api/src/common/decorators/require-permission.decorator.ts @@ -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); diff --git a/apps/api/src/common/guards/permission.guard.ts b/apps/api/src/common/guards/permission.guard.ts new file mode 100644 index 0000000..fe75e5b --- /dev/null +++ b/apps/api/src/common/guards/permission.guard.ts @@ -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 { + const permissionKey = this.reflector.get(PERMISSION_KEY, context.getHandler()); + if (!permissionKey) { + return true; + } + + const request = context.switchToHttp().getRequest(); + 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; + } +} diff --git a/apps/api/src/extensions/dto/create-extension.dto.ts b/apps/api/src/extensions/dto/create-extension.dto.ts new file mode 100644 index 0000000..91b513f --- /dev/null +++ b/apps/api/src/extensions/dto/create-extension.dto.ts @@ -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; +} diff --git a/apps/api/src/extensions/extensions.controller.ts b/apps/api/src/extensions/extensions.controller.ts new file mode 100644 index 0000000..50fd0d2 --- /dev/null +++ b/apps/api/src/extensions/extensions.controller.ts @@ -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, + }); + } +} diff --git a/apps/api/src/extensions/extensions.module.ts b/apps/api/src/extensions/extensions.module.ts new file mode 100644 index 0000000..cd8af03 --- /dev/null +++ b/apps/api/src/extensions/extensions.module.ts @@ -0,0 +1,7 @@ +import { Module } from "@nestjs/common"; +import { ExtensionsController } from "./extensions.controller"; + +@Module({ + controllers: [ExtensionsController], +}) +export class ExtensionsModule {} diff --git a/apps/freeswitch-config/Dockerfile b/apps/freeswitch-config/Dockerfile index 6b3924c..0c67ea8 100644 --- a/apps/freeswitch-config/Dockerfile +++ b/apps/freeswitch-config/Dockerfile @@ -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"] diff --git a/apps/freeswitch-config/package.json b/apps/freeswitch-config/package.json index c14e91e..a3a2e72 100644 --- a/apps/freeswitch-config/package.json +++ b/apps/freeswitch-config/package.json @@ -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" }, diff --git a/apps/freeswitch-config/src/main.ts b/apps/freeswitch-config/src/main.ts index 56a8d2f..10503c5 100644 --- a/apps/freeswitch-config/src/main.ts +++ b/apps/freeswitch-config/src/main.ts @@ -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 = ` - -
- -
-
`; +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 { + 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; }); diff --git a/docker-compose.yml b/docker-compose.yml index 49c0ca9..e9bbe44 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -38,6 +38,15 @@ services: dockerfile: apps/freeswitch-config/Dockerfile container_name: b2bcall-fs-config restart: unless-stopped + depends_on: + - postgres + environment: + # Hostname interno do compose (postgres), 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} # 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))"] @@ -57,6 +66,8 @@ services: - fs-config environment: ESL_PASSWORD: ${ESL_PASSWORD} + FS_CONFIG_USER: ${FS_CONFIG_USER} + FS_CONFIG_PASSWORD: ${FS_CONFIG_PASSWORD} # 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). diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md new file mode 100644 index 0000000..706787a --- /dev/null +++ b/docs/EXTENSIONS.md @@ -0,0 +1,115 @@ +# Extensions (Ramais) + +Primeira fase que dá dado real de negócio ao FreeSWITCH via `mod_xml_curl` +(agente.md secao 39-40, 178). + +## Modelo + +`extensions` (tenant-scoped, RLS — mesmo padrão de `tenant_memberships`): +`number`, `name`, `domain`, `sip_password_enc`, `caller_id_name/number`, +`context`, `sofia_profile`, `codecs`, `max_registrations`, `enabled`. +`UNIQUE(tenant_id, number)` — números só precisam ser únicos dentro do +tenant (secao 34). + +## Senha SIP (secao 178) + +- Gerada com `generateStrongPassword()` (24 caracteres alfanuméricos, + `crypto.randomBytes`). +- Cifrada em repouso com AES-256-GCM (`packages/shared/src/crypto.ts`), + chave em `ENCRYPTION_KEY` (.env, 32 bytes hex) — nunca no PostgreSQL. +- **Só aparece em texto puro na resposta do `POST /extensions`, uma única + vez.** `GET`/`list` nunca devolvem `sipPasswordEnc` nem a senha — a função + `toPublicExtension()` faz destructuring explícito do campo (não spread) pra + garantir isso é removido de fato, não só "esquecido" no tipo TypeScript. + +## API (`apps/api/src/extensions`) + +``` +POST /extensions extensions.manage cria (gera+cifra senha, devolve 1x) +GET /extensions extensions.view lista (sem senha) +GET /extensions/:id extensions.view detalhe (sem senha) +DELETE /extensions/:id extensions.manage soft delete (deletedAt + enabled=false) +``` + +Novo `PermissionGuard` genérico (`@RequirePermission('extensions.manage')`) +— roda depois do `JwtAuthGuard`, exige tenant selecionado no JWT (nunca +aceita tenant_id do client) e chama `userHasPermission()` de `packages/auth`. + +**Achado real durante os testes**: o `PermissionGuard` injeta `Reflector` via +construtor — padrão documentado do NestJS. Rodando via `tsx` (esbuild), o +`reflector` chegava `undefined` em runtime (`TypeError: Cannot read +properties of undefined`), porque esbuild não faz emissão de +`design:paramtypes` com checagem de tipos completa entre arquivos (limitação +conhecida do esbuild, diferente do `tsc`). Resolvido com `@Inject(Reflector)` +explícito no construtor. Isso é um risco real pra qualquer guard/serviço +futuro que dependa de injeção implícita de tipo — **usar `@Inject()` +explícito sempre que o dev/runtime for via `tsx`**, ou considerar migrar +`apps/api` pra build real (`tsc`) mais adiante. + +## `b2bcall-fs-config` agora responde directory de verdade + +Fluxo `section === "directory"`: + +1. `Tenant.findFirst({ telephonyDomain: domain, status: "ACTIVE" })` — tenants + não são RLS-protected (é o registro da plataforma). +2. `withTenantContext(tenant.id) → Extension.findFirst({ number: user, enabled: true })`. +3. `buildDirectoryUserXml()` (`packages/telephony`) monta o XML, incluindo + `b2bcall_tenant_id`/`b2bcall_extension_id` como channel variables (secao 81 + — assim qualquer chamada desse ramal já carrega a origem). + +**Achado real**: a primeira versão do XML não incluía o bloco +`` que a +config vanilla tem em `directory/default.xml`. Sem isso, `originate +user/1500 &park()` falhava com `MANDATORY_IE_MISSING` em vez do +`USER_NOT_REGISTERED` esperado — o FreeSWITCH não sabia montar o dialstring +pro endpoint `user/`. Corrigido copiando o mesmo template de `dial-string` da +config vanilla. + +## `$${domain}` fixo + +`Tenant.telephonyDomain` precisa bater com o que o FreeSWITCH manda como +`domain` no POST do xml_curl. Por padrão, a config vanilla usa +`domain=$${local_ip_v4}` — o IP do container, que muda a cada restart e +nunca seria estável o suficiente pra configurar em um tenant. Corrigido no +`Dockerfile` do FreeSWITCH com um `sed` fixando `$${domain}` pra +`b2bcall.local` (configurável via `ARG DEFAULT_SIP_DOMAIN`). Multi-domínio +real por tenant (múltiplos domínios simultâneos, um por tenant) ainda não +está resolvido — hoje só suporta um domínio fixo pra todos; isso é uma +limitação genuína a resolver quando existir gestão de domínio por tenant de +verdade (fora do escopo desta fase). + +## Verificado ponta a ponta + +```bash +POST /extensions {"number":"1500","name":"Ramal de Teste"} # 201, senha aparece 1x +GET /extensions/:id # confirma senha nunca reaparece + +# fs-config resolve com a senha certa (decifrada corretamente): +curl -d "section=directory&user=1500&domain=b2bcall.local" http://fs-config:8080/ + +# FreeSWITCH: +originate user/1500 &park() # USER_NOT_REGISTERED (achou o ramal, sem telefone registrado) +originate user/1501 &park() # SUBSCRIBER_ABSENT (nao existe) + +DELETE /extensions/:id +originate user/1500 &park() # volta a SUBSCRIBER_ABSENT +``` + +## Autenticação HTTP `fs-config` ↔ FreeSWITCH + +Adicionada nesta mesma fase (assim que o serviço passou a devolver dados +reais, deixou de ser opcional): HTTP Basic, credenciais em `FS_CONFIG_USER`/ +`FS_CONFIG_PASSWORD` (.env, geradas com `openssl rand`). FreeSWITCH manda via +`gateway-credentials` em `xml_curl.conf.xml` (substituído em runtime pelo +`entrypoint.sh`, mesmo padrão do `ESL_PASSWORD` — nunca fica na imagem). +`fs-config` compara com `timingSafeEqual` (evita timing attack), libera só +`/health` sem auth (usado pelo healthcheck do Docker). Verificado: requisição +sem credenciais recebe 401; o FreeSWITCH (com `gateway-credentials` +configurado) continua funcionando normalmente. + +## O que falta + +- Quota de ramais (`max_extensions` do plano, secao 57) — depende da fase + Plans/Entitlements, que ainda não existe. +- Tela "Telefonia → Ramais" (frontend) — fase Frontend, bem mais adiante. +- Multi-domínio real por tenant (ver acima). diff --git a/infrastructure/freeswitch/Dockerfile b/infrastructure/freeswitch/Dockerfile index 0246e0e..e02d9b3 100644 --- a/infrastructure/freeswitch/Dockerfile +++ b/infrastructure/freeswitch/Dockerfile @@ -46,6 +46,15 @@ COPY overrides/autoload_configs/modules.conf.xml /etc/freeswitch/autoload_config COPY overrides/autoload_configs/event_socket.conf.xml /etc/freeswitch/autoload_configs/event_socket.conf.xml COPY overrides/autoload_configs/acl.conf.xml /etc/freeswitch/autoload_configs/acl.conf.xml COPY overrides/autoload_configs/xml_curl.conf.xml /etc/freeswitch/autoload_configs/xml_curl.conf.xml + +# Pino $${domain} num valor estavel em vez do IP dinamico do container +# (vars.xml vanilla usa "domain=$${local_ip_v4}", que muda a cada restart e +# nunca bateria com Tenant.telephonyDomain). Ver docs/EXTENSIONS.md. +ARG DEFAULT_SIP_DOMAIN=b2bcall.local +RUN sed -i "s/data=\"domain=\$\${local_ip_v4}\"/data=\"domain=${DEFAULT_SIP_DOMAIN}\"/" \ + /etc/freeswitch/vars.xml \ + && grep -q "domain=${DEFAULT_SIP_DOMAIN}" /etc/freeswitch/vars.xml + COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh diff --git a/infrastructure/freeswitch/entrypoint.sh b/infrastructure/freeswitch/entrypoint.sh index f2f7dbd..e5c426b 100644 --- a/infrastructure/freeswitch/entrypoint.sh +++ b/infrastructure/freeswitch/entrypoint.sh @@ -6,8 +6,15 @@ set -eu : "${ESL_PASSWORD:?ESL_PASSWORD precisa estar definido no ambiente do container}" +: "${FS_CONFIG_USER:?FS_CONFIG_USER precisa estar definido no ambiente do container}" +: "${FS_CONFIG_PASSWORD:?FS_CONFIG_PASSWORD precisa estar definido no ambiente do container}" sed -i "s/__ESL_PASSWORD__/${ESL_PASSWORD}/" \ /etc/freeswitch/autoload_configs/event_socket.conf.xml +sed -i \ + -e "s/__FS_CONFIG_USER__/${FS_CONFIG_USER}/" \ + -e "s/__FS_CONFIG_PASSWORD__/${FS_CONFIG_PASSWORD}/" \ + /etc/freeswitch/autoload_configs/xml_curl.conf.xml + exec "$@" diff --git a/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml b/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml index b6ad04e..e114c60 100644 --- a/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml +++ b/infrastructure/freeswitch/overrides/autoload_configs/xml_curl.conf.xml @@ -1,10 +1,14 @@ - + + + diff --git a/packages/database/prisma/migrations/20260828102043_extensions/migration.sql b/packages/database/prisma/migrations/20260828102043_extensions/migration.sql new file mode 100644 index 0000000..31c2168 --- /dev/null +++ b/packages/database/prisma/migrations/20260828102043_extensions/migration.sql @@ -0,0 +1,37 @@ +-- CreateTable +CREATE TABLE "extensions" ( + "id" UUID NOT NULL, + "tenant_id" UUID NOT NULL, + "number" TEXT NOT NULL, + "name" TEXT NOT NULL, + "domain" TEXT NOT NULL, + "sip_password_enc" TEXT NOT NULL, + "caller_id_name" TEXT, + "caller_id_number" TEXT, + "context" TEXT NOT NULL DEFAULT 'default', + "sofia_profile" TEXT NOT NULL DEFAULT 'internal', + "codecs" TEXT NOT NULL DEFAULT 'PCMU,PCMA,OPUS', + "max_registrations" INTEGER NOT NULL DEFAULT 1, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "extensions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "extensions_tenant_id_idx" ON "extensions"("tenant_id"); + +-- CreateIndex +CREATE UNIQUE INDEX "extensions_tenant_id_number_key" ON "extensions"("tenant_id", "number"); + +-- AddForeignKey +ALTER TABLE "extensions" ADD CONSTRAINT "extensions_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 "extensions" ENABLE ROW LEVEL SECURITY; +ALTER TABLE "extensions" FORCE ROW LEVEL SECURITY; + +CREATE POLICY "tenant_isolation" ON "extensions" + 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 787af3f..f49ef43 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -34,6 +34,7 @@ model Tenant { memberships TenantMembership[] userRoles UserRole[] + extensions Extension[] @@map("tenants") } @@ -181,3 +182,38 @@ model TenantMembership { @@index([tenantId]) @@map("tenant_memberships") } + +// Tabela tenant-scoped protegida por Row Level Security. sipPasswordEnc +// guarda a senha SIP cifrada (AES-256-GCM, ver packages/shared/src/crypto.ts) +// — nunca texto puro (agente.md secao 178). +model Extension { + id String @id @default(uuid()) @db.Uuid + tenantId String @map("tenant_id") @db.Uuid + + number String + name String + domain String + + sipPasswordEnc String @map("sip_password_enc") + + callerIdName String? @map("caller_id_name") + callerIdNumber String? @map("caller_id_number") + + context String @default("default") + sofiaProfile String @default("internal") @map("sofia_profile") + codecs String @default("PCMU,PCMA,OPUS") @map("codecs") + + maxRegistrations Int @default(1) @map("max_registrations") + + enabled Boolean @default(true) + + 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, number]) + @@index([tenantId]) + @@map("extensions") +} diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts new file mode 100644 index 0000000..cb11cd8 --- /dev/null +++ b/packages/shared/src/crypto.ts @@ -0,0 +1,65 @@ +import { randomBytes, createCipheriv, createDecipheriv } from "node:crypto"; + +const ALGORITHM = "aes-256-gcm"; +const IV_LENGTH = 12; // recomendado pro GCM + +/** + * Cifra secrets em repouso (senha SIP, API keys de IA — agente.md secoes + * 101, 178) com AES-256-GCM. A master key nunca fica no PostgreSQL — só em + * `ENCRYPTION_KEY` (.env), 32 bytes em hex. + * + * Formato de saída: `::` — tudo + * necessário pra decifrar fica junto, exceto a chave. + */ +function getKey(): Buffer { + const hex = process.env.ENCRYPTION_KEY; + if (!hex) { + throw new Error("ENCRYPTION_KEY nao definida no ambiente"); + } + const key = Buffer.from(hex, "hex"); + if (key.length !== 32) { + throw new Error("ENCRYPTION_KEY precisa ter 32 bytes (64 caracteres hex) para AES-256"); + } + return key; +} + +export function encryptSecret(plainText: string): string { + const key = getKey(); + const iv = randomBytes(IV_LENGTH); + const cipher = createCipheriv(ALGORITHM, key, iv); + const ciphertext = Buffer.concat([cipher.update(plainText, "utf8"), cipher.final()]); + const authTag = cipher.getAuthTag(); + return `${iv.toString("hex")}:${authTag.toString("hex")}:${ciphertext.toString("hex")}`; +} + +export function decryptSecret(encoded: string): string { + const key = getKey(); + const [ivHex, authTagHex, ciphertextHex] = encoded.split(":"); + if (!ivHex || !authTagHex || !ciphertextHex) { + throw new Error("Formato invalido de secret cifrado"); + } + const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(ivHex, "hex")); + decipher.setAuthTag(Buffer.from(authTagHex, "hex")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ciphertextHex, "hex")), + decipher.final(), + ]); + return plaintext.toString("utf8"); +} + +/** Senha SIP forte, alfanumérica (evita caracteres que compliquem SIP/URI). */ +export function generateStrongPassword(length = 24): string { + const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + const bytes = randomBytes(length); + let result = ""; + for (let i = 0; i < length; i++) { + result += alphabet[bytes[i]! % alphabet.length]; + } + return result; +} + +/** Máscara pra exibição (nunca a senha inteira de novo — agente.md secao 39). */ +export function maskSecret(plainText: string): string { + if (plainText.length <= 4) return "****"; + return `${plainText.slice(0, 2)}${"*".repeat(plainText.length - 4)}${plainText.slice(-2)}`; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 1bdd6ec..df9aa7d 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,2 +1,3 @@ export * from "@b2bcall/types"; export * from "./logger"; +export * from "./crypto"; diff --git a/packages/telephony/src/directory-xml.ts b/packages/telephony/src/directory-xml.ts new file mode 100644 index 0000000..40e8eaf --- /dev/null +++ b/packages/telephony/src/directory-xml.ts @@ -0,0 +1,72 @@ +/** Escapa texto pra uso seguro dentro de atributos/elementos XML. */ +function xmlEscape(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +export interface DirectoryUserParams { + domain: string; + extensionNumber: string; + extensionName: string; + sipPassword: string; + context: string; + callerIdName?: string; + callerIdNumber?: string; + tenantId: string; + extensionId: string; +} + +/** + * Monta o XML de directory que o FreeSWITCH espera do mod_xml_curl pra + * autenticar/rotear um ramal (agente.md secao 26). `b2bcall_tenant_id` e + * `b2bcall_extension_id` viram channel variables em toda chamada desse + * ramal — é assim que o resto do sistema volta a saber de qual tenant uma + * chamada é (secao 81). + */ +export function buildDirectoryUserXml(params: DirectoryUserParams): string { + const callerIdName = xmlEscape(params.callerIdName ?? params.extensionName); + const callerIdNumber = xmlEscape(params.callerIdNumber ?? params.extensionNumber); + + return ` + +
+ + + + + + + + + + + + + + + + + + + + + + + + +
+
`; +} + +export const NOT_FOUND_XML = ` + +
+ +
+
`; diff --git a/packages/telephony/src/index.ts b/packages/telephony/src/index.ts index a393e04..f4dcefa 100644 --- a/packages/telephony/src/index.ts +++ b/packages/telephony/src/index.ts @@ -1,3 +1,4 @@ export * from "./types"; export * from "./normalize-event"; export * from "./freeswitch-provider"; +export * from "./directory-xml"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2c75620..7a545a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -72,9 +72,15 @@ importers: apps/freeswitch-config: dependencies: + '@b2bcall/database': + specifier: workspace:* + version: link:../../packages/database '@b2bcall/shared': specifier: workspace:* version: link:../../packages/shared + '@b2bcall/telephony': + specifier: workspace:* + version: link:../../packages/telephony '@fastify/formbody': specifier: ^8.0.1 version: 8.0.2