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:
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user