feat: add trunks and extensions CRUD with realtime PJSIP provisioning

- apps/api/src/telephony: TelephonyModule (conexao AMI da API para
  comandos de controle) + PjsipRealtimeService (unico ponto de escrita nas
  tabelas realtime do schema 'asterisk' via Prisma $executeRaw
  parametrizado)
- apps/api/src/trunks: CRUD completo (tipos IP/AUTH/REGISTRATION), secret
  cifrado em repouso (AES-256-GCM), endpoint de teste de status via
  PJSIPShowContacts/pjsip show registration
- apps/api/src/extensions: CRUD completo, senha SIP gerada
  automaticamente, endpoint de reset, nunca reexibe a senha apos a criacao
- apps/api/src/monitoring: GET /api/monitoring/extensions com prioridade
  de cor (agente.md secao 15) a partir do ExtensionState alimentado por
  apps/asterisk-events
- health check agora reporta status real do Asterisk/AMI via heartbeat do
  asterisk-events no Redis, sem precisar de conexao AMI propria para isso

Testado ponta a ponta contra o Asterisk real: ramal e tronco criados via
API aparecem imediatamente via 'pjsip show endpoint' sem reload (realtime
funcionando), secret nunca retornado pela API, exclusao em cascata
confirmada (ps_endpoints/ps_auths/ps_aors/ps_contacts/ps_registrations).
This commit is contained in:
2026-08-27 12:58:12 -03:00
parent 6f3d731581
commit 8a41b5d2b2
21 changed files with 1221 additions and 5 deletions

10
TODO.md
View File

@@ -105,8 +105,14 @@ mestre original (`agente.md`, seções 90-93).
heartbeat para health check — testado ponta a ponta com chamada real heartbeat para health check — testado ponta a ponta com chamada real
- [x] Schema Prisma: Trunk, Extension, ExtensionState + criptografia de - [x] Schema Prisma: Trunk, Extension, ExtensionState + criptografia de
segredos AES-256-GCM (packages/shared/secret-crypto.ts) segredos AES-256-GCM (packages/shared/secret-crypto.ts)
- [ ] CRUD Troncos (com CPS máximo, ACL, teste de status) - [x] CRUD Troncos (IP/AUTH/REGISTRATION, CPS máximo, ACL, secret cifrado
- [ ] CRUD Ramais (senha SIP gerada, reset, status tempo real) AES-256-GCM, teste de status) — testado: criado via API, verificado
que o Asterisk enxerga o endpoint via realtime SEM reload, status
consultado via PJSIPShowContacts, exclusão em cascata confirmada
- [x] CRUD Ramais (senha SIP gerada automaticamente, nunca reexibida exceto
na criação/reset, reset endpoint) — testado igual aos troncos
- [x] GET /api/monitoring/extensions — status com prioridade de cor
(offline/available/busy; pausa e agente logado chegam na Fase 5)
- [ ] Painel visual de Ramais (WebSocket, prioridade de cores) — backend - [ ] Painel visual de Ramais (WebSocket, prioridade de cores) — backend
(gateway WS) pendente; ExtensionState/pub-sub já prontos como base (gateway WS) pendente; ExtensionState/pub-sub já prontos como base
- [ ] Dialplan estruturado (versionado, modo Advanced, validação+rollback) - [ ] Dialplan estruturado (versionado, modo Advanced, validação+rollback)

View File

@@ -22,6 +22,7 @@
"dependencies": { "dependencies": {
"@b2bcall/database": "workspace:*", "@b2bcall/database": "workspace:*",
"@b2bcall/shared": "workspace:*", "@b2bcall/shared": "workspace:*",
"@b2bcall/telephony": "workspace:*",
"@fastify/cookie": "^11.0.2", "@fastify/cookie": "^11.0.2",
"@fastify/helmet": "^13.0.1", "@fastify/helmet": "^13.0.1",
"@fastify/static": "^8.0.4", "@fastify/static": "^8.0.4",
@@ -29,6 +30,7 @@
"@nestjs/config": "^4.0.2", "@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.0.1", "@nestjs/core": "^11.0.1",
"@nestjs/jwt": "^11.0.0", "@nestjs/jwt": "^11.0.0",
"@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-fastify": "^11.0.1", "@nestjs/platform-fastify": "^11.0.1",
"@nestjs/swagger": "^11.2.0", "@nestjs/swagger": "^11.2.0",
"@nestjs/terminus": "^11.0.0", "@nestjs/terminus": "^11.0.0",

View File

@@ -13,6 +13,10 @@ import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { RolesModule } from './roles/roles.module'; import { RolesModule } from './roles/roles.module';
import { HealthModule } from './health/health.module'; import { HealthModule } from './health/health.module';
import { TelephonyModule } from './telephony/telephony.module';
import { TrunksModule } from './trunks/trunks.module';
import { ExtensionsModule } from './extensions/extensions.module';
import { MonitoringModule } from './monitoring/monitoring.module';
import { AuthGuard } from './common/guards/auth.guard'; import { AuthGuard } from './common/guards/auth.guard';
import { PermissionsGuard } from './common/guards/permissions.guard'; import { PermissionsGuard } from './common/guards/permissions.guard';
import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
@@ -52,6 +56,10 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
UsersModule, UsersModule,
RolesModule, RolesModule,
HealthModule, HealthModule,
TelephonyModule,
TrunksModule,
ExtensionsModule,
MonitoringModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ providers: [

View File

@@ -0,0 +1,57 @@
import {
IsArray,
IsBoolean,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
export class CreateExtensionDto {
@IsString()
@Matches(/^\d{2,10}$/, {
message: 'Número de ramal deve conter apenas dígitos (2 a 10).',
})
number!: string;
@IsString()
@MinLength(1)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
callerId?: string;
@IsOptional()
@IsString()
context?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
codecs?: string[];
@IsOptional()
@IsString()
transport?: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(10)
maxContacts?: number;
@IsOptional()
@IsInt()
@Min(10)
qualifyFrequency?: number;
@IsOptional()
@IsBoolean()
enabled?: boolean;
}

View File

@@ -0,0 +1,8 @@
import { PartialType, OmitType } from '@nestjs/mapped-types';
import { CreateExtensionDto } from './create-extension.dto';
// "number" não é editável — trocar o número de ramal é, na prática, criar
// outro ramal (o identificador do objeto PJSIP é o próprio número).
export class UpdateExtensionDto extends PartialType(
OmitType(CreateExtensionDto, ['number'] as const),
) {}

View File

@@ -0,0 +1,88 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { ExtensionsService } from './extensions.service';
import { CreateExtensionDto } from './dto/create-extension.dto';
import { UpdateExtensionDto } from './dto/update-extension.dto';
@Controller('extensions')
export class ExtensionsController {
constructor(private readonly extensionsService: ExtensionsService) {}
@Get()
@RequirePermissions('extensions.view')
list() {
return this.extensionsService.list();
}
@Get(':id')
@RequirePermissions('extensions.view')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.extensionsService.findByIdOrThrow(id);
}
@Post()
@RequirePermissions('extensions.create')
create(
@Body() dto: CreateExtensionDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.extensionsService.create(dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Patch(':id')
@RequirePermissions('extensions.update')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateExtensionDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.extensionsService.update(id, dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Post(':id/reset-password')
@RequirePermissions('extensions.update')
resetPassword(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.extensionsService.resetPassword(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Delete(':id')
@RequirePermissions('extensions.delete')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.extensionsService.delete(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { ExtensionsController } from './extensions.controller';
import { ExtensionsService } from './extensions.service';
@Module({
controllers: [ExtensionsController],
providers: [ExtensionsService],
})
export class ExtensionsModule {}

View File

@@ -0,0 +1,230 @@
import {
BadRequestException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Extension } from '@b2bcall/database';
import {
decryptSecret,
encryptSecret,
generateStrongPassword,
} from '@b2bcall/shared';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import type { RequestContext } from '../auth/auth.service';
import { PjsipRealtimeService } from '../telephony/pjsip-realtime.service';
import { CreateExtensionDto } from './dto/create-extension.dto';
import { UpdateExtensionDto } from './dto/update-extension.dto';
function toSafeExtension(ext: Extension) {
return {
id: ext.id,
number: ext.number,
name: ext.name,
callerId: ext.callerId,
context: ext.context,
codecs: ext.codecs,
transport: ext.transport,
maxContacts: ext.maxContacts,
qualifyFrequency: ext.qualifyFrequency,
enabled: ext.enabled,
createdAt: ext.createdAt,
updatedAt: ext.updatedAt,
};
}
@Injectable()
export class ExtensionsService {
constructor(
private readonly prisma: PrismaService,
private readonly pjsip: PjsipRealtimeService,
private readonly audit: AuditService,
private readonly config: ConfigService,
) {}
private get masterKey(): string {
return this.config.getOrThrow<string>('SECRETS_MASTER_KEY');
}
async list() {
const extensions = await this.prisma.extension.findMany({
orderBy: { number: 'asc' },
});
const states = await this.prisma.extensionState.findMany({
where: { extension: { in: extensions.map((e) => e.number) } },
});
const stateByExtension = new Map(states.map((s) => [s.extension, s]));
return extensions.map((e) => ({
...toSafeExtension(e),
state: stateByExtension.get(e.number) ?? null,
}));
}
async findByIdOrThrow(id: string) {
const extension = await this.prisma.extension.findUnique({ where: { id } });
if (!extension) throw new NotFoundException('Ramal não encontrado.');
const state = await this.prisma.extensionState.findUnique({
where: { extension: extension.number },
});
return { ...toSafeExtension(extension), state };
}
private async provisionPjsip(extension: Extension, plainPassword: string) {
const id = extension.number;
await this.pjsip.upsertAuth({ id, username: id, password: plainPassword });
await this.pjsip.upsertAor({
id,
maxContacts: extension.maxContacts,
qualifyFrequency: extension.qualifyFrequency,
});
await this.pjsip.upsertEndpoint({
id,
aors: id,
auth: id,
context: extension.context,
allowCodecs: extension.codecs,
dtmfMode: 'rfc4733',
callerId: extension.callerId ?? undefined,
identifyBy: 'username',
maxContacts: extension.maxContacts,
});
}
async create(
dto: CreateExtensionDto,
actor: { id: string },
ctx: RequestContext,
) {
const existing = await this.prisma.extension.findUnique({
where: { number: dto.number },
});
if (existing)
throw new BadRequestException('Já existe um ramal com este número.');
const password = generateStrongPassword();
const sipPasswordEncrypted = encryptSecret(password, this.masterKey);
const extension = await this.prisma.extension.create({
data: {
number: dto.number,
name: dto.name,
sipPasswordEncrypted,
callerId: dto.callerId,
context: dto.context ?? 'b2bcall-agents',
codecs: dto.codecs ?? ['ulaw', 'alaw'],
transport: dto.transport ?? 'udp',
maxContacts: dto.maxContacts ?? 1,
qualifyFrequency: dto.qualifyFrequency ?? 60,
enabled: dto.enabled ?? true,
},
});
await this.provisionPjsip(extension, password);
await this.audit.log({
userId: actor.id,
action: 'extension_created',
entityType: 'extension',
entityId: extension.id,
after: { number: extension.number, name: extension.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
// A senha só é exibida nesta resposta — nunca mais recuperável (só reset).
return { ...toSafeExtension(extension), sipPassword: password };
}
async update(
id: string,
dto: UpdateExtensionDto,
actor: { id: string },
ctx: RequestContext,
) {
const before = await this.prisma.extension.findUnique({ where: { id } });
if (!before) throw new NotFoundException('Ramal não encontrado.');
const extension = await this.prisma.extension.update({
where: { id },
data: {
name: dto.name,
callerId: dto.callerId,
context: dto.context,
codecs: dto.codecs,
transport: dto.transport,
maxContacts: dto.maxContacts,
qualifyFrequency: dto.qualifyFrequency,
enabled: dto.enabled,
},
});
// Não há troca de senha aqui — reaplica só endpoint/aor com a senha atual.
const currentPassword = decryptSecret(
extension.sipPasswordEncrypted,
this.masterKey,
);
await this.provisionPjsip(extension, currentPassword);
await this.audit.log({
userId: actor.id,
action: 'extension_updated',
entityType: 'extension',
entityId: id,
before: { name: before.name, enabled: before.enabled },
after: { name: extension.name, enabled: extension.enabled },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return toSafeExtension(extension);
}
async resetPassword(id: string, actor: { id: string }, ctx: RequestContext) {
const extension = await this.prisma.extension.findUnique({ where: { id } });
if (!extension) throw new NotFoundException('Ramal não encontrado.');
const password = generateStrongPassword();
const sipPasswordEncrypted = encryptSecret(password, this.masterKey);
const updated = await this.prisma.extension.update({
where: { id },
data: { sipPasswordEncrypted },
});
await this.provisionPjsip(updated, password);
await this.audit.log({
userId: actor.id,
action: 'extension_password_reset',
entityType: 'extension',
entityId: id,
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return { ...toSafeExtension(updated), sipPassword: password };
}
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
const extension = await this.prisma.extension.findUnique({ where: { id } });
if (!extension) throw new NotFoundException('Ramal não encontrado.');
await this.pjsip.deleteObjectCascade(extension.number);
await this.prisma.$transaction([
this.prisma.extensionState.deleteMany({
where: { extension: extension.number },
}),
this.prisma.extension.delete({ where: { id } }),
]);
await this.audit.log({
userId: actor.id,
action: 'extension_deleted',
entityType: 'extension',
entityId: id,
before: { number: extension.number },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
}

View File

@@ -31,6 +31,21 @@ export class HealthController {
return { redis: { status: 'up' } }; return { redis: { status: 'up' } };
}; };
// apps/asterisk-events grava um heartbeat a cada ~7,5s (TTL 15s) sempre
// que sua conexão AMI está viva — evita a API precisar de sua própria
// conexão AMI só para reportar status (agente.md seção 62).
private asteriskIndicator: HealthIndicatorFunction =
async (): Promise<HealthIndicatorResult> => {
const heartbeat = await this.redis.get(
'b2bcall:asterisk-events:heartbeat',
);
if (!heartbeat)
throw new Error(
'Sem heartbeat de apps/asterisk-events (AMI indisponível)',
);
return { asterisk: { status: 'up', lastHeartbeat: heartbeat } };
};
// Liveness: o processo da API está de pé. Não depende de dependências // Liveness: o processo da API está de pé. Não depende de dependências
// externas — usado por orquestradores para decidir se precisa reiniciar. // externas — usado por orquestradores para decidir se precisa reiniciar.
@Public() @Public()
@@ -41,18 +56,25 @@ export class HealthController {
} }
// Readiness: a API está pronta para tráfego real (dependências no ar). // Readiness: a API está pronta para tráfego real (dependências no ar).
// Asterisk/AMI entram aqui quando apps/asterisk-events existir (Fase 4).
@Public() @Public()
@Get('ready') @Get('ready')
@HealthCheck() @HealthCheck()
ready() { ready() {
return this.health.check([this.postgresIndicator, this.redisIndicator]); return this.health.check([
this.postgresIndicator,
this.redisIndicator,
this.asteriskIndicator,
]);
} }
@Public() @Public()
@Get() @Get()
@HealthCheck() @HealthCheck()
check() { check() {
return this.health.check([this.postgresIndicator, this.redisIndicator]); return this.health.check([
this.postgresIndicator,
this.redisIndicator,
this.asteriskIndicator,
]);
} }
} }

View File

@@ -0,0 +1,26 @@
// Prioridade visual do painel de ramais (agente.md seção 15). As camadas de
// PAUSA e AGENTE LOGADO dependem de dados da Fase 5 (agents/agent_sessions)
// — por ora computamos apenas REGISTERED/AVAILABLE/BUSY/OFFLINE a partir do
// estado real do dispositivo PJSIP.
export type ExtensionVisualStatus = 'OFFLINE' | 'AVAILABLE' | 'BUSY';
const BUSY_DEVICE_STATES = new Set([
'INUSE',
'BUSY',
'RINGING',
'RINGINUSE',
'ONHOLD',
]);
export function computeExtensionVisualStatus(
deviceState: string | null | undefined,
contactStatus: string | null | undefined,
): ExtensionVisualStatus {
const isRegistered =
contactStatus === 'Reachable' ||
contactStatus === 'Created' ||
contactStatus === 'Updated';
if (!isRegistered) return 'OFFLINE';
if (deviceState && BUSY_DEVICE_STATES.has(deviceState)) return 'BUSY';
return 'AVAILABLE';
}

View File

@@ -0,0 +1,37 @@
import { Controller, Get } from '@nestjs/common';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { PrismaService } from '../prisma/prisma.service';
import { computeExtensionVisualStatus } from './extension-status.util';
@Controller('monitoring')
export class MonitoringController {
constructor(private readonly prisma: PrismaService) {}
@Get('extensions')
@RequirePermissions('monitoring.view')
async extensions() {
const extensions = await this.prisma.extension.findMany({
orderBy: { number: 'asc' },
});
const states = await this.prisma.extensionState.findMany({
where: { extension: { in: extensions.map((e) => e.number) } },
});
const stateByExtension = new Map(states.map((s) => [s.extension, s]));
return extensions.map((ext) => {
const state = stateByExtension.get(ext.number);
return {
number: ext.number,
name: ext.name,
enabled: ext.enabled,
deviceState: state?.deviceState ?? null,
contactStatus: state?.contactStatus ?? null,
status: computeExtensionVisualStatus(
state?.deviceState,
state?.contactStatus,
),
updatedAt: state?.updatedAt ?? null,
};
});
}
}

View File

@@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { MonitoringController } from './monitoring.controller';
@Module({
controllers: [MonitoringController],
})
export class MonitoringModule {}

View File

@@ -0,0 +1,160 @@
import { Injectable } from '@nestjs/common';
import { Prisma } from '@b2bcall/database';
import { PrismaService } from '../prisma/prisma.service';
export interface EndpointFields {
id: string;
transport?: string;
aors: string;
auth?: string;
outboundAuth?: string;
context: string;
allowCodecs: string[];
dtmfMode: string;
callerId?: string;
identifyBy: 'username' | 'ip' | 'username,ip';
maxContacts: number;
}
export interface AuthFields {
id: string;
username: string;
password: string;
}
export interface AorFields {
id: string;
contact?: string;
maxContacts: number;
qualifyFrequency: number;
}
export interface IdentifyFields {
id: string;
endpoint: string;
match: string;
}
export interface RegistrationFields {
id: string;
endpoint: string;
clientUri: string;
serverUri: string;
outboundAuth?: string;
transport?: string;
retryInterval: number;
maxRetries: number;
}
/**
* Único ponto de escrita nas tabelas de Realtime do Asterisk (schema
* "asterisk" — nunca "public"). Nenhum outro serviço deve montar SQL contra
* essas tabelas diretamente (agente.md seção 6: "não misture indiscriminadamente
* tabelas da aplicação com tabelas internas do Asterisk").
*/
@Injectable()
export class PjsipRealtimeService {
constructor(private readonly prisma: PrismaService) {}
async upsertEndpoint(f: EndpointFields): Promise<void> {
await this.prisma.$executeRaw`
INSERT INTO asterisk.ps_endpoints
(id, transport, aors, auth, outbound_auth, context, disallow, allow,
dtmf_mode, callerid, identify_by, max_contacts, rewrite_contact,
rtp_symmetric, force_rport, type)
VALUES
(${f.id}, ${f.transport ?? null}, ${f.aors}, ${f.auth ?? null}, ${f.outboundAuth ?? null},
${f.context}, 'all', ${f.allowCodecs.join(',')}, ${f.dtmfMode}, ${f.callerId ?? null},
${f.identifyBy}, ${f.maxContacts}, 'yes', 'yes', 'yes', 'endpoint')
ON CONFLICT (id) DO UPDATE SET
transport = EXCLUDED.transport,
aors = EXCLUDED.aors,
auth = EXCLUDED.auth,
outbound_auth = EXCLUDED.outbound_auth,
context = EXCLUDED.context,
allow = EXCLUDED.allow,
dtmf_mode = EXCLUDED.dtmf_mode,
callerid = EXCLUDED.callerid,
identify_by = EXCLUDED.identify_by,
max_contacts = EXCLUDED.max_contacts
`;
}
async upsertAuth(f: AuthFields): Promise<void> {
await this.prisma.$executeRaw`
INSERT INTO asterisk.ps_auths (id, auth_type, username, password)
VALUES (${f.id}, 'userpass', ${f.username}, ${f.password})
ON CONFLICT (id) DO UPDATE SET username = EXCLUDED.username, password = EXCLUDED.password
`;
}
async upsertAor(f: AorFields): Promise<void> {
await this.prisma.$executeRaw`
INSERT INTO asterisk.ps_aors (id, contact, max_contacts, qualify_frequency, remove_existing)
VALUES (${f.id}, ${f.contact ?? null}, ${f.maxContacts}, ${f.qualifyFrequency}, 'yes')
ON CONFLICT (id) DO UPDATE SET
contact = EXCLUDED.contact,
max_contacts = EXCLUDED.max_contacts,
qualify_frequency = EXCLUDED.qualify_frequency
`;
}
async upsertIdentify(f: IdentifyFields): Promise<void> {
await this.prisma.$executeRaw`
INSERT INTO asterisk.ps_endpoint_id_ips (id, endpoint, match)
VALUES (${f.id}, ${f.endpoint}, ${f.match})
ON CONFLICT (id) DO UPDATE SET endpoint = EXCLUDED.endpoint, match = EXCLUDED.match
`;
}
async deleteIdentifyByEndpoint(endpoint: string): Promise<void> {
await this.prisma
.$executeRaw`DELETE FROM asterisk.ps_endpoint_id_ips WHERE endpoint = ${endpoint}`;
}
async upsertRegistration(f: RegistrationFields): Promise<void> {
await this.prisma.$executeRaw`
INSERT INTO asterisk.ps_registrations
(id, endpoint, client_uri, server_uri, outbound_auth, transport, retry_interval, max_retries)
VALUES
(${f.id}, ${f.endpoint}, ${f.clientUri}, ${f.serverUri}, ${f.outboundAuth ?? null},
${f.transport ?? null}, ${f.retryInterval}, ${f.maxRetries})
ON CONFLICT (id) DO UPDATE SET
client_uri = EXCLUDED.client_uri,
server_uri = EXCLUDED.server_uri,
outbound_auth = EXCLUDED.outbound_auth,
transport = EXCLUDED.transport,
retry_interval = EXCLUDED.retry_interval,
max_retries = EXCLUDED.max_retries
`;
}
async deleteRegistration(id: string): Promise<void> {
await this.prisma
.$executeRaw`DELETE FROM asterisk.ps_registrations WHERE id = ${id}`;
}
/** Remove um objeto (trunk ou ramal) e tudo que depende dele, em cascata manual. */
async deleteObjectCascade(id: string): Promise<void> {
await this.prisma.$transaction([
this.prisma
.$executeRaw`DELETE FROM asterisk.ps_contacts WHERE endpoint = ${id}`,
this.prisma
.$executeRaw`DELETE FROM asterisk.ps_registrations WHERE endpoint = ${id}`,
this.prisma
.$executeRaw`DELETE FROM asterisk.ps_endpoint_id_ips WHERE endpoint = ${id}`,
this.prisma
.$executeRaw`DELETE FROM asterisk.ps_endpoints WHERE id = ${id}`,
this.prisma.$executeRaw`DELETE FROM asterisk.ps_auths WHERE id = ${id}`,
this.prisma.$executeRaw`DELETE FROM asterisk.ps_aors WHERE id = ${id}`,
] as Prisma.PrismaPromise<unknown>[]);
}
async getContacts(
endpoint: string,
): Promise<Array<{ uri: string; via_addr: string | null }>> {
return this.prisma.$queryRaw`
SELECT uri, via_addr FROM asterisk.ps_contacts WHERE endpoint = ${endpoint}
`;
}
}

View File

@@ -0,0 +1,59 @@
import {
Global,
Inject,
Module,
OnModuleDestroy,
OnModuleInit,
Logger,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AsteriskTelephonyProvider } from '@b2bcall/telephony';
import { PjsipRealtimeService } from './pjsip-realtime.service';
export const TELEPHONY_PROVIDER = 'TELEPHONY_PROVIDER';
@Global()
@Module({
providers: [
{
provide: TELEPHONY_PROVIDER,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new AsteriskTelephonyProvider({
host: config.getOrThrow<string>('ASTERISK_HOST'),
amiPort: Number(config.get('AMI_PORT', '5038')),
amiUsername: config.getOrThrow<string>('AMI_USERNAME'),
amiSecret: config.getOrThrow<string>('AMI_SECRET'),
reconnect: true,
}),
},
PjsipRealtimeService,
],
exports: [TELEPHONY_PROVIDER, PjsipRealtimeService],
})
export class TelephonyModule implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(TelephonyModule.name);
constructor(
@Inject(TELEPHONY_PROVIDER)
private readonly provider: AsteriskTelephonyProvider,
) {}
async onModuleInit() {
try {
await this.provider.connect();
this.logger.log('Conectado ao AMI do Asterisk');
} catch (err) {
// Não derruba a API se o Asterisk estiver fora do ar no boot — o
// client tem reconnect automático e tentará novamente. Endpoints que
// dependem dele falharão individualmente até a conexão voltar.
this.logger.error(
`Falha ao conectar ao AMI (tentará reconectar): ${(err as Error).message}`,
);
}
}
onModuleDestroy() {
this.provider.disconnect();
}
}

View File

@@ -0,0 +1,111 @@
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { TrunkType, DtmfMode } from '@b2bcall/database';
export class CreateTrunkDto {
@IsString()
@MinLength(1)
@MaxLength(80)
@Matches(/^[a-zA-Z0-9_-]+$/, {
message: 'Use apenas letras, números, hífen e underscore.',
})
name!: string;
@IsEnum(TrunkType)
type!: TrunkType;
@IsString()
@MinLength(1)
host!: string;
@IsOptional()
@IsInt()
@Min(1)
@Max(65535)
port?: number;
@IsOptional()
@IsString()
transport?: string;
@IsOptional()
@IsString()
username?: string;
@IsOptional()
@IsString()
password?: string;
@IsOptional()
@IsString()
fromUser?: string;
@IsOptional()
@IsString()
fromDomain?: string;
@IsOptional()
@IsString()
contactUser?: string;
@IsOptional()
@IsString()
outboundProxy?: string;
@IsOptional()
@IsString()
context?: string;
@IsOptional()
@IsString()
callerId?: string;
@IsOptional()
@IsArray()
@IsString({ each: true })
codecs?: string[];
@IsOptional()
@IsEnum(DtmfMode)
dtmfMode?: DtmfMode;
@IsOptional()
@IsInt()
@Min(10)
qualifyFrequency?: number;
@IsOptional()
@IsInt()
@Min(1)
maxChannels?: number;
@IsInt()
@Min(1)
@Max(1000)
maxCps!: number;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@Matches(/^\d{1,3}(\.\d{1,3}){3}(\/\d{1,2})?$/, {
each: true,
message: 'Use IPv4 ou CIDR, ex.: 10.0.0.0/24',
})
allowedIps?: string[];
@IsOptional()
@IsBoolean()
enabled?: boolean;
}

View File

@@ -0,0 +1,8 @@
import { PartialType, OmitType } from '@nestjs/mapped-types';
import { CreateTrunkDto } from './create-trunk.dto';
// "name" e "type" não são editáveis após a criação — trocar exigiria
// reprovisionar os objetos PJSIP do zero; mais simples desativar e recriar.
export class UpdateTrunkDto extends PartialType(
OmitType(CreateTrunkDto, ['name', 'type'] as const),
) {}

View File

@@ -0,0 +1,81 @@
import {
Body,
Controller,
Delete,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Req,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
import { RequirePermissions } from '../common/decorators/permissions.decorator';
import { CurrentUser } from '../common/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../common/guards/auth.guard';
import { TrunksService } from './trunks.service';
import { CreateTrunkDto } from './dto/create-trunk.dto';
import { UpdateTrunkDto } from './dto/update-trunk.dto';
@Controller('trunks')
export class TrunksController {
constructor(private readonly trunksService: TrunksService) {}
@Get()
@RequirePermissions('trunks.view')
list() {
return this.trunksService.list();
}
@Get(':id')
@RequirePermissions('trunks.view')
findOne(@Param('id', ParseUUIDPipe) id: string) {
return this.trunksService.findByIdOrThrow(id);
}
@Get(':id/status')
@RequirePermissions('trunks.view')
status(@Param('id', ParseUUIDPipe) id: string) {
return this.trunksService.testStatus(id).then((status) => ({ status }));
}
@Post()
@RequirePermissions('trunks.create')
create(
@Body() dto: CreateTrunkDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.trunksService.create(dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Patch(':id')
@RequirePermissions('trunks.update')
update(
@Param('id', ParseUUIDPipe) id: string,
@Body() dto: UpdateTrunkDto,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.trunksService.update(id, dto, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
@Delete(':id')
@RequirePermissions('trunks.delete')
remove(
@Param('id', ParseUUIDPipe) id: string,
@CurrentUser() actor: AuthenticatedUser,
@Req() request: FastifyRequest,
) {
return this.trunksService.delete(id, actor, {
ip: request.ip,
userAgent: request.headers['user-agent'],
});
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TrunksController } from './trunks.controller';
import { TrunksService } from './trunks.service';
@Module({
controllers: [TrunksController],
providers: [TrunksService],
})
export class TrunksModule {}

View File

@@ -0,0 +1,279 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Trunk, TrunkType } from '@b2bcall/database';
import { encryptSecret } from '@b2bcall/shared';
import type { TelephonyProvider } from '@b2bcall/telephony';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import type { RequestContext } from '../auth/auth.service';
import { TELEPHONY_PROVIDER } from '../telephony/telephony.module';
import { PjsipRealtimeService } from '../telephony/pjsip-realtime.service';
import { CreateTrunkDto } from './dto/create-trunk.dto';
import { UpdateTrunkDto } from './dto/update-trunk.dto';
export type TrunkStatus = 'ONLINE' | 'OFFLINE' | 'UNREACHABLE' | 'UNKNOWN';
function toSafeTrunk(trunk: Trunk) {
const { secretEncrypted: _secretEncrypted, ...safe } = trunk;
return { ...safe, hasSecret: Boolean(_secretEncrypted) };
}
@Injectable()
export class TrunksService {
constructor(
private readonly prisma: PrismaService,
private readonly pjsip: PjsipRealtimeService,
private readonly audit: AuditService,
private readonly config: ConfigService,
@Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider,
) {}
private get masterKey(): string {
return this.config.getOrThrow<string>('SECRETS_MASTER_KEY');
}
async list() {
const trunks = await this.prisma.trunk.findMany({
orderBy: { name: 'asc' },
});
return trunks.map(toSafeTrunk);
}
async findByIdOrThrow(id: string) {
const trunk = await this.prisma.trunk.findUnique({ where: { id } });
if (!trunk) throw new NotFoundException('Tronco não encontrado.');
return toSafeTrunk(trunk);
}
// Provisiona os objetos PJSIP realtime correspondentes ao tipo do tronco
// (agente.md seção 5: IP Authentication / Username-Password / Registration).
private async provisionPjsip(trunk: Trunk, plainSecret?: string) {
const id = trunk.name;
if (
trunk.type === TrunkType.AUTH ||
trunk.type === TrunkType.REGISTRATION
) {
if (!trunk.username || !plainSecret) {
throw new BadRequestException(
'Troncos do tipo AUTH/REGISTRATION exigem username e password.',
);
}
await this.pjsip.upsertAuth({
id,
username: trunk.username,
password: plainSecret,
});
}
await this.pjsip.upsertAor({
id,
contact:
trunk.type !== TrunkType.REGISTRATION
? `sip:${trunk.host}:${trunk.port}`
: undefined,
maxContacts: trunk.maxChannels ?? 10,
qualifyFrequency: trunk.qualifyFrequency,
});
await this.pjsip.upsertEndpoint({
id,
aors: id,
auth: trunk.type === TrunkType.AUTH ? id : undefined,
outboundAuth:
trunk.type === TrunkType.AUTH || trunk.type === TrunkType.REGISTRATION
? id
: undefined,
context: trunk.context,
allowCodecs: trunk.codecs,
dtmfMode: trunk.dtmfMode,
callerId: trunk.callerId ?? undefined,
identifyBy: trunk.type === TrunkType.IP ? 'ip' : 'username,ip',
maxContacts: trunk.maxChannels ?? 10,
});
await this.pjsip.deleteIdentifyByEndpoint(id);
if (trunk.type === TrunkType.IP) {
const ips = trunk.allowedIps.length > 0 ? trunk.allowedIps : [trunk.host];
for (const [i, ip] of ips.entries()) {
await this.pjsip.upsertIdentify({
id: `${id}-ip-${i}`,
endpoint: id,
match: ip,
});
}
}
if (trunk.type === TrunkType.REGISTRATION) {
await this.pjsip.upsertRegistration({
id,
endpoint: id,
clientUri: `sip:${trunk.contactUser ?? trunk.username}@${trunk.host}:${trunk.port}`,
serverUri: `sip:${trunk.host}:${trunk.port}`,
outboundAuth: id,
transport: trunk.transport,
retryInterval: 60,
maxRetries: 10,
});
} else {
await this.pjsip.deleteRegistration(id);
}
}
async create(
dto: CreateTrunkDto,
actor: { id: string },
ctx: RequestContext,
) {
const existing = await this.prisma.trunk.findUnique({
where: { name: dto.name },
});
if (existing)
throw new BadRequestException('Já existe um tronco com este nome.');
const secretEncrypted = dto.password
? encryptSecret(dto.password, this.masterKey)
: null;
const trunk = await this.prisma.trunk.create({
data: {
name: dto.name,
type: dto.type,
host: dto.host,
port: dto.port ?? 5060,
transport: dto.transport ?? 'udp',
username: dto.username,
secretEncrypted,
fromUser: dto.fromUser,
fromDomain: dto.fromDomain,
contactUser: dto.contactUser,
outboundProxy: dto.outboundProxy,
context: dto.context ?? 'outbound',
callerId: dto.callerId,
codecs: dto.codecs ?? ['ulaw', 'alaw'],
dtmfMode: dto.dtmfMode,
qualifyFrequency: dto.qualifyFrequency ?? 60,
maxChannels: dto.maxChannels,
maxCps: dto.maxCps,
allowedIps: dto.allowedIps ?? [],
enabled: dto.enabled ?? true,
},
});
await this.provisionPjsip(trunk, dto.password);
await this.audit.log({
userId: actor.id,
action: 'trunk_created',
entityType: 'trunk',
entityId: trunk.id,
after: { ...dto, password: dto.password ? '[REDACTED]' : undefined },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return toSafeTrunk(trunk);
}
async update(
id: string,
dto: UpdateTrunkDto,
actor: { id: string },
ctx: RequestContext,
) {
const before = await this.prisma.trunk.findUnique({ where: { id } });
if (!before) throw new NotFoundException('Tronco não encontrado.');
const secretEncrypted = dto.password
? encryptSecret(dto.password, this.masterKey)
: undefined;
const trunk = await this.prisma.trunk.update({
where: { id },
data: {
host: dto.host,
port: dto.port,
transport: dto.transport,
username: dto.username,
...(secretEncrypted !== undefined ? { secretEncrypted } : {}),
fromUser: dto.fromUser,
fromDomain: dto.fromDomain,
contactUser: dto.contactUser,
outboundProxy: dto.outboundProxy,
context: dto.context,
callerId: dto.callerId,
codecs: dto.codecs,
dtmfMode: dto.dtmfMode,
qualifyFrequency: dto.qualifyFrequency,
maxChannels: dto.maxChannels,
maxCps: dto.maxCps,
allowedIps: dto.allowedIps,
enabled: dto.enabled,
},
});
await this.provisionPjsip(trunk, dto.password);
await this.audit.log({
userId: actor.id,
action: 'trunk_updated',
entityType: 'trunk',
entityId: id,
before: {
...before,
secretEncrypted: before.secretEncrypted ? '[REDACTED]' : null,
},
after: { ...dto, password: dto.password ? '[REDACTED]' : undefined },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
return toSafeTrunk(trunk);
}
async delete(id: string, actor: { id: string }, ctx: RequestContext) {
const trunk = await this.prisma.trunk.findUnique({ where: { id } });
if (!trunk) throw new NotFoundException('Tronco não encontrado.');
await this.pjsip.deleteObjectCascade(trunk.name);
await this.prisma.trunk.delete({ where: { id } });
await this.audit.log({
userId: actor.id,
action: 'trunk_deleted',
entityType: 'trunk',
entityId: id,
before: { name: trunk.name },
ipAddress: ctx.ip,
userAgent: ctx.userAgent,
});
}
async testStatus(id: string): Promise<TrunkStatus> {
const trunk = await this.prisma.trunk.findUnique({ where: { id } });
if (!trunk) throw new NotFoundException('Tronco não encontrado.');
if (!this.telephony.isConnected()) return 'UNKNOWN';
if (trunk.type === TrunkType.REGISTRATION) {
const output = await this.telephony.runCommand(
`pjsip show registration ${trunk.name}`,
);
if (/Registered/i.test(output)) return 'ONLINE';
if (/Rejected|Unregistered/i.test(output)) return 'OFFLINE';
return 'UNKNOWN';
}
const contacts = await this.telephony.pjsipShowContacts();
const mine = contacts.filter((c) => c.endpointName === trunk.name);
if (mine.length === 0) return 'UNKNOWN';
if (mine.some((c) => c.status === 'Reachable')) return 'ONLINE';
if (mine.every((c) => c.status === 'Unreachable')) return 'UNREACHABLE';
return 'UNKNOWN';
}
}

View File

@@ -14,14 +14,17 @@ COPY package.json pnpm-workspace.yaml pnpm-lock.yaml ./
COPY apps/api/package.json apps/api/package.json COPY apps/api/package.json apps/api/package.json
COPY packages/database/package.json packages/database/package.json COPY packages/database/package.json packages/database/package.json
COPY packages/shared/package.json packages/shared/package.json COPY packages/shared/package.json packages/shared/package.json
COPY packages/telephony/package.json packages/telephony/package.json
RUN pnpm install --frozen-lockfile RUN pnpm install --frozen-lockfile
COPY packages/shared packages/shared COPY packages/shared packages/shared
COPY packages/telephony packages/telephony
COPY packages/database packages/database COPY packages/database packages/database
COPY apps/api apps/api COPY apps/api apps/api
RUN pnpm --filter @b2bcall/shared build \ RUN pnpm --filter @b2bcall/shared build \
&& pnpm --filter @b2bcall/telephony build \
&& pnpm --filter @b2bcall/database build \ && pnpm --filter @b2bcall/database build \
&& pnpm --filter @b2bcall/api build && pnpm --filter @b2bcall/api build

6
pnpm-lock.yaml generated
View File

@@ -16,6 +16,9 @@ importers:
'@b2bcall/shared': '@b2bcall/shared':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/shared version: link:../../packages/shared
'@b2bcall/telephony':
specifier: workspace:*
version: link:../../packages/telephony
'@fastify/cookie': '@fastify/cookie':
specifier: ^11.0.2 specifier: ^11.0.2
version: 11.1.2 version: 11.1.2
@@ -37,6 +40,9 @@ importers:
'@nestjs/jwt': '@nestjs/jwt':
specifier: ^11.0.0 specifier: ^11.0.0
version: 11.0.2(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1)) version: 11.0.2(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))
'@nestjs/mapped-types':
specifier: ^2.1.0
version: 2.1.1(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)
'@nestjs/platform-fastify': '@nestjs/platform-fastify':
specifier: ^11.0.1 specifier: ^11.0.1
version: 11.2.3(@fastify/static@8.3.0)(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)(rxjs@7.8.2)) version: 11.2.3(@fastify/static@8.3.0)(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@11.2.3(@nestjs/common@11.2.3(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(reflect-metadata@0.2.2)(rxjs@7.8.2))