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