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

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