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 { PartialType, OmitType } from '@nestjs/mapped-types';
|
||||||
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
import { CreateExtensionDto } from './create-extension.dto';
|
import { CreateExtensionDto } from './create-extension.dto';
|
||||||
|
|
||||||
// "number" não é editável — trocar o número de ramal é, na prática, criar
|
// "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).
|
// outro ramal (o identificador do objeto PJSIP é o próprio número).
|
||||||
export class UpdateExtensionDto extends PartialType(
|
export class UpdateExtensionDto extends PartialType(
|
||||||
OmitType(CreateExtensionDto, ['number'] as const),
|
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')
|
@Post(':id/reset-password')
|
||||||
@RequirePermissions('extensions.update')
|
@RequirePermissions('extensions.update')
|
||||||
resetPassword(
|
resetPassword(
|
||||||
|
|||||||
@@ -70,6 +70,33 @@ export class ExtensionsService {
|
|||||||
return { ...toSafeExtension(extension), state };
|
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) {
|
private async provisionPjsip(extension: Extension, plainPassword: string) {
|
||||||
const id = extension.number;
|
const id = extension.number;
|
||||||
await this.pjsip.upsertAuth({ id, username: id, password: plainPassword });
|
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 } });
|
const before = await this.prisma.extension.findUnique({ where: { id } });
|
||||||
if (!before) throw new NotFoundException('Ramal não encontrado.');
|
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({
|
const extension = await this.prisma.extension.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
@@ -156,19 +187,23 @@ export class ExtensionsService {
|
|||||||
maxContacts: dto.maxContacts,
|
maxContacts: dto.maxContacts,
|
||||||
qualifyFrequency: dto.qualifyFrequency,
|
qualifyFrequency: dto.qualifyFrequency,
|
||||||
enabled: dto.enabled,
|
enabled: dto.enabled,
|
||||||
|
...(sipPasswordEncrypted ? { sipPasswordEncrypted } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Não há troca de senha aqui — reaplica só endpoint/aor com a senha atual.
|
// Reaplica o objeto PJSIP com a senha (nova, se veio no dto; a mesma de
|
||||||
const currentPassword = decryptSecret(
|
// antes, caso contrário) — endpoint/aor podem ter mudado mesmo sem
|
||||||
extension.sipPasswordEncrypted,
|
// trocar senha (contexto, codecs, etc.).
|
||||||
this.masterKey,
|
const currentPassword =
|
||||||
);
|
dto.sipPassword ??
|
||||||
|
decryptSecret(extension.sipPasswordEncrypted, this.masterKey);
|
||||||
await this.provisionPjsip(extension, currentPassword);
|
await this.provisionPjsip(extension, currentPassword);
|
||||||
|
|
||||||
await this.audit.log({
|
await this.audit.log({
|
||||||
userId: actor.id,
|
userId: actor.id,
|
||||||
action: 'extension_updated',
|
action: dto.sipPassword
|
||||||
|
? 'extension_updated_password_changed'
|
||||||
|
: 'extension_updated',
|
||||||
entityType: 'extension',
|
entityType: 'extension',
|
||||||
entityId: id,
|
entityId: id,
|
||||||
before: { name: before.name, enabled: before.enabled },
|
before: { name: before.name, enabled: before.enabled },
|
||||||
|
|||||||
@@ -46,8 +46,22 @@ describe('generateOutboundRoutesConfig', () => {
|
|||||||
|
|
||||||
it('ordena rotas pelo campo order', () => {
|
it('ordena rotas pelo campo order', () => {
|
||||||
const config = generateOutboundRoutesConfig([
|
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'));
|
expect(config.indexOf('Primeira')).toBeLessThan(config.indexOf('Segunda'));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
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 { PageHeader } from '@/components/layout/page-header';
|
||||||
import { RequirePermission } from '@/components/require-permission';
|
import { RequirePermission } from '@/components/require-permission';
|
||||||
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
import { DataTable, type DataTableColumn } from '@/components/data-table/data-table';
|
||||||
@@ -44,10 +44,23 @@ function ExtensionFormDialog({
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
});
|
});
|
||||||
const [createdPassword, setCreatedPassword] = React.useState<string | null>(null);
|
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(() => {
|
React.useEffect(() => {
|
||||||
if (open) {
|
if (open) {
|
||||||
setCreatedPassword(null);
|
setCreatedPassword(null);
|
||||||
|
setShowPassword(false);
|
||||||
|
originalPasswordRef.current = undefined;
|
||||||
setForm(
|
setForm(
|
||||||
extension
|
extension
|
||||||
? {
|
? {
|
||||||
@@ -60,13 +73,27 @@ function ExtensionFormDialog({
|
|||||||
}
|
}
|
||||||
}, [open, extension]);
|
}, [open, extension]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (passwordQuery.data) {
|
||||||
|
originalPasswordRef.current = passwordQuery.data.sipPassword;
|
||||||
|
setForm((f) => ({ ...f, sipPassword: passwordQuery.data.sipPassword }));
|
||||||
|
}
|
||||||
|
}, [passwordQuery.data]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () => {
|
||||||
isEdit && extension
|
if (isEdit && extension) {
|
||||||
? extensionsService.update(extension.id, form)
|
const changedPassword =
|
||||||
: extensionsService.create(form),
|
form.sipPassword && form.sipPassword !== originalPasswordRef.current
|
||||||
|
? form.sipPassword
|
||||||
|
: undefined;
|
||||||
|
return extensionsService.update(extension.id, { ...form, sipPassword: changedPassword });
|
||||||
|
}
|
||||||
|
return extensionsService.create(form);
|
||||||
|
},
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['extensions'] });
|
queryClient.invalidateQueries({ queryKey: ['extensions'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['extension-password', extension?.id] });
|
||||||
if (!isEdit && 'sipPassword' in result && result.sipPassword) {
|
if (!isEdit && 'sipPassword' in result && result.sipPassword) {
|
||||||
setCreatedPassword(result.sipPassword);
|
setCreatedPassword(result.sipPassword);
|
||||||
toast({ title: 'Ramal criado', variant: 'success' });
|
toast({ title: 'Ramal criado', variant: 'success' });
|
||||||
@@ -140,6 +167,41 @@ function ExtensionFormDialog({
|
|||||||
onChange={(e) => setForm((f) => ({ ...f, callerId: e.target.value }))}
|
onChange={(e) => setForm((f) => ({ ...f, callerId: e.target.value }))}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<DialogFooter>
|
||||||
<Button type="submit" loading={mutation.isPending}>
|
<Button type="submit" loading={mutation.isPending}>
|
||||||
Salvar
|
Salvar
|
||||||
|
|||||||
@@ -11,11 +11,13 @@ export interface ExtensionInput {
|
|||||||
maxContacts?: number;
|
maxContacts?: number;
|
||||||
qualifyFrequency?: number;
|
qualifyFrequency?: number;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
sipPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const extensionsService = {
|
export const extensionsService = {
|
||||||
list: () => api.get<Extension[]>('/extensions'),
|
list: () => api.get<Extension[]>('/extensions'),
|
||||||
get: (id: string) => api.get<Extension>(`/extensions/${id}`),
|
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),
|
create: (input: ExtensionInput) => api.post<Extension>('/extensions', input),
|
||||||
update: (id: string, input: Partial<ExtensionInput>) =>
|
update: (id: string, input: Partial<ExtensionInput>) =>
|
||||||
api.patch<Extension>(`/extensions/${id}`, input),
|
api.patch<Extension>(`/extensions/${id}`, input),
|
||||||
|
|||||||
@@ -34,11 +34,23 @@ table inet b2bcall_fw {
|
|||||||
# SSH nunca é bloqueado
|
# SSH nunca é bloqueado
|
||||||
tcp dport 22 accept
|
tcp dport 22 accept
|
||||||
|
|
||||||
|
# SIP/RTP liberados para toda a faixa RFC1918 (rede privada) na
|
||||||
|
# interface LAN — necessário para softphones/ramais reais de agentes
|
||||||
|
# registrarem no Asterisk, já que o OpenSIPS (que ficaria na frente,
|
||||||
|
# ver docs/OPENSIPS.md) não foi implantado nesta fase. Avaliado
|
||||||
|
# ANTES das regras de drop abaixo: nftables usa a primeira regra
|
||||||
|
# terminante que casar. Origem fora do RFC1918 (ex.: internet
|
||||||
|
# pública) continua caindo nas regras de drop — o Asterisk nunca
|
||||||
|
# deve receber SIP de fora da rede privada (agente.md seção 5).
|
||||||
|
ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } iifname "ens18" udp dport 5060 accept
|
||||||
|
ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } iifname "ens18" tcp dport { 5060, 5061 } accept
|
||||||
|
ip saddr { 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 } iifname "ens18" udp dport 10000-20000 accept
|
||||||
|
|
||||||
# --- Serviços administrativos/telefônicos sensíveis do Asterisk ---
|
# --- Serviços administrativos/telefônicos sensíveis do Asterisk ---
|
||||||
# Nunca devem ser alcançáveis pela LAN (interface ens18). Habilitar
|
# AMI/ARI nunca devem ser alcançáveis pela LAN (interface ens18),
|
||||||
# exceções pontuais aqui somente quando o IP do OpenSIPS/tronco for
|
# mesmo de origem RFC1918 — só a aplicação (containers, via rede
|
||||||
# conhecido e explicitamente autorizado (ver docs/OPENSIPS.md, a
|
# Docker interna) precisa falar com eles. SIP/RTP de origem fora do
|
||||||
# ser criado na Fase 2 de telefonia).
|
# RFC1918 também caem aqui (bloqueados, ver acima).
|
||||||
iifname "ens18" tcp dport { 5038, 8088, 8089 } counter drop
|
iifname "ens18" tcp dport { 5038, 8088, 8089 } counter drop
|
||||||
iifname "ens18" tcp dport { 5060, 5061 } counter drop
|
iifname "ens18" tcp dport { 5060, 5061 } counter drop
|
||||||
iifname "ens18" udp dport 5060 counter drop
|
iifname "ens18" udp dport 5060 counter drop
|
||||||
|
|||||||
Reference in New Issue
Block a user