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:
2026-08-27 13:14:50 -03:00
parent 8a41b5d2b2
commit cf3fc4b3ef
18 changed files with 789 additions and 10 deletions

27
TODO.md
View File

@@ -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

View File

@@ -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: [

View 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'],
});
}
}

View 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 {}

View 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' };
}
}

View 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],
);
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class DiagnosticCommandDto {
@IsString()
@MinLength(1)
command!: string;
}

View 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');
}

View 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'],
});
}
}

View 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 {}

View 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';
}
}
}

View 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;
}

View File

@@ -0,0 +1,6 @@
import { PartialType } from '@nestjs/mapped-types';
import { CreateDialplanEntryDto } from './create-dialplan-entry.dto';
export class UpdateDialplanEntryDto extends PartialType(
CreateDialplanEntryDto,
) {}

View File

@@ -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:

View File

@@ -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"

View File

@@ -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 "$@"

View File

@@ -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");

View File

@@ -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")
}