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:
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user