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

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