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

View File

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

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,
) {}