fix: liberar SIP/RTP da rede privada no firewall + ver/editar senha SIP do ramal
nftables bloqueava incondicionalmente SIP (5060/udp) e RTP (10000-20000) vindos da interface LAN — impedia qualquer softphone real de registrar, já que o OpenSIPS que ficaria na frente (docs/OPENSIPS.md) não foi implantado. Liberado para toda a faixa RFC1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), mantendo AMI/ARI bloqueados incondicionalmente. Aplicado ao vivo no servidor. Ramais: agora dá para ver e trocar a senha SIP diretamente no formulário de edição (GET /extensions/:id/password, decifra sob demanda — diferente da senha de login, a senha SIP é cifrada, não hasheada, porque o próprio Asterisk precisa dela em texto puro para autenticar; expor sob permissão extensions.update e auditado é razoável). Evita ter que redefinir toda vez que o operador esquece a senha configurada num softphone.
This commit is contained in:
@@ -1,8 +1,18 @@
|
||||
import { PartialType, OmitType } from '@nestjs/mapped-types';
|
||||
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||
import { CreateExtensionDto } from './create-extension.dto';
|
||||
|
||||
// "number" não é editável — trocar o número de ramal é, na prática, criar
|
||||
// outro ramal (o identificador do objeto PJSIP é o próprio número).
|
||||
export class UpdateExtensionDto extends PartialType(
|
||||
OmitType(CreateExtensionDto, ['number'] as const),
|
||||
) {}
|
||||
) {
|
||||
// Só na edição (não no create, que sempre gera automaticamente) — permite
|
||||
// o operador definir/trocar a senha SIP diretamente, sem precisar de um
|
||||
// reset (agente.md seção 55: cifrada em repouso, nunca em texto puro fora
|
||||
// deste fluxo).
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MinLength(12, { message: 'Senha SIP deve ter pelo menos 12 caracteres.' })
|
||||
sipPassword?: string;
|
||||
}
|
||||
|
||||
@@ -60,6 +60,19 @@ export class ExtensionsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get(':id/password')
|
||||
@RequirePermissions('extensions.update')
|
||||
getPassword(
|
||||
@Param('id', ParseUUIDPipe) id: string,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
@Req() request: FastifyRequest,
|
||||
) {
|
||||
return this.extensionsService.getPassword(id, actor, {
|
||||
ip: request.ip,
|
||||
userAgent: request.headers['user-agent'],
|
||||
});
|
||||
}
|
||||
|
||||
@Post(':id/reset-password')
|
||||
@RequirePermissions('extensions.update')
|
||||
resetPassword(
|
||||
|
||||
@@ -70,6 +70,33 @@ export class ExtensionsService {
|
||||
return { ...toSafeExtension(extension), state };
|
||||
}
|
||||
|
||||
// Diferente da senha de login (hash argon2id, irreversível por design), a
|
||||
// senha SIP é cifrada (AES-256-GCM) porque o próprio Asterisk precisa da
|
||||
// senha em texto puro para autenticar o REGISTER — o sistema já tem essa
|
||||
// capacidade de decifrar, então expor sob demanda (permissão
|
||||
// extensions.update, auditado) é razoável e evita forçar reset toda vez
|
||||
// que o operador esquece a senha de um softphone.
|
||||
async getPassword(id: string, actor: { id: string }, ctx: RequestContext) {
|
||||
const extension = await this.prisma.extension.findUnique({ where: { id } });
|
||||
if (!extension) throw new NotFoundException('Ramal não encontrado.');
|
||||
|
||||
const sipPassword = decryptSecret(
|
||||
extension.sipPasswordEncrypted,
|
||||
this.masterKey,
|
||||
);
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'extension_password_viewed',
|
||||
entityType: 'extension',
|
||||
entityId: id,
|
||||
ipAddress: ctx.ip,
|
||||
userAgent: ctx.userAgent,
|
||||
});
|
||||
|
||||
return { sipPassword };
|
||||
}
|
||||
|
||||
private async provisionPjsip(extension: Extension, plainPassword: string) {
|
||||
const id = extension.number;
|
||||
await this.pjsip.upsertAuth({ id, username: id, password: plainPassword });
|
||||
@@ -145,6 +172,10 @@ export class ExtensionsService {
|
||||
const before = await this.prisma.extension.findUnique({ where: { id } });
|
||||
if (!before) throw new NotFoundException('Ramal não encontrado.');
|
||||
|
||||
const sipPasswordEncrypted = dto.sipPassword
|
||||
? encryptSecret(dto.sipPassword, this.masterKey)
|
||||
: undefined;
|
||||
|
||||
const extension = await this.prisma.extension.update({
|
||||
where: { id },
|
||||
data: {
|
||||
@@ -156,19 +187,23 @@ export class ExtensionsService {
|
||||
maxContacts: dto.maxContacts,
|
||||
qualifyFrequency: dto.qualifyFrequency,
|
||||
enabled: dto.enabled,
|
||||
...(sipPasswordEncrypted ? { sipPasswordEncrypted } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// Não há troca de senha aqui — reaplica só endpoint/aor com a senha atual.
|
||||
const currentPassword = decryptSecret(
|
||||
extension.sipPasswordEncrypted,
|
||||
this.masterKey,
|
||||
);
|
||||
// Reaplica o objeto PJSIP com a senha (nova, se veio no dto; a mesma de
|
||||
// antes, caso contrário) — endpoint/aor podem ter mudado mesmo sem
|
||||
// trocar senha (contexto, codecs, etc.).
|
||||
const currentPassword =
|
||||
dto.sipPassword ??
|
||||
decryptSecret(extension.sipPasswordEncrypted, this.masterKey);
|
||||
await this.provisionPjsip(extension, currentPassword);
|
||||
|
||||
await this.audit.log({
|
||||
userId: actor.id,
|
||||
action: 'extension_updated',
|
||||
action: dto.sipPassword
|
||||
? 'extension_updated_password_changed'
|
||||
: 'extension_updated',
|
||||
entityType: 'extension',
|
||||
entityId: id,
|
||||
before: { name: before.name, enabled: before.enabled },
|
||||
|
||||
@@ -46,8 +46,22 @@ describe('generateOutboundRoutesConfig', () => {
|
||||
|
||||
it('ordena rotas pelo campo order', () => {
|
||||
const config = generateOutboundRoutesConfig([
|
||||
{ name: 'Segunda', prefix: '', matchPattern: 'X.', prepend: null, order: 5, trunk: { name: 't2' } },
|
||||
{ name: 'Primeira', prefix: '', matchPattern: 'X.', prepend: null, order: 1, trunk: { name: 't1' } },
|
||||
{
|
||||
name: 'Segunda',
|
||||
prefix: '',
|
||||
matchPattern: 'X.',
|
||||
prepend: null,
|
||||
order: 5,
|
||||
trunk: { name: 't2' },
|
||||
},
|
||||
{
|
||||
name: 'Primeira',
|
||||
prefix: '',
|
||||
matchPattern: 'X.',
|
||||
prepend: null,
|
||||
order: 1,
|
||||
trunk: { name: 't1' },
|
||||
},
|
||||
]);
|
||||
expect(config.indexOf('Primeira')).toBeLessThan(config.indexOf('Segunda'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user