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

@@ -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=

26
TODO.md
View File

@@ -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)
---

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;
});

View File

@@ -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).

115
docs/EXTENSIONS.md Normal file
View File

@@ -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
`<domain><params><param name="dial-string".../></params></domain>` 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).

View File

@@ -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

View File

@@ -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 "$@"

View File

@@ -1,10 +1,14 @@
<configuration name="xml_curl.conf" description="cURL XML Gateway">
<bindings>
<binding name="b2bcall-fs-config">
<!-- b2bcall-fs-config ainda so responde "not found" pra tudo (nao
existe extensions/dialplan persistidos ainda fases seguintes).
A config estatica vanilla continua valendo como fallback. -->
<!-- b2bcall-fs-config responde directory de verdade (extensions) e
"not found" pra dialplan ainda (fase seguinte). A config estatica
vanilla continua valendo como fallback quando "not found".
Credenciais substituidas em runtime pelo entrypoint.sh — nunca
ficam de verdade na imagem (mesmo padrao do ESL_PASSWORD). -->
<param name="gateway-url" value="http://fs-config:8080/" bindings="directory|dialplan"/>
<param name="gateway-credentials" value="__FS_CONFIG_USER__:__FS_CONFIG_PASSWORD__"/>
<param name="auth-scheme" value="basic"/>
<param name="timeout" value="5"/>
</binding>
</bindings>

View File

@@ -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);

View File

@@ -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")
}

View File

@@ -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: `<iv_hex>:<authTag_hex>:<ciphertext_hex>` — 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)}`;
}

View File

@@ -1,2 +1,3 @@
export * from "@b2bcall/types";
export * from "./logger";
export * from "./crypto";

View File

@@ -0,0 +1,72 @@
/** Escapa texto pra uso seguro dentro de atributos/elementos XML. */
function xmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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 `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="directory">
<domain name="${xmlEscape(params.domain)}">
<params>
<!-- Sem isto, o endpoint "user/" nao sabe montar o dialstring pra
alcançar o contato registrado (mesmo padrão da config vanilla
em directory/default.xml). -->
<param name="dial-string" value="{^^:sip_invite_domain=\${dialed_domain}:presence_id=\${dialed_user}@\${dialed_domain}}\${sofia_contact(*/\${dialed_user}@\${dialed_domain})}"/>
</params>
<groups>
<group name="default">
<users>
<user id="${xmlEscape(params.extensionNumber)}">
<params>
<param name="password" value="${xmlEscape(params.sipPassword)}"/>
</params>
<variables>
<variable name="user_context" value="${xmlEscape(params.context)}"/>
<variable name="effective_caller_id_name" value="${callerIdName}"/>
<variable name="effective_caller_id_number" value="${callerIdNumber}"/>
<variable name="b2bcall_tenant_id" value="${xmlEscape(params.tenantId)}"/>
<variable name="b2bcall_extension_id" value="${xmlEscape(params.extensionId)}"/>
</variables>
</user>
</users>
</group>
</groups>
</domain>
</section>
</document>`;
}
export const NOT_FOUND_XML = `<?xml version="1.0" encoding="UTF-8"?>
<document type="freeswitch/xml">
<section name="result">
<result status="not found"/>
</section>
</document>`;

View File

@@ -1,3 +1,4 @@
export * from "./types";
export * from "./normalize-event";
export * from "./freeswitch-provider";
export * from "./directory-xml";

6
pnpm-lock.yaml generated
View File

@@ -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