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:
2026-08-27 21:32:15 -03:00
parent a10a40a489
commit 6fc7a842bd
7 changed files with 166 additions and 18 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
import * as React from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Plus, KeyRound, Trash2, Pencil, Copy } from 'lucide-react';
import { Plus, KeyRound, Trash2, Pencil, Copy, Eye, EyeOff } from 'lucide-react';
import { PageHeader } from '@/components/layout/page-header';
import { RequirePermission } from '@/components/require-permission';
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
@@ -44,10 +44,23 @@ function ExtensionFormDialog({
enabled: true,
});
const [createdPassword, setCreatedPassword] = React.useState<string | null>(null);
const [showPassword, setShowPassword] = React.useState(false);
// Compara com o valor buscado do servidor para só mandar sipPassword no
// update quando o operador realmente mudou o campo (evita re-provisionar
// o PJSIP e gerar log de "senha alterada" à toa a cada salvar).
const originalPasswordRef = React.useRef<string | undefined>(undefined);
const passwordQuery = useQuery({
queryKey: ['extension-password', extension?.id],
queryFn: () => extensionsService.getPassword(extension!.id),
enabled: open && isEdit && Boolean(extension),
});
React.useEffect(() => {
if (open) {
setCreatedPassword(null);
setShowPassword(false);
originalPasswordRef.current = undefined;
setForm(
extension
? {
@@ -60,13 +73,27 @@ function ExtensionFormDialog({
}
}, [open, extension]);
React.useEffect(() => {
if (passwordQuery.data) {
originalPasswordRef.current = passwordQuery.data.sipPassword;
setForm((f) => ({ ...f, sipPassword: passwordQuery.data.sipPassword }));
}
}, [passwordQuery.data]);
const mutation = useMutation({
mutationFn: () =>
isEdit && extension
? extensionsService.update(extension.id, form)
: extensionsService.create(form),
mutationFn: () => {
if (isEdit && extension) {
const changedPassword =
form.sipPassword && form.sipPassword !== originalPasswordRef.current
? form.sipPassword
: undefined;
return extensionsService.update(extension.id, { ...form, sipPassword: changedPassword });
}
return extensionsService.create(form);
},
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['extensions'] });
queryClient.invalidateQueries({ queryKey: ['extension-password', extension?.id] });
if (!isEdit && 'sipPassword' in result && result.sipPassword) {
setCreatedPassword(result.sipPassword);
toast({ title: 'Ramal criado', variant: 'success' });
@@ -140,6 +167,41 @@ function ExtensionFormDialog({
onChange={(e) => setForm((f) => ({ ...f, callerId: e.target.value }))}
/>
</div>
{isEdit && (
<div className="flex flex-col gap-1.5">
<Label htmlFor="sipPassword">Senha SIP</Label>
<div className="flex items-center gap-2">
<Input
id="sipPassword"
type={showPassword ? 'text' : 'password'}
className="font-mono"
value={form.sipPassword ?? ''}
placeholder={passwordQuery.isLoading ? 'Carregando...' : undefined}
onChange={(e) => setForm((f) => ({ ...f, sipPassword: e.target.value }))}
/>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => setShowPassword((v) => !v)}
>
{showPassword ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</Button>
<Button
type="button"
size="icon"
variant="ghost"
onClick={() => form.sipPassword && navigator.clipboard.writeText(form.sipPassword)}
>
<Copy className="size-4" />
</Button>
</div>
<p className="text-xs text-muted-foreground">
Use isso para configurar o softphone. Editar aqui troca a senha de
verdade (reaplica no Asterisk ao salvar).
</p>
</div>
)}
<DialogFooter>
<Button type="submit" loading={mutation.isPending}>
Salvar

View File

@@ -11,11 +11,13 @@ export interface ExtensionInput {
maxContacts?: number;
qualifyFrequency?: number;
enabled?: boolean;
sipPassword?: string;
}
export const extensionsService = {
list: () => api.get<Extension[]>('/extensions'),
get: (id: string) => api.get<Extension>(`/extensions/${id}`),
getPassword: (id: string) => api.get<{ sipPassword: string }>(`/extensions/${id}/password`),
create: (input: ExtensionInput) => api.post<Extension>('/extensions', input),
update: (id: string, input: Partial<ExtensionInput>) =>
api.patch<Extension>(`/extensions/${id}`, input),