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:
@@ -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);
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
65
packages/shared/src/crypto.ts
Normal file
65
packages/shared/src/crypto.ts
Normal 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)}`;
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "@b2bcall/types";
|
||||
export * from "./logger";
|
||||
export * from "./crypto";
|
||||
|
||||
72
packages/telephony/src/directory-xml.ts
Normal file
72
packages/telephony/src/directory-xml.ts
Normal file
@@ -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, """)
|
||||
.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 `<?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>`;
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from "./types";
|
||||
export * from "./normalize-event";
|
||||
export * from "./freeswitch-provider";
|
||||
export * from "./directory-xml";
|
||||
|
||||
Reference in New Issue
Block a user