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