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:
@@ -1,8 +1,9 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { HealthModule } from "./health/health.module";
|
||||
import { AuthModule } from "./auth/auth.module";
|
||||
import { ExtensionsModule } from "./extensions/extensions.module";
|
||||
|
||||
@Module({
|
||||
imports: [HealthModule, AuthModule],
|
||||
imports: [HealthModule, AuthModule, ExtensionsModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { SetMetadata } from "@nestjs/common";
|
||||
|
||||
export const PERMISSION_KEY = "permission";
|
||||
|
||||
/** Marca uma rota com a permission necessária (agente.md secao 145). */
|
||||
export const RequirePermission = (permissionKey: string) => SetMetadata(PERMISSION_KEY, permissionKey);
|
||||
43
apps/api/src/common/guards/permission.guard.ts
Normal file
43
apps/api/src/common/guards/permission.guard.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { CanActivate, ExecutionContext, ForbiddenException, Inject, Injectable } from "@nestjs/common";
|
||||
import { Reflector } from "@nestjs/core";
|
||||
import { userHasPermission } from "@b2bcall/auth";
|
||||
import { PERMISSION_KEY } from "../decorators/require-permission.decorator";
|
||||
import type { AuthenticatedRequest } from "./jwt-auth.guard";
|
||||
|
||||
/**
|
||||
* Roda depois do JwtAuthGuard. Exige que a rota tenha um tenant selecionado
|
||||
* (agente.md secao 31: nunca confiar em tenant_id do frontend — aqui vem só
|
||||
* do JWT, nunca do body/query) e que o usuário tenha a permission marcada
|
||||
* via @RequirePermission() (secao 145/146).
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
// @Inject explicito: a metadata de tipos emitida via tsx/esbuild nao e'
|
||||
// sempre confiavel o suficiente pra resolucao automatica de DI do Nest
|
||||
// (esbuild nao faz checagem de tipos cross-file completa) — sem isso,
|
||||
// `reflector` chega undefined em runtime.
|
||||
constructor(@Inject(Reflector) private readonly reflector: Reflector) {}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const permissionKey = this.reflector.get<string | undefined>(PERMISSION_KEY, context.getHandler());
|
||||
if (!permissionKey) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const request = context.switchToHttp().getRequest<AuthenticatedRequest>();
|
||||
const user = request.user;
|
||||
if (!user) {
|
||||
throw new ForbiddenException("Nao autenticado");
|
||||
}
|
||||
if (!user.tenantId) {
|
||||
throw new ForbiddenException("Nenhum tenant selecionado (use /auth/select-tenant)");
|
||||
}
|
||||
|
||||
const allowed = await userHasPermission(user.sub, permissionKey, user.tenantId);
|
||||
if (!allowed) {
|
||||
throw new ForbiddenException(`Permissao necessaria: ${permissionKey}`);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
36
apps/api/src/extensions/dto/create-extension.dto.ts
Normal file
36
apps/api/src/extensions/dto/create-extension.dto.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { IsIn, IsInt, IsOptional, IsString, Matches, Max, Min, MaxLength } from "class-validator";
|
||||
|
||||
export class CreateExtensionDto {
|
||||
@IsString()
|
||||
@Matches(/^[0-9]{2,10}$/, { message: "number deve ter só dígitos (2 a 10)" })
|
||||
number!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
callerIdName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[0-9]{2,20}$/)
|
||||
callerIdNumber?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(80)
|
||||
context?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(["internal"])
|
||||
sofiaProfile?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Max(10)
|
||||
maxRegistrations?: number;
|
||||
}
|
||||
148
apps/api/src/extensions/extensions.controller.ts
Normal file
148
apps/api/src/extensions/extensions.controller.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Post,
|
||||
UseGuards,
|
||||
} from "@nestjs/common";
|
||||
import { getPrismaClient, withTenantContext } from "@b2bcall/database";
|
||||
import { generateStrongPassword, encryptSecret } from "@b2bcall/shared";
|
||||
import { recordAuditEvent, type AccessTokenClaims } from "@b2bcall/auth";
|
||||
import { JwtAuthGuard } from "../common/guards/jwt-auth.guard";
|
||||
import { PermissionGuard } from "../common/guards/permission.guard";
|
||||
import { RequirePermission } from "../common/decorators/require-permission.decorator";
|
||||
import { CurrentUser } from "../common/decorators/current-user.decorator";
|
||||
import { CreateExtensionDto } from "./dto/create-extension.dto";
|
||||
|
||||
function toPublicExtension(ext: {
|
||||
id: string;
|
||||
number: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
sipPasswordEnc: string;
|
||||
callerIdName: string | null;
|
||||
callerIdNumber: string | null;
|
||||
context: string;
|
||||
sofiaProfile: string;
|
||||
codecs: string;
|
||||
maxRegistrations: number;
|
||||
enabled: boolean;
|
||||
createdAt: Date;
|
||||
}) {
|
||||
// sipPasswordEnc NUNCA sai daqui — agente.md secao 39: "Nunca mostrar
|
||||
// novamente a senha inteira". Destructuring explícito (não spread) pra
|
||||
// garantir que o campo é de fato removido, não só "esquecido" no tipo.
|
||||
const { sipPasswordEnc: _sipPasswordEnc, ...rest } = ext;
|
||||
return rest;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard, PermissionGuard)
|
||||
@Controller("extensions")
|
||||
export class ExtensionsController {
|
||||
@RequirePermission("extensions.manage")
|
||||
@Post()
|
||||
async create(@CurrentUser() user: AccessTokenClaims, @Body() dto: CreateExtensionDto) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const tenant = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.tenant.findUniqueOrThrow({ where: { id: tenantId } }),
|
||||
);
|
||||
if (!tenant.telephonyDomain) {
|
||||
throw new BadRequestException(
|
||||
"Tenant ainda nao tem telephony_domain configurado — necessario antes de criar ramais",
|
||||
);
|
||||
}
|
||||
|
||||
const plainPassword = generateStrongPassword();
|
||||
|
||||
const extension = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.extension.create({
|
||||
data: {
|
||||
tenantId,
|
||||
number: dto.number,
|
||||
name: dto.name,
|
||||
domain: tenant.telephonyDomain!,
|
||||
sipPasswordEnc: encryptSecret(plainPassword),
|
||||
callerIdName: dto.callerIdName,
|
||||
callerIdNumber: dto.callerIdNumber,
|
||||
context: dto.context ?? "default",
|
||||
sofiaProfile: dto.sofiaProfile ?? "internal",
|
||||
maxRegistrations: dto.maxRegistrations ?? 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "EXTENSION_CREATE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "extension",
|
||||
entityId: extension.id,
|
||||
after: { number: extension.number, name: extension.name },
|
||||
});
|
||||
|
||||
return {
|
||||
...toPublicExtension(extension),
|
||||
// Só aqui, uma unica vez, na resposta da criacao.
|
||||
sipPassword: plainPassword,
|
||||
};
|
||||
}
|
||||
|
||||
@RequirePermission("extensions.view")
|
||||
@Get()
|
||||
async list(@CurrentUser() user: AccessTokenClaims) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const extensions = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.extension.findMany({ where: { deletedAt: null }, orderBy: { number: "asc" } }),
|
||||
);
|
||||
return extensions.map(toPublicExtension);
|
||||
}
|
||||
|
||||
@RequirePermission("extensions.view")
|
||||
@Get(":id")
|
||||
async get(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
const extension = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.extension.findFirst({ where: { id, deletedAt: null } }),
|
||||
);
|
||||
if (!extension) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
return toPublicExtension(extension);
|
||||
}
|
||||
|
||||
@RequirePermission("extensions.manage")
|
||||
@Delete(":id")
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
async remove(@CurrentUser() user: AccessTokenClaims, @Param("id") id: string) {
|
||||
const prisma = getPrismaClient();
|
||||
const tenantId = user.tenantId!;
|
||||
|
||||
const result = await withTenantContext(prisma, tenantId, (tx) =>
|
||||
tx.extension.updateMany({
|
||||
where: { id, deletedAt: null },
|
||||
data: { deletedAt: new Date(), enabled: false },
|
||||
}),
|
||||
);
|
||||
if (result.count === 0) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
|
||||
await recordAuditEvent(prisma, {
|
||||
action: "EXTENSION_DELETE",
|
||||
tenantId,
|
||||
userId: user.sub,
|
||||
entityType: "extension",
|
||||
entityId: id,
|
||||
});
|
||||
}
|
||||
}
|
||||
7
apps/api/src/extensions/extensions.module.ts
Normal file
7
apps/api/src/extensions/extensions.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ExtensionsController } from "./extensions.controller";
|
||||
|
||||
@Module({
|
||||
controllers: [ExtensionsController],
|
||||
})
|
||||
export class ExtensionsModule {}
|
||||
@@ -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