import { timingSafeEqual } from "node:crypto"; import Fastify from "fastify"; import formbody from "@fastify/formbody"; import Redis from "ioredis"; import { getPrismaClient, withTenantContext } from "@b2bcall/database"; import { decryptSecret } from "@b2bcall/shared"; import { buildDirectoryUserXml, NOT_FOUND_XML } from "@b2bcall/telephony"; import { createLogger } from "@b2bcall/shared"; import { syncTrunks } from "./trunk-sync"; import { syncQueues } from "./queue-sync"; import { syncAgent, syncTier, type AgentSyncMessage, type TierSyncMessage } from "./agent-sync"; const logger = createLogger("b2bcall-fs-config"); const TRUNKS_SYNC_CHANNEL = "b2bcall:trunks:sync"; const AGENTS_SYNC_CHANNEL = "b2bcall:agents:sync"; const TIERS_SYNC_CHANNEL = "b2bcall:tiers:sync"; const QUEUES_SYNC_CHANNEL = "b2bcall:queues:sync"; 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; purpose?: string; user?: string; domain?: string; context?: string; "Caller-Context"?: string; hostname?: 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, }); } /** * Resolve tenant pelo channel variable `b2bcall_tenant_id` — injetado em * toda chamada originada de um ramal nosso (ver buildDirectoryUserXml). * Ao contrário do directory (resolvido por domain, que hoje é o mesmo pra * todos os tenants — limitação conhecida, ver docs/EXTENSIONS.md), o * dialplan já tem essa variável disponível na própria chamada, então nem * sofre da mesma ambiguidade. * * Serve o XML JÁ GERADO da versão ACTIVE (dialplan_versions.generated_xml) * — nunca reconstrói ao vivo a partir de dialplan_extensions. Editar as * linhas do editor estruturado não afeta chamadas até uma nova versão ser * gerada e ativada (agente.md secao 44). */ async function resolveDialplanXml(body: XmlCurlBody): Promise { const tenantId = body["variable_b2bcall_tenant_id"] as string | undefined; const context = (body["Caller-Context"] ?? body.context ?? "default") as string; if (!tenantId) { return NOT_FOUND_XML; } const prisma = getPrismaClient(); const version = await withTenantContext(prisma, tenantId, (tx) => tx.dialplanVersion.findFirst({ where: { tenantId, context, status: "ACTIVE" } }), ); if (!version) { return NOT_FOUND_XML; } return version.generatedXml; } 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 }); 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; } } if (section === "dialplan") { try { return await resolveDialplanXml(request.body ?? {}); } catch (err) { logger.error("erro resolvendo dialplan", { error: String(err) }); return NOT_FOUND_XML; } } return NOT_FOUND_XML; }); app.get("/health", async () => ({ status: "ok" })); const port = Number(process.env.PORT ?? 8080); await app.listen({ port, host: "0.0.0.0" }); logger.info(`b2bcall-fs-config ouvindo na porta ${port}`); // apps/api publica no canal apos criar/editar/apagar um trunk (agente.md // secao 41-42). Roda uma sincronizacao inicial tambem, pra cobrir trunks // criados enquanto este servico estava fora do ar. const subscriber = new Redis(process.env.REDIS_URL!); subscriber.on("error", (err) => logger.error("erro na conexao Redis (subscriber)", { error: String(err) })); await subscriber.subscribe(TRUNKS_SYNC_CHANNEL, QUEUES_SYNC_CHANNEL, AGENTS_SYNC_CHANNEL, TIERS_SYNC_CHANNEL); subscriber.on("message", (channel, msg) => { if (channel === TRUNKS_SYNC_CHANNEL) { syncTrunks().catch((err) => logger.error("falha ao sincronizar trunks", { error: String(err) })); } else if (channel === QUEUES_SYNC_CHANNEL) { syncQueues().catch((err) => logger.error("falha ao sincronizar filas", { error: String(err) })); } else if (channel === AGENTS_SYNC_CHANNEL) { try { const parsed = JSON.parse(msg) as AgentSyncMessage; syncAgent(parsed).catch((err) => logger.error("falha ao sincronizar agente", { error: String(err) })); } catch (err) { logger.error("mensagem invalida no canal de agentes", { error: String(err) }); } } else if (channel === TIERS_SYNC_CHANNEL) { try { const parsed = JSON.parse(msg) as TierSyncMessage; syncTier(parsed).catch((err) => logger.error("falha ao sincronizar tier", { error: String(err) })); } catch (err) { logger.error("mensagem invalida no canal de tiers", { error: String(err) }); } } }); syncTrunks().catch((err) => logger.error("falha na sincronizacao inicial de trunks", { error: String(err) })); syncQueues().catch((err) => logger.error("falha na sincronizacao inicial de filas", { error: String(err) })); } main().catch((err) => { logger.error("falha fatal ao iniciar b2bcall-fs-config", { error: String(err) }); process.exit(1); });