From cf3fc4b3efd394ab579044e47c5c4d037d491994 Mon Sep 17 00:00:00 2001 From: B2BCall Bootstrap Date: Thu, 27 Aug 2026 13:14:50 -0300 Subject: [PATCH] feat: add structured versioned dialplan and asterisk admin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - packages/database: DialplanEntry + DialplanVersion (schema/config gerado, nunca dialplan cru vindo do usuario) - apps/api/src/dialplan: DialplanService gera extensions a partir de entradas estruturadas (campos com allowlist estrita de caracteres), escreve em arquivo compartilhado via volume Docker 'dialplan-generated' entre api e asterisk, recarrega via AMI ('dialplan reload') e valida checando 'dialplan show ' por marcadores de erro. Falha na validacao dispara rollback automatico para a versao anterior; rollback manual tambem disponivel para qualquer versao no historico - infrastructure/asterisk: extensions.conf agora inclui o arquivo gerado pela aplicacao; entrypoint garante que ele existe (vazio) no primeiro boot antes da primeira publicacao - apps/api/src/asterisk-admin: status (AMI + heartbeat), module show, e diagnostico com ALLOWLIST ESTRITA de comandos exatos (nunca shell arbitraria) — agente.md secao 22 Testado ponta a ponta contra o Asterisk real: dialplan publicado fica ativo imediatamente sem restart (confirmado via 'dialplan show'), comando de diagnostico fora da allowlist rejeitado com 400. --- TODO.md | 27 +- apps/api/src/app.module.ts | 4 + .../asterisk-admin.controller.ts | 56 ++++ .../asterisk-admin/asterisk-admin.module.ts | 9 + .../asterisk-admin/asterisk-admin.service.ts | 93 ++++++ .../asterisk-admin/diagnostic-allowlist.ts | 24 ++ .../dto/diagnostic-command.dto.ts | 7 + apps/api/src/dialplan/dialplan-generator.ts | 44 +++ apps/api/src/dialplan/dialplan.controller.ts | 103 +++++++ apps/api/src/dialplan/dialplan.module.ts | 9 + apps/api/src/dialplan/dialplan.service.ts | 266 ++++++++++++++++++ .../dialplan/dto/create-dialplan-entry.dto.ts | 54 ++++ .../dialplan/dto/update-dialplan-entry.dto.ts | 6 + docker-compose.yml | 6 + .../asterisk/config/extensions.conf | 9 +- infrastructure/docker/asterisk-entrypoint.sh | 10 +- .../20260827155941_add_dialplan/migration.sql | 33 +++ packages/database/prisma/schema.prisma | 39 +++ 18 files changed, 789 insertions(+), 10 deletions(-) create mode 100644 apps/api/src/asterisk-admin/asterisk-admin.controller.ts create mode 100644 apps/api/src/asterisk-admin/asterisk-admin.module.ts create mode 100644 apps/api/src/asterisk-admin/asterisk-admin.service.ts create mode 100644 apps/api/src/asterisk-admin/diagnostic-allowlist.ts create mode 100644 apps/api/src/asterisk-admin/dto/diagnostic-command.dto.ts create mode 100644 apps/api/src/dialplan/dialplan-generator.ts create mode 100644 apps/api/src/dialplan/dialplan.controller.ts create mode 100644 apps/api/src/dialplan/dialplan.module.ts create mode 100644 apps/api/src/dialplan/dialplan.service.ts create mode 100644 apps/api/src/dialplan/dto/create-dialplan-entry.dto.ts create mode 100644 apps/api/src/dialplan/dto/update-dialplan-entry.dto.ts create mode 100644 packages/database/prisma/migrations/20260827155941_add_dialplan/migration.sql diff --git a/TODO.md b/TODO.md index 4fe2bc9..f9ce0c8 100644 --- a/TODO.md +++ b/TODO.md @@ -113,11 +113,28 @@ mestre original (`agente.md`, seções 90-93). 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 - (gateway WS) pendente; ExtensionState/pub-sub já prontos como base -- [ ] Dialplan estruturado (versionado, modo Advanced, validação+rollback) -- [ ] Administração do Asterisk (abas: geral, pjsip, rtp, filas, cdr, cel, logs, - ami, ari, modules, diagnóstico com allowlist de comandos) +- [ ] Painel visual de Ramais (WebSocket) — backend (gateway WS) ainda + pendente; ExtensionState/pub-sub e o endpoint de monitoramento já + prontos como base (frontend consumirá via WS na Fase 8) +- [x] Dialplan estruturado e versionado (dialplan_entries + dialplan_versions) + — testado ponta a ponta: criar entradas -> publicar -> gera + b2bcall-dialplan.conf (volume compartilhado api<->asterisk) -> AMI + "dialplan reload" -> "dialplan show" confirma ativo, SEM restart. + Validação estrita de caracteres em cada campo (nunca aceita entrada + capaz de injetar linhas de config). Rollback automático se a + verificação pós-reload detectar erro; rollback manual para qualquer + versão anterior via endpoint dedicado. Limitação conhecida: detecta + falhas estruturais/sintáticas, não erros semânticos como nome de + aplicação Asterisk inexistente (só se manifesta em tempo de chamada) +- [x] Administração do Asterisk: status (AMI + heartbeat asterisk-events), + module show, e diagnóstico com ALLOWLIST ESTRITA de comandos exatos + (core show uptime/channels/version, pjsip show *, queue show, module + show, dialplan show) — testado: comando permitido executa, comando + arbitrário rejeitado com 400. Abas de configuração fina (pjsip/rtp/ + cdr/cel/logs como formulário editável) ficam para quando o fluxo de + "editor avançado com backup/validação" for generalizado além do + dialplan — no momento config geral do Asterisk é só leitura via + status/diagnóstico, não tem tela de edição de asterisk.conf/rtp.conf ## Fase 5 — Call Center - [ ] Agentes (agents, agent_sessions) separados de users diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index ca30d6b..126a6f1 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -17,6 +17,8 @@ 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 { DialplanModule } from './dialplan/dialplan.module'; +import { AsteriskAdminModule } from './asterisk-admin/asterisk-admin.module'; import { AuthGuard } from './common/guards/auth.guard'; import { PermissionsGuard } from './common/guards/permissions.guard'; import { GlobalExceptionFilter } from './common/filters/global-exception.filter'; @@ -60,6 +62,8 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter' TrunksModule, ExtensionsModule, MonitoringModule, + DialplanModule, + AsteriskAdminModule, ], controllers: [AppController], providers: [ diff --git a/apps/api/src/asterisk-admin/asterisk-admin.controller.ts b/apps/api/src/asterisk-admin/asterisk-admin.controller.ts new file mode 100644 index 0000000..ecb8ae5 --- /dev/null +++ b/apps/api/src/asterisk-admin/asterisk-admin.controller.ts @@ -0,0 +1,56 @@ +import { Body, Controller, Get, Post, Query, 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 { AsteriskAdminService } from './asterisk-admin.service'; +import { DiagnosticCommandDto } from './dto/diagnostic-command.dto'; + +@Controller('asterisk') +export class AsteriskAdminController { + constructor(private readonly asteriskAdminService: AsteriskAdminService) {} + + @Get('status') + @RequirePermissions('asterisk.view') + status() { + return this.asteriskAdminService.status(); + } + + @Get('modules') + @RequirePermissions('asterisk.view') + modules() { + return this.asteriskAdminService.modules(); + } + + @Get('diagnostic/allowed-commands') + @RequirePermissions('asterisk.view') + allowedCommands() { + return this.asteriskAdminService.listAllowedDiagnosticCommands(); + } + + @Post('diagnostic') + @RequirePermissions('asterisk.view') + runDiagnostic( + @Body() dto: DiagnosticCommandDto, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.asteriskAdminService.runDiagnostic(dto.command, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Post('reload') + @RequirePermissions('asterisk.reload') + reload( + @Query('module') module: string | undefined, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.asteriskAdminService.reload(module, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } +} diff --git a/apps/api/src/asterisk-admin/asterisk-admin.module.ts b/apps/api/src/asterisk-admin/asterisk-admin.module.ts new file mode 100644 index 0000000..b4a319b --- /dev/null +++ b/apps/api/src/asterisk-admin/asterisk-admin.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { AsteriskAdminController } from './asterisk-admin.controller'; +import { AsteriskAdminService } from './asterisk-admin.service'; + +@Module({ + controllers: [AsteriskAdminController], + providers: [AsteriskAdminService], +}) +export class AsteriskAdminModule {} diff --git a/apps/api/src/asterisk-admin/asterisk-admin.service.ts b/apps/api/src/asterisk-admin/asterisk-admin.service.ts new file mode 100644 index 0000000..0f8025d --- /dev/null +++ b/apps/api/src/asterisk-admin/asterisk-admin.service.ts @@ -0,0 +1,93 @@ +import { + BadRequestException, + Inject, + Injectable, + ServiceUnavailableException, +} from '@nestjs/common'; +import type Redis from 'ioredis'; +import type { TelephonyProvider } from '@b2bcall/telephony'; +import { TELEPHONY_PROVIDER } from '../telephony/telephony.module'; +import { REDIS_CLIENT } from '../redis/redis.module'; +import { AuditService } from '../audit/audit.service'; +import type { RequestContext } from '../auth/auth.service'; +import { + DIAGNOSTIC_COMMAND_ALLOWLIST, + isDiagnosticCommandAllowed, +} from './diagnostic-allowlist'; + +@Injectable() +export class AsteriskAdminService { + constructor( + @Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider, + @Inject(REDIS_CLIENT) private readonly redis: Redis, + private readonly audit: AuditService, + ) {} + + async status() { + const heartbeat = await this.redis.get('b2bcall:asterisk-events:heartbeat'); + return { + amiControlConnection: this.telephony.isConnected() ? 'up' : 'down', + asteriskEventsHeartbeat: heartbeat ? 'up' : 'down', + lastHeartbeat: heartbeat, + }; + } + + async modules() { + if (!this.telephony.isConnected()) + throw new ServiceUnavailableException('AMI indisponível.'); + const output = await this.telephony.runCommand('module show'); + return { raw: output }; + } + + listAllowedDiagnosticCommands(): readonly string[] { + return DIAGNOSTIC_COMMAND_ALLOWLIST; + } + + async runDiagnostic( + command: string, + actor: { id: string }, + ctx: RequestContext, + ) { + if (!isDiagnosticCommandAllowed(command)) { + throw new BadRequestException( + 'Comando não permitido. Apenas comandos da allowlist de diagnóstico podem ser executados.', + ); + } + if (!this.telephony.isConnected()) + throw new ServiceUnavailableException('AMI indisponível.'); + + const output = await this.telephony.runCommand(command); + + await this.audit.log({ + userId: actor.id, + action: 'asterisk_diagnostic_command', + entityType: 'asterisk', + after: { command }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + return { command, output }; + } + + async reload( + module: string | undefined, + actor: { id: string }, + ctx: RequestContext, + ) { + if (!this.telephony.isConnected()) + throw new ServiceUnavailableException('AMI indisponível.'); + await this.telephony.reload(module); + + await this.audit.log({ + userId: actor.id, + action: 'asterisk_reload', + entityType: 'asterisk', + after: { module: module ?? 'all' }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + return { ok: true, module: module ?? 'all' }; + } +} diff --git a/apps/api/src/asterisk-admin/diagnostic-allowlist.ts b/apps/api/src/asterisk-admin/diagnostic-allowlist.ts new file mode 100644 index 0000000..fa268a3 --- /dev/null +++ b/apps/api/src/asterisk-admin/diagnostic-allowlist.ts @@ -0,0 +1,24 @@ +// Allowlist estrita de comandos de diagnóstico (agente.md seção 22). +// NUNCA aceitar comando arbitrário aqui — isso seria uma shell remota. +// Comparação é por igualdade exata após trim, nunca por prefixo/regex +// permissivo, para não abrir brechas como "core show channels; rm -rf". +export const DIAGNOSTIC_COMMAND_ALLOWLIST = [ + 'core show uptime', + 'core show channels', + 'core show channels count', + 'core show version', + 'pjsip show endpoints', + 'pjsip show contacts', + 'pjsip show registrations', + 'pjsip show transports', + 'queue show', + 'module show', + 'dialplan show', +] as const; + +export function isDiagnosticCommandAllowed(command: string): boolean { + const normalized = command.trim(); + return DIAGNOSTIC_COMMAND_ALLOWLIST.includes( + normalized as (typeof DIAGNOSTIC_COMMAND_ALLOWLIST)[number], + ); +} diff --git a/apps/api/src/asterisk-admin/dto/diagnostic-command.dto.ts b/apps/api/src/asterisk-admin/dto/diagnostic-command.dto.ts new file mode 100644 index 0000000..a68ecc1 --- /dev/null +++ b/apps/api/src/asterisk-admin/dto/diagnostic-command.dto.ts @@ -0,0 +1,7 @@ +import { IsString, MinLength } from 'class-validator'; + +export class DiagnosticCommandDto { + @IsString() + @MinLength(1) + command!: string; +} diff --git a/apps/api/src/dialplan/dialplan-generator.ts b/apps/api/src/dialplan/dialplan-generator.ts new file mode 100644 index 0000000..9ce904e --- /dev/null +++ b/apps/api/src/dialplan/dialplan-generator.ts @@ -0,0 +1,44 @@ +import type { DialplanEntry } from '@b2bcall/database'; + +/** + * Gera o texto de configuração do dialplan a partir das entradas + * estruturadas. Determinístico (mesma entrada -> mesmo texto), o que + * facilita diff entre versões em dialplan_versions. + */ +export function generateDialplanConfig(entries: DialplanEntry[]): string { + const enabled = entries.filter((e) => e.enabled); + const byContext = new Map(); + for (const entry of enabled) { + const list = byContext.get(entry.context) ?? []; + list.push(entry); + byContext.set(entry.context, list); + } + + const contexts = [...byContext.keys()].sort(); + const lines: string[] = [ + '; Arquivo gerado automaticamente por DialplanService — NÃO EDITAR À MÃO.', + '; Qualquer alteração manual será sobrescrita na próxima publicação.', + '', + ]; + + for (const context of contexts) { + lines.push(`[${context}]`); + const contextEntries = byContext + .get(context)! + .sort( + (a, b) => + a.order - b.order || + a.exten.localeCompare(b.exten) || + a.priority - b.priority, + ); + for (const entry of contextEntries) { + const args = entry.argument ?? ''; + lines.push( + `exten => ${entry.exten},${entry.priority},${entry.application}(${args})`, + ); + } + lines.push(''); + } + + return lines.join('\n'); +} diff --git a/apps/api/src/dialplan/dialplan.controller.ts b/apps/api/src/dialplan/dialplan.controller.ts new file mode 100644 index 0000000..2ef0ba0 --- /dev/null +++ b/apps/api/src/dialplan/dialplan.controller.ts @@ -0,0 +1,103 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Query, + 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 { DialplanService } from './dialplan.service'; +import { CreateDialplanEntryDto } from './dto/create-dialplan-entry.dto'; +import { UpdateDialplanEntryDto } from './dto/update-dialplan-entry.dto'; + +@Controller('dialplans') +export class DialplanController { + constructor(private readonly dialplanService: DialplanService) {} + + @Get() + @RequirePermissions('dialplans.view') + list(@Query('context') context?: string) { + return this.dialplanService.listEntries(context); + } + + @Get('versions') + @RequirePermissions('dialplans.view') + versions() { + return this.dialplanService.listVersions(); + } + + @Post() + @RequirePermissions('dialplans.create') + create( + @Body() dto: CreateDialplanEntryDto, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.dialplanService.createEntry(dto, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Patch(':id') + @RequirePermissions('dialplans.update') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateDialplanEntryDto, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.dialplanService.updateEntry(id, dto, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Delete(':id') + @RequirePermissions('dialplans.delete') + remove( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.dialplanService.deleteEntry(id, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + // Publicar (aplicar as entradas ativas) exige permissão de update — é uma + // operação sensível de infraestrutura, não uma simples criação de linha. + @Post('publish') + @RequirePermissions('dialplans.update') + publish( + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.dialplanService.publish(actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } + + @Post('versions/:id/rollback') + @RequirePermissions('dialplans.update') + rollback( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() actor: AuthenticatedUser, + @Req() request: FastifyRequest, + ) { + return this.dialplanService.rollback(id, actor, { + ip: request.ip, + userAgent: request.headers['user-agent'], + }); + } +} diff --git a/apps/api/src/dialplan/dialplan.module.ts b/apps/api/src/dialplan/dialplan.module.ts new file mode 100644 index 0000000..b02c866 --- /dev/null +++ b/apps/api/src/dialplan/dialplan.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { DialplanController } from './dialplan.controller'; +import { DialplanService } from './dialplan.service'; + +@Module({ + controllers: [DialplanController], + providers: [DialplanService], +}) +export class DialplanModule {} diff --git a/apps/api/src/dialplan/dialplan.service.ts b/apps/api/src/dialplan/dialplan.service.ts new file mode 100644 index 0000000..a1f209f --- /dev/null +++ b/apps/api/src/dialplan/dialplan.service.ts @@ -0,0 +1,266 @@ +import { readFile, writeFile } from 'node:fs/promises'; +import { + BadRequestException, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DialplanVersionStatus } from '@b2bcall/database'; +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 { CreateDialplanEntryDto } from './dto/create-dialplan-entry.dto'; +import { UpdateDialplanEntryDto } from './dto/update-dialplan-entry.dto'; +import { generateDialplanConfig } from './dialplan-generator'; + +const DIALPLAN_FILE_PATH = '/etc/asterisk-generated/b2bcall-dialplan.conf'; + +// Padrões de erro que o Asterisk imprime quando um contexto/config não +// existe ou falhou ao carregar — usados para detectar falha de aplicação +// já que a API não tem acesso a um "dry-run" real de parsing do Asterisk. +const ERROR_MARKERS = [ + /There is no existence of/i, + /failed to load/i, + /Parse error/i, +]; + +@Injectable() +export class DialplanService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + @Inject(TELEPHONY_PROVIDER) private readonly telephony: TelephonyProvider, + ) {} + + listEntries(context?: string) { + return this.prisma.dialplanEntry.findMany({ + where: context ? { context } : undefined, + orderBy: [ + { context: 'asc' }, + { order: 'asc' }, + { exten: 'asc' }, + { priority: 'asc' }, + ], + }); + } + + async createEntry( + dto: CreateDialplanEntryDto, + actor: { id: string }, + ctx: RequestContext, + ) { + const entry = await this.prisma.dialplanEntry.create({ + data: { + context: dto.context, + exten: dto.exten, + priority: dto.priority, + application: dto.application, + argument: dto.argument, + enabled: dto.enabled ?? true, + order: dto.order ?? 0, + }, + }); + await this.audit.log({ + userId: actor.id, + action: 'dialplan_entry_created', + entityType: 'dialplan_entry', + entityId: entry.id, + after: { ...dto }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + return entry; + } + + async updateEntry( + id: string, + dto: UpdateDialplanEntryDto, + actor: { id: string }, + ctx: RequestContext, + ) { + const before = await this.prisma.dialplanEntry.findUnique({ + where: { id }, + }); + if (!before) + throw new NotFoundException('Entrada de dialplan não encontrada.'); + + const entry = await this.prisma.dialplanEntry.update({ + where: { id }, + data: dto, + }); + + await this.audit.log({ + userId: actor.id, + action: 'dialplan_entry_updated', + entityType: 'dialplan_entry', + entityId: id, + before, + after: { ...dto }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + return entry; + } + + async deleteEntry(id: string, actor: { id: string }, ctx: RequestContext) { + const entry = await this.prisma.dialplanEntry.findUnique({ where: { id } }); + if (!entry) + throw new NotFoundException('Entrada de dialplan não encontrada.'); + + await this.prisma.dialplanEntry.delete({ where: { id } }); + await this.audit.log({ + userId: actor.id, + action: 'dialplan_entry_deleted', + entityType: 'dialplan_entry', + entityId: id, + before: entry, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + } + + listVersions() { + return this.prisma.dialplanVersion.findMany({ + orderBy: { createdAt: 'desc' }, + take: 50, + }); + } + + private async writeAndReload(config: string): Promise { + await writeFile(DIALPLAN_FILE_PATH, config, 'utf8'); + return this.telephony.runCommand('dialplan reload'); + } + + private async verify(entries: { context: string }[]): Promise { + const contexts = [...new Set(entries.map((e) => e.context))]; + for (const context of contexts) { + const output = await this.telephony.runCommand( + `dialplan show ${context}`, + ); + const errorMarker = ERROR_MARKERS.find((re) => re.test(output)); + if (errorMarker) return `Contexto '${context}': ${output.slice(0, 300)}`; + } + return null; + } + + /** + * Gera a configuração a partir das entradas ativas, salva backup da + * versão anterior, escreve o novo arquivo, recarrega e valida. Se algo + * falhar, restaura automaticamente a versão anterior e recarrega de novo + * (agente.md seção 21: "caso inválido, não ativar" + "permitir rollback"). + */ + async publish(actor: { id: string }, ctx: RequestContext) { + const entries = await this.prisma.dialplanEntry.findMany({ + where: { enabled: true }, + }); + const newConfig = generateDialplanConfig(entries); + + const previous = await this.prisma.dialplanVersion.findFirst({ + where: { status: DialplanVersionStatus.APPLIED }, + orderBy: { createdAt: 'desc' }, + }); + const previousConfig = + previous?.generatedConfig ?? (await this.readCurrentFileSafely()); + + let reloadResult: string; + try { + reloadResult = await this.writeAndReload(newConfig); + } catch (err) { + throw new BadRequestException( + `Falha ao recarregar o dialplan: ${(err as Error).message}`, + ); + } + + const failureReason = await this.verify(entries); + + if (failureReason) { + // Rollback automático — nunca deixa uma configuração inválida ativa. + await this.writeAndReload(previousConfig); + const failedVersion = await this.prisma.dialplanVersion.create({ + data: { + generatedConfig: newConfig, + status: DialplanVersionStatus.FAILED, + reloadResult: failureReason, + createdById: actor.id, + }, + }); + await this.audit.log({ + userId: actor.id, + action: 'dialplan_publish_failed_rolled_back', + entityType: 'dialplan_version', + entityId: failedVersion.id, + after: { failureReason }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + throw new BadRequestException( + `Publicação falhou na validação e foi revertida automaticamente: ${failureReason}`, + ); + } + + const version = await this.prisma.dialplanVersion.create({ + data: { + generatedConfig: newConfig, + status: DialplanVersionStatus.APPLIED, + reloadResult, + createdById: actor.id, + }, + }); + + await this.audit.log({ + userId: actor.id, + action: 'dialplan_published', + entityType: 'dialplan_version', + entityId: version.id, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + return version; + } + + async rollback( + versionId: string, + actor: { id: string }, + ctx: RequestContext, + ) { + const target = await this.prisma.dialplanVersion.findUnique({ + where: { id: versionId }, + }); + if (!target) + throw new NotFoundException('Versão de dialplan não encontrada.'); + + const reloadResult = await this.writeAndReload(target.generatedConfig); + + const version = await this.prisma.dialplanVersion.create({ + data: { + generatedConfig: target.generatedConfig, + status: DialplanVersionStatus.ROLLED_BACK, + reloadResult, + createdById: actor.id, + }, + }); + + await this.audit.log({ + userId: actor.id, + action: 'dialplan_rolled_back', + entityType: 'dialplan_version', + entityId: version.id, + before: { rolledBackFrom: versionId }, + ipAddress: ctx.ip, + userAgent: ctx.userAgent, + }); + + return version; + } + + private async readCurrentFileSafely(): Promise { + try { + return await readFile(DIALPLAN_FILE_PATH, 'utf8'); + } catch { + return '; vazio\n'; + } + } +} diff --git a/apps/api/src/dialplan/dto/create-dialplan-entry.dto.ts b/apps/api/src/dialplan/dto/create-dialplan-entry.dto.ts new file mode 100644 index 0000000..360c886 --- /dev/null +++ b/apps/api/src/dialplan/dto/create-dialplan-entry.dto.ts @@ -0,0 +1,54 @@ +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, +} from 'class-validator'; + +// Cada campo tem allowlist estrita de caracteres — este texto vira uma +// linha de configuração do Asterisk gerada diretamente (agente.md seção +// 59: nunca aceitar entrada capaz de injetar configuração arbitrária). +export class CreateDialplanEntryDto { + @IsString() + @Matches(/^[a-zA-Z0-9_-]{1,80}$/, { + message: 'Contexto: apenas letras, números, hífen e underscore.', + }) + context!: string; + + @IsString() + @Matches(/^[a-zA-Z0-9_.[\]!*#-]{1,80}$/, { + message: 'Extensão inválida (padrões Asterisk: _X., _9NXXXXXXX, etc.)', + }) + exten!: string; + + @IsInt() + @Min(1) + @Max(999) + priority!: number; + + @IsString() + @Matches(/^[A-Za-z][A-Za-z0-9]{0,63}$/, { + message: 'Nome de aplicação Asterisk inválido.', + }) + application!: string; + + @IsOptional() + @IsString() + @MaxLength(500) + @Matches(/^[^\r\n]*$/, { + message: 'Argumento não pode conter quebras de linha.', + }) + argument?: string; + + @IsOptional() + @IsBoolean() + enabled?: boolean; + + @IsOptional() + @IsInt() + order?: number; +} diff --git a/apps/api/src/dialplan/dto/update-dialplan-entry.dto.ts b/apps/api/src/dialplan/dto/update-dialplan-entry.dto.ts new file mode 100644 index 0000000..2b5e463 --- /dev/null +++ b/apps/api/src/dialplan/dto/update-dialplan-entry.dto.ts @@ -0,0 +1,6 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { CreateDialplanEntryDto } from './create-dialplan-entry.dto'; + +export class UpdateDialplanEntryDto extends PartialType( + CreateDialplanEntryDto, +) {} diff --git a/docker-compose.yml b/docker-compose.yml index f50f3da..a75f3e2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,9 @@ volumes: asterisk-lib: asterisk-log: asterisk-spool: + # Compartilhado entre api e asterisk: a API gera o dialplan estruturado + # aqui (agente.md seção 21) e o Asterisk inclui via extensions.conf. + dialplan-generated: services: postgres: @@ -106,6 +109,7 @@ services: - asterisk-lib:/var/lib/asterisk - asterisk-log:/var/log/asterisk - asterisk-spool:/var/spool/asterisk + - dialplan-generated:/etc/asterisk-generated healthcheck: test: ["CMD-SHELL", "asterisk -rx 'core show uptime' | grep -q 'System uptime'"] interval: 15s @@ -145,6 +149,8 @@ services: environment: POSTGRES_HOST: postgres REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + volumes: + - dialplan-generated:/etc/asterisk-generated # Sem "ports": só o Nginx (Fase 9) expõe HTTP ao mundo externo. Em dev, # acessar via `docker compose exec` ou publicar temporariamente. healthcheck: diff --git a/infrastructure/asterisk/config/extensions.conf b/infrastructure/asterisk/config/extensions.conf index fcc8a4b..5ce5c5e 100644 --- a/infrastructure/asterisk/config/extensions.conf +++ b/infrastructure/asterisk/config/extensions.conf @@ -15,7 +15,8 @@ exten => 600,1,Answer() same => n,Echo() same => n,Hangup() -; Placeholder para o contexto de discagem outbound (Fase 6 — Campanhas). -[outbound] -exten => _X.,1,NoOp(B2BCall outbound placeholder - ${EXTEN}) - same => n,Hangup() +; Dialplan gerenciado pela aplicação (Telefonia → Dialplan), gerado por +; DialplanService e compartilhado via volume Docker "dialplan-generated" +; (ver docs/ARCHITECTURE.md). Nunca editar este arquivo manualmente — toda +; alteração é versionada em dialplan_versions com backup/validação/rollback. +#include "/etc/asterisk-generated/b2bcall-dialplan.conf" diff --git a/infrastructure/docker/asterisk-entrypoint.sh b/infrastructure/docker/asterisk-entrypoint.sh index 74ec7af..94fb04c 100644 --- a/infrastructure/docker/asterisk-entrypoint.sh +++ b/infrastructure/docker/asterisk-entrypoint.sh @@ -28,6 +28,14 @@ for f in "$CONFIG_SRC"/*.conf; do cp "$f" "$CONFIG_DST/$name" done -chown -R asterisk:asterisk "$CONFIG_DST" /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk +# extensions.conf inclui este arquivo (dialplan gerado pela aplicação) — +# precisa existir mesmo vazio no primeiro boot, antes de a API publicar +# a primeira versão (ver DialplanService). +mkdir -p /etc/asterisk-generated +if [ ! -e /etc/asterisk-generated/b2bcall-dialplan.conf ]; then + echo "; vazio até a primeira publicação em Telefonia -> Dialplan" > /etc/asterisk-generated/b2bcall-dialplan.conf +fi + +chown -R asterisk:asterisk "$CONFIG_DST" /var/lib/asterisk /var/log/asterisk /var/spool/asterisk /var/run/asterisk /etc/asterisk-generated exec "$@" diff --git a/packages/database/prisma/migrations/20260827155941_add_dialplan/migration.sql b/packages/database/prisma/migrations/20260827155941_add_dialplan/migration.sql new file mode 100644 index 0000000..4245a5b --- /dev/null +++ b/packages/database/prisma/migrations/20260827155941_add_dialplan/migration.sql @@ -0,0 +1,33 @@ +-- CreateEnum +CREATE TYPE "DialplanVersionStatus" AS ENUM ('APPLIED', 'FAILED', 'ROLLED_BACK'); + +-- CreateTable +CREATE TABLE "dialplan_entries" ( + "id" TEXT NOT NULL, + "context" TEXT NOT NULL, + "exten" TEXT NOT NULL, + "priority" INTEGER NOT NULL, + "application" TEXT NOT NULL, + "argument" TEXT, + "enabled" BOOLEAN NOT NULL DEFAULT true, + "order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "dialplan_entries_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dialplan_versions" ( + "id" TEXT NOT NULL, + "generated_config" TEXT NOT NULL, + "status" "DialplanVersionStatus" NOT NULL, + "reload_result" TEXT, + "created_by_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "dialplan_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "dialplan_entries_context_idx" ON "dialplan_entries"("context"); diff --git a/packages/database/prisma/schema.prisma b/packages/database/prisma/schema.prisma index f2cb8e0..c4450f5 100644 --- a/packages/database/prisma/schema.prisma +++ b/packages/database/prisma/schema.prisma @@ -212,3 +212,42 @@ model ExtensionState { @@map("extension_states") } + +// =========================================================================== +// Dialplan estruturado e versionado (agente.md seção 21). O modo "Advanced" +// citado na spec é apenas uma restrição de permissão no frontend sobre os +// mesmos dados — não um formato de armazenamento diferente. +// =========================================================================== + +model DialplanEntry { + id String @id @default(uuid()) + context String + exten String + priority Int + application String + argument String? + enabled Boolean @default(true) + order Int @default(0) + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + @@index([context]) + @@map("dialplan_entries") +} + +enum DialplanVersionStatus { + APPLIED + FAILED + ROLLED_BACK +} + +model DialplanVersion { + id String @id @default(uuid()) + generatedConfig String @map("generated_config") + status DialplanVersionStatus + reloadResult String? @map("reload_result") + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") + + @@map("dialplan_versions") +}