- Status-Dot, monospace Priorität, icon-only Hover-Actions - Duplicate-Button (CopyOutlined) — disabled=false copy + priority+1 - rowClassName für deaktivierte NAT-Regeln (fw-rule-row--disabled) - Spaltenheader bereinigt (kein 'Edit'-Text mehr) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
411 lines
17 KiB
TypeScript
411 lines
17 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd'
|
|
import type { ColumnsType } from 'antd/es/table'
|
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { ArrowDownOutlined, ArrowUpOutlined, BranchesOutlined, CopyOutlined, DeleteOutlined, EditOutlined } from '@ant-design/icons'
|
|
|
|
const { Text } = Typography
|
|
|
|
import DataTable from '../../components/DataTable'
|
|
import EmptyState from '../../components/EmptyState'
|
|
|
|
import apiClient, { isEnvelope } from '../../api/client'
|
|
import { useAuthStore } from '../../stores/auth'
|
|
import type { FwZone, NATRule } from './types'
|
|
|
|
interface FormValues {
|
|
name?: string
|
|
priority: number
|
|
enabled: boolean
|
|
kind: NATRule['kind']
|
|
in_zone?: string
|
|
out_zone?: string
|
|
proto?: 'tcp' | 'udp' | 'any'
|
|
match_src_cidr?: string
|
|
match_dst_cidr?: string
|
|
match_dport_start?: number
|
|
match_dport_end?: number
|
|
target_addr?: string
|
|
target_port_start?: number
|
|
target_port_end?: number
|
|
comment?: string
|
|
}
|
|
|
|
async function listNAT(): Promise<NATRule[]> {
|
|
const r = await apiClient.get('/firewall/nat-rules')
|
|
if (!isEnvelope(r.data)) return []
|
|
return (r.data.data as { nat_rules?: NATRule[] }).nat_rules ?? []
|
|
}
|
|
async function listZones(): Promise<FwZone[]> {
|
|
const r = await apiClient.get('/firewall/zones')
|
|
if (!isEnvelope(r.data)) return []
|
|
return (r.data.data as { zones?: FwZone[] }).zones ?? []
|
|
}
|
|
|
|
const KIND_COLORS: Record<NATRule['kind'], string> = {
|
|
dnat: 'blue',
|
|
snat: 'purple',
|
|
masquerade: 'gold',
|
|
}
|
|
|
|
export default function NATRulesTab() {
|
|
const { t } = useTranslation()
|
|
const qc = useQueryClient()
|
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
|
const { data, isLoading } = useQuery({ queryKey: ['fw', 'nat'], queryFn: listNAT })
|
|
const { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZones })
|
|
|
|
// NAT zones don't accept "any" — the renderer needs a concrete
|
|
// iface group to attach DNAT/SNAT/masq chains to. Fallback to the
|
|
// seed list while loading.
|
|
const zoneOptions: string[] = zones && zones.length > 0
|
|
? zones.map((z) => z.name)
|
|
: ['wan', 'lan', 'dmz', 'mgmt', 'cluster']
|
|
|
|
const [editing, setEditing] = useState<NATRule | null>(null)
|
|
const [creating, setCreating] = useState(false)
|
|
const [form] = Form.useForm<FormValues>()
|
|
|
|
const create = useMutation({
|
|
mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/nat-rules', v) },
|
|
onSuccess: () => {
|
|
message.success(t('common.save')); setCreating(false); form.resetFields()
|
|
void qc.invalidateQueries({ queryKey: ['fw', 'nat'] })
|
|
},
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
const update = useMutation({
|
|
mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { await apiClient.put(`/firewall/nat-rules/${id}`, v) },
|
|
onSuccess: () => {
|
|
message.success(t('common.save')); setEditing(null); form.resetFields()
|
|
void qc.invalidateQueries({ queryKey: ['fw', 'nat'] })
|
|
},
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
const del = useMutation({
|
|
mutationFn: async (id: number) => { await apiClient.delete(`/firewall/nat-rules/${id}`) },
|
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
|
|
const duplicate = useMutation({
|
|
mutationFn: async (r: NATRule) => {
|
|
const { id: _id, created_at: _ca, updated_at: _ua, ...rest } = r as NATRule & { created_at?: unknown; updated_at?: unknown }
|
|
await apiClient.post('/firewall/nat-rules', {
|
|
...rest,
|
|
name: r.name ? `${r.name} (copy)` : undefined,
|
|
priority: r.priority + 1,
|
|
enabled: false,
|
|
})
|
|
},
|
|
onSuccess: () => {
|
|
message.success(t('fw.rule.duplicated'))
|
|
void qc.invalidateQueries({ queryKey: ['fw', 'nat'] })
|
|
},
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
const quickToggle = useMutation({
|
|
mutationFn: async ({ id, row, checked }: { id: number; row: NATRule; checked: boolean }) => {
|
|
await apiClient.put(`/firewall/nat-rules/${id}`, { ...row, enabled: checked })
|
|
},
|
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
|
|
const sortedNAT = useMemo(
|
|
() => [...(data ?? [])].sort((a, b) => a.priority - b.priority),
|
|
[data],
|
|
)
|
|
|
|
const swapNAT = useMutation({
|
|
mutationFn: async ({ a, b }: { a: NATRule; b: NATRule }) => {
|
|
await apiClient.put(`/firewall/nat-rules/${a.id}`, { ...a, priority: b.priority })
|
|
await apiClient.put(`/firewall/nat-rules/${b.id}`, { ...b, priority: a.priority })
|
|
},
|
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
|
|
onError: (e: Error) => message.error(e.message),
|
|
})
|
|
|
|
const renderTarget = (r: NATRule) => {
|
|
if (r.kind === 'masquerade') return <Tag color="gold">{r.out_zone ?? '?'}-iface IP</Tag>
|
|
if (!r.target_addr) return '—'
|
|
return <code>{r.target_addr}{r.target_port_start ? `:${r.target_port_start}${r.target_port_end !== r.target_port_start ? `-${r.target_port_end}` : ''}` : ''}</code>
|
|
}
|
|
|
|
const openEdit = (row: NATRule) => {
|
|
setEditing(row)
|
|
form.setFieldsValue({
|
|
name: row.name ?? undefined,
|
|
priority: row.priority, enabled: row.enabled, kind: row.kind,
|
|
in_zone: row.in_zone ?? undefined, out_zone: row.out_zone ?? undefined,
|
|
proto: row.proto ?? undefined,
|
|
match_src_cidr: row.match_src_cidr ?? undefined,
|
|
match_dst_cidr: row.match_dst_cidr ?? undefined,
|
|
match_dport_start: row.match_dport_start ?? undefined,
|
|
match_dport_end: row.match_dport_end ?? undefined,
|
|
target_addr: row.target_addr ?? undefined,
|
|
target_port_start: row.target_port_start ?? undefined,
|
|
target_port_end: row.target_port_end ?? undefined,
|
|
comment: row.comment ?? undefined,
|
|
})
|
|
}
|
|
|
|
const columns: ColumnsType<NATRule> = [
|
|
{
|
|
title: '', key: 'dot', width: 28,
|
|
render: (_, row) => (
|
|
<Tooltip title={row.enabled ? t('fw.rule.enabled') : t('fw.rule.ruleDisabled')}>
|
|
<span className={`fw-rule-dot fw-rule-dot--${row.enabled ? 'on' : 'off'}`} />
|
|
</Tooltip>
|
|
),
|
|
},
|
|
{
|
|
title: '#', dataIndex: 'priority', key: 'priority', width: 52,
|
|
render: (v: number) => (
|
|
<Text style={{ fontFamily: 'monospace', fontSize: 12, color: '#475569' }}>{v}</Text>
|
|
),
|
|
},
|
|
{
|
|
title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', width: 110,
|
|
render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag>,
|
|
},
|
|
{
|
|
title: t('fw.nat.name'), key: 'name',
|
|
render: (_, r) => (
|
|
<div>
|
|
{r.name
|
|
? <div className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>
|
|
: <div className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>{t('fw.rule.unnamed')}</div>
|
|
}
|
|
{r.comment && <div style={{ fontSize: 11, color: '#64748B', marginTop: 1 }}>{r.comment}</div>}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: t('fw.nat.match'), key: 'match',
|
|
render: (_, r) => (
|
|
<Space size={4} wrap>
|
|
{r.in_zone && <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>in:{r.in_zone}</Tag>}
|
|
{r.out_zone && <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>out:{r.out_zone}</Tag>}
|
|
{r.proto && <Tag style={{ fontSize: 11 }}>{r.proto}</Tag>}
|
|
{r.match_src_cidr && <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>src={r.match_src_cidr}</Text>}
|
|
{r.match_dst_cidr && <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>dst={r.match_dst_cidr}</Text>}
|
|
{r.match_dport_start && <Text style={{ fontFamily: 'monospace', fontSize: 11 }}>:{r.match_dport_start}{r.match_dport_end && r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</Text>}
|
|
</Space>
|
|
),
|
|
},
|
|
{ title: t('fw.nat.target'), key: 'target', width: 200, render: (_, r) => renderTarget(r) },
|
|
{
|
|
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 68,
|
|
render: (v: boolean, row: NATRule) => (
|
|
<Switch
|
|
size="small"
|
|
checked={v}
|
|
disabled={isViewer}
|
|
loading={quickToggle.isPending && quickToggle.variables?.id === row.id}
|
|
onChange={(checked) => quickToggle.mutate({ id: row.id, row, checked })}
|
|
/>
|
|
),
|
|
},
|
|
{
|
|
title: '', key: 'move', width: 52,
|
|
render: (_, row) => {
|
|
const idx = sortedNAT.findIndex(r => r.id === row.id)
|
|
const swapping = swapNAT.isPending
|
|
return (
|
|
<Space size={1} className="fw-row-actions">
|
|
<Tooltip title={t('fw.rule.moveUp')}>
|
|
<Button type="text" size="small" icon={<ArrowUpOutlined />}
|
|
disabled={isViewer || idx <= 0 || swapping}
|
|
onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx - 1] })} />
|
|
</Tooltip>
|
|
<Tooltip title={t('fw.rule.moveDown')}>
|
|
<Button type="text" size="small" icon={<ArrowDownOutlined />}
|
|
disabled={isViewer || idx >= sortedNAT.length - 1 || swapping}
|
|
onClick={() => swapNAT.mutate({ a: row, b: sortedNAT[idx + 1] })} />
|
|
</Tooltip>
|
|
</Space>
|
|
)
|
|
},
|
|
},
|
|
{
|
|
title: '', key: 'actions', width: 88,
|
|
render: (_, row) => (
|
|
<Space size={0} className="fw-row-actions">
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
|
|
<Button type="text" size="small" icon={<EditOutlined />}
|
|
disabled={isViewer}
|
|
onClick={() => openEdit(row)} />
|
|
</Tooltip>
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('fw.rule.duplicate')}>
|
|
<Button type="text" size="small" icon={<CopyOutlined />}
|
|
disabled={isViewer}
|
|
loading={duplicate.isPending && duplicate.variables?.id === row.id}
|
|
onClick={() => duplicate.mutate(row)} />
|
|
</Tooltip>
|
|
{isViewer ? (
|
|
<Tooltip title={t('auth.viewerBadge')}>
|
|
<Button type="text" size="small" icon={<DeleteOutlined />} danger disabled />
|
|
</Tooltip>
|
|
) : (
|
|
<Popconfirm title={t('fw.nat.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
|
|
<Button type="text" size="small" icon={<DeleteOutlined />} danger />
|
|
</Popconfirm>
|
|
)}
|
|
</Space>
|
|
),
|
|
},
|
|
]
|
|
|
|
const openCreate = () => {
|
|
setCreating(true); form.resetFields()
|
|
form.setFieldsValue({ priority: 100, enabled: true, kind: 'dnat' })
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" className="mb-16" disabled={isViewer} onClick={openCreate}>
|
|
{t('fw.nat.add')}
|
|
</Button>
|
|
</Tooltip>
|
|
<DataTable
|
|
rowKey="id"
|
|
loading={isLoading}
|
|
dataSource={sortedNAT}
|
|
columns={columns}
|
|
rowClassName={(row: NATRule) => !row.enabled ? 'fw-rule-row--disabled' : ''}
|
|
emptyContent={
|
|
<EmptyState
|
|
icon={<BranchesOutlined />}
|
|
title={t('fw.nat.emptyTitle')}
|
|
description={t('fw.nat.emptyDesc')}
|
|
action={
|
|
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
|
|
<Button type="primary" disabled={isViewer} onClick={openCreate}>{t('fw.nat.add')}</Button>
|
|
</Tooltip>
|
|
}
|
|
/>
|
|
}
|
|
/>
|
|
<Modal
|
|
title={editing ? t('fw.nat.edit') : t('fw.nat.add')}
|
|
open={editing !== null || creating}
|
|
onCancel={() => { setEditing(null); setCreating(false) }}
|
|
onOk={() => { void form.submit() }}
|
|
confirmLoading={create.isPending || update.isPending}
|
|
destroyOnHidden
|
|
width={560}
|
|
>
|
|
<Form
|
|
form={form}
|
|
layout="vertical"
|
|
onFinish={(v) => { if (editing) update.mutate({ id: editing.id, v }); else create.mutate(v) }}
|
|
>
|
|
<Form.Item label={t('fw.nat.name')} name="name">
|
|
<Input placeholder={t('fw.nat.namePlaceholder')} />
|
|
</Form.Item>
|
|
<Space size="middle">
|
|
<Form.Item label={t('fw.nat.priority')} name="priority" rules={[{ required: true }]}>
|
|
<InputNumber min={0} max={9999} />
|
|
</Form.Item>
|
|
<Form.Item label={t('fw.nat.kind')} name="kind" rules={[{ required: true }]}>
|
|
<Select style={{ width: 160 }} options={(['dnat','snat','masquerade'] as const).map(k => ({ value: k, label: k.toUpperCase() }))} />
|
|
</Form.Item>
|
|
<Form.Item label={t('fw.nat.enabled')} name="enabled" valuePropName="checked">
|
|
<Switch />
|
|
</Form.Item>
|
|
</Space>
|
|
|
|
<Form.Item noStyle shouldUpdate={(p, c) => p.kind !== c.kind}>
|
|
{({ getFieldValue }) => {
|
|
const kind = getFieldValue('kind') as NATRule['kind']
|
|
return (
|
|
<>
|
|
{kind === 'dnat' && (
|
|
<Form.Item label={t('fw.nat.inZone')} name="in_zone" rules={[{ required: true }]}>
|
|
<Select options={zoneOptions.map(z => ({ value: z, label: z }))} />
|
|
</Form.Item>
|
|
)}
|
|
{(kind === 'snat' || kind === 'masquerade') && (
|
|
<Form.Item label={t('fw.nat.outZone')} name="out_zone" rules={[{ required: true }]}>
|
|
<Select options={zoneOptions.map(z => ({ value: z, label: z }))} />
|
|
</Form.Item>
|
|
)}
|
|
<Form.Item label={t('fw.nat.proto')} name="proto">
|
|
<Select allowClear options={(['tcp','udp','any'] as const).map(p => ({ value: p, label: p }))} />
|
|
</Form.Item>
|
|
<Form.Item label={t('fw.nat.matchSrcCidr')} name="match_src_cidr">
|
|
<Input placeholder="10.0.0.0/24" />
|
|
</Form.Item>
|
|
{kind === 'dnat' && (
|
|
<>
|
|
<Form.Item label={t('fw.nat.matchDstCidr')} name="match_dst_cidr">
|
|
<Input placeholder={t('fw.nat.matchDstCidrHint')} />
|
|
</Form.Item>
|
|
<Space>
|
|
<Form.Item label={t('fw.nat.dportStart')} name="match_dport_start" rules={[{ required: true }]}>
|
|
<InputNumber min={1} max={65535} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t('fw.nat.dportEnd')}
|
|
name="match_dport_end"
|
|
dependencies={['match_dport_start']}
|
|
rules={[({ getFieldValue }) => ({
|
|
validator(_, val) {
|
|
if (!val) return Promise.resolve()
|
|
return val >= getFieldValue('match_dport_start')
|
|
? Promise.resolve()
|
|
: Promise.reject(new Error(t('fw.nat.portRangeError')))
|
|
},
|
|
})]}
|
|
>
|
|
<InputNumber min={1} max={65535} />
|
|
</Form.Item>
|
|
</Space>
|
|
</>
|
|
)}
|
|
{kind !== 'masquerade' && (
|
|
<>
|
|
<Form.Item label={t('fw.nat.targetAddr')} name="target_addr" rules={[{ required: true }]}>
|
|
<Input placeholder="192.0.2.10" />
|
|
</Form.Item>
|
|
{kind === 'dnat' && (
|
|
<Space>
|
|
<Form.Item label={t('fw.nat.targetPortStart')} name="target_port_start">
|
|
<InputNumber min={1} max={65535} />
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t('fw.nat.targetPortEnd')}
|
|
name="target_port_end"
|
|
dependencies={['target_port_start']}
|
|
rules={[({ getFieldValue }) => ({
|
|
validator(_, val) {
|
|
if (!val) return Promise.resolve()
|
|
return val >= getFieldValue('target_port_start')
|
|
? Promise.resolve()
|
|
: Promise.reject(new Error(t('fw.nat.portRangeError')))
|
|
},
|
|
})]}
|
|
>
|
|
<InputNumber min={1} max={65535} />
|
|
</Form.Item>
|
|
</Space>
|
|
)}
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
}}
|
|
</Form.Item>
|
|
|
|
<Form.Item label={t('fw.nat.comment')} name="comment">
|
|
<Input />
|
|
</Form.Item>
|
|
</Form>
|
|
</Modal>
|
|
</>
|
|
)
|
|
}
|