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,72 @@
/** Escapa texto pra uso seguro dentro de atributos/elementos XML. */
function xmlEscape(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
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>`;