Files
edgeguard-native/management-ui/src/pages/Firewall/NATRules.tsx
Debian b48ba65ce3 feat(firewall): Inline-Note + Labels direkt in der Tabelle
- Migration 0035: note TEXT + labels TEXT[] für firewall_rules + nat_rules
- PATCH /firewall/rules/:id + /nat-rules/:id für note/labels Updates
- InlineNote: gold Tag mit MessageOutlined, Klick zum Bearbeiten (Enter/Blur speichert)
- InlineLabels: geekblue Tags mit X-Button zum Entfernen, "+" zum Hinzufügen
- Name-Spalte: Name + Labels + Note in Zeile 1, comment/auto-desc in Zeile 2
- Gleiche UX für NAT-Regeln

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-31 18:46:54 +02:00

440 lines
18 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'
import { InlineNote, InlineLabels } from './InlineEditors'
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 patchNote = useMutation({
mutationFn: async ({ id, note }: { id: number; note: string }) => {
await apiClient.patch(`/firewall/nat-rules/${id}`, { note })
},
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) },
onError: (e: Error) => message.error(e.message),
})
const patchLabels = useMutation({
mutationFn: async ({ id, labels }: { id: number; labels: string[] }) => {
await apiClient.patch(`/firewall/nat-rules/${id}`, { labels })
},
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>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, flexWrap: 'wrap' }}>
{r.name
? <span className="fw-rule-name" style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</span>
: <span className="fw-rule-name" style={{ fontSize: 12, color: '#94A3B8', fontStyle: 'italic' }}>{t('fw.rule.unnamed')}</span>
}
<InlineLabels
labels={r.labels ?? []}
disabled={isViewer}
onSave={labels => patchLabels.mutate({ id: r.id, labels })}
/>
<InlineNote
value={r.note}
disabled={isViewer}
onSave={note => patchNote.mutate({ id: r.id, note })}
/>
</div>
{r.comment && <div style={{ fontSize: 11, color: '#64748B', marginTop: 2 }}>{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>
</>
)
}