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