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
|
||||
- [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
|
||||
|
||||
@@ -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: [
|
||||
|
||||
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-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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "$@"
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// 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