feat: add structured versioned dialplan and asterisk admin
- 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 <contexto>' 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.
This commit is contained in:
27
TODO.md
27
TODO.md
@@ -113,11 +113,28 @@ mestre original (`agente.md`, seções 90-93).
|
|||||||
na criação/reset, reset endpoint) — testado igual aos troncos
|
na criação/reset, reset endpoint) — testado igual aos troncos
|
||||||
- [x] GET /api/monitoring/extensions — status com prioridade de cor
|
- [x] GET /api/monitoring/extensions — status com prioridade de cor
|
||||||
(offline/available/busy; pausa e agente logado chegam na Fase 5)
|
(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) — backend (gateway WS) ainda
|
||||||
(gateway WS) pendente; ExtensionState/pub-sub já prontos como base
|
pendente; ExtensionState/pub-sub e o endpoint de monitoramento já
|
||||||
- [ ] Dialplan estruturado (versionado, modo Advanced, validação+rollback)
|
prontos como base (frontend consumirá via WS na Fase 8)
|
||||||
- [ ] Administração do Asterisk (abas: geral, pjsip, rtp, filas, cdr, cel, logs,
|
- [x] Dialplan estruturado e versionado (dialplan_entries + dialplan_versions)
|
||||||
ami, ari, modules, diagnóstico com allowlist de comandos)
|
— 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
|
## Fase 5 — Call Center
|
||||||
- [ ] Agentes (agents, agent_sessions) separados de users
|
- [ ] Agentes (agents, agent_sessions) separados de users
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { TelephonyModule } from './telephony/telephony.module';
|
|||||||
import { TrunksModule } from './trunks/trunks.module';
|
import { TrunksModule } from './trunks/trunks.module';
|
||||||
import { ExtensionsModule } from './extensions/extensions.module';
|
import { ExtensionsModule } from './extensions/extensions.module';
|
||||||
import { MonitoringModule } from './monitoring/monitoring.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 { 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';
|
||||||
@@ -60,6 +62,8 @@ import { GlobalExceptionFilter } from './common/filters/global-exception.filter'
|
|||||||
TrunksModule,
|
TrunksModule,
|
||||||
ExtensionsModule,
|
ExtensionsModule,
|
||||||
MonitoringModule,
|
MonitoringModule,
|
||||||
|
DialplanModule,
|
||||||
|
AsteriskAdminModule,
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
56
apps/api/src/asterisk-admin/asterisk-admin.controller.ts
Normal file
56
apps/api/src/asterisk-admin/asterisk-admin.controller.ts
Normal file
@@ -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'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/asterisk-admin/asterisk-admin.module.ts
Normal file
9
apps/api/src/asterisk-admin/asterisk-admin.module.ts
Normal file
@@ -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 {}
|
||||||
93
apps/api/src/asterisk-admin/asterisk-admin.service.ts
Normal file
93
apps/api/src/asterisk-admin/asterisk-admin.service.ts
Normal file
@@ -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' };
|
||||||
|
}
|
||||||
|
}
|
||||||
24
apps/api/src/asterisk-admin/diagnostic-allowlist.ts
Normal file
24
apps/api/src/asterisk-admin/diagnostic-allowlist.ts
Normal file
@@ -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],
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class DiagnosticCommandDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
command!: string;
|
||||||
|
}
|
||||||
44
apps/api/src/dialplan/dialplan-generator.ts
Normal file
44
apps/api/src/dialplan/dialplan-generator.ts
Normal file
@@ -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<string, DialplanEntry[]>();
|
||||||
|
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');
|
||||||
|
}
|
||||||
103
apps/api/src/dialplan/dialplan.controller.ts
Normal file
103
apps/api/src/dialplan/dialplan.controller.ts
Normal file
@@ -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'],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
9
apps/api/src/dialplan/dialplan.module.ts
Normal file
9
apps/api/src/dialplan/dialplan.module.ts
Normal file
@@ -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 {}
|
||||||
266
apps/api/src/dialplan/dialplan.service.ts
Normal file
266
apps/api/src/dialplan/dialplan.service.ts
Normal file
@@ -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<string> {
|
||||||
|
await writeFile(DIALPLAN_FILE_PATH, config, 'utf8');
|
||||||
|
return this.telephony.runCommand('dialplan reload');
|
||||||
|
}
|
||||||
|
|
||||||
|
private async verify(entries: { context: string }[]): Promise<string | null> {
|
||||||
|
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<string> {
|
||||||
|
try {
|
||||||
|
return await readFile(DIALPLAN_FILE_PATH, 'utf8');
|
||||||
|
} catch {
|
||||||
|
return '; vazio\n';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
54
apps/api/src/dialplan/dto/create-dialplan-entry.dto.ts
Normal file
54
apps/api/src/dialplan/dto/create-dialplan-entry.dto.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
6
apps/api/src/dialplan/dto/update-dialplan-entry.dto.ts
Normal file
6
apps/api/src/dialplan/dto/update-dialplan-entry.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
|
import { CreateDialplanEntryDto } from './create-dialplan-entry.dto';
|
||||||
|
|
||||||
|
export class UpdateDialplanEntryDto extends PartialType(
|
||||||
|
CreateDialplanEntryDto,
|
||||||
|
) {}
|
||||||
@@ -10,6 +10,9 @@ volumes:
|
|||||||
asterisk-lib:
|
asterisk-lib:
|
||||||
asterisk-log:
|
asterisk-log:
|
||||||
asterisk-spool:
|
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:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -106,6 +109,7 @@ services:
|
|||||||
- asterisk-lib:/var/lib/asterisk
|
- asterisk-lib:/var/lib/asterisk
|
||||||
- asterisk-log:/var/log/asterisk
|
- asterisk-log:/var/log/asterisk
|
||||||
- asterisk-spool:/var/spool/asterisk
|
- asterisk-spool:/var/spool/asterisk
|
||||||
|
- dialplan-generated:/etc/asterisk-generated
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "asterisk -rx 'core show uptime' | grep -q 'System uptime'"]
|
test: ["CMD-SHELL", "asterisk -rx 'core show uptime' | grep -q 'System uptime'"]
|
||||||
interval: 15s
|
interval: 15s
|
||||||
@@ -145,6 +149,8 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
POSTGRES_HOST: postgres
|
POSTGRES_HOST: postgres
|
||||||
REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379
|
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,
|
# Sem "ports": só o Nginx (Fase 9) expõe HTTP ao mundo externo. Em dev,
|
||||||
# acessar via `docker compose exec` ou publicar temporariamente.
|
# acessar via `docker compose exec` ou publicar temporariamente.
|
||||||
healthcheck:
|
healthcheck:
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ exten => 600,1,Answer()
|
|||||||
same => n,Echo()
|
same => n,Echo()
|
||||||
same => n,Hangup()
|
same => n,Hangup()
|
||||||
|
|
||||||
; Placeholder para o contexto de discagem outbound (Fase 6 — Campanhas).
|
; Dialplan gerenciado pela aplicação (Telefonia → Dialplan), gerado por
|
||||||
[outbound]
|
; DialplanService e compartilhado via volume Docker "dialplan-generated"
|
||||||
exten => _X.,1,NoOp(B2BCall outbound placeholder - ${EXTEN})
|
; (ver docs/ARCHITECTURE.md). Nunca editar este arquivo manualmente — toda
|
||||||
same => n,Hangup()
|
; alteração é versionada em dialplan_versions com backup/validação/rollback.
|
||||||
|
#include "/etc/asterisk-generated/b2bcall-dialplan.conf"
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ for f in "$CONFIG_SRC"/*.conf; do
|
|||||||
cp "$f" "$CONFIG_DST/$name"
|
cp "$f" "$CONFIG_DST/$name"
|
||||||
done
|
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 "$@"
|
exec "$@"
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -212,3 +212,42 @@ model ExtensionState {
|
|||||||
|
|
||||||
@@map("extension_states")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user