feat: HA-Cluster v1.2.x — Split-Brain, TOTP, Enterprise-FW, Drift-Fix, VIP-Recovery

- keepalived: pg_role='standby' hat Vorrang vor role für BACKUP-Bestimmung
- keepalived-master.sh: gecrasht Dienste beim MASTER-Übergang starten (nicht nur reload)
- confighash: ip_addresses per Interface-Name hashen statt per FK (Cross-Node-Drift-Fix)
- TOTP/2FA: RFC 6238 — Setup-Flow, QR-Code, Admin-Disable; two-step Login
- Firewall-UI: Enterprise-Design — auto-Beschreibung, icon-only Actions, zero-hit Indikator
- fe80-Filter: Link-local IPv6 aus NTP/DNS Listen-Dropdowns entfernen
- VIP-Dashboard, Dual-Path VRRP, GW-Tracking (Migrations 0033/0034)
- Forward Proxy + DNS erweiterte Einstellungen (Migrations 0031/0032)
- unbound-control: edgeguard in unbound-Gruppe via postinst

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-31 18:18:31 +02:00
parent 49899e984c
commit 1d06b28064
55 changed files with 3132 additions and 672 deletions

View File

@@ -116,7 +116,17 @@ export default function NATRulesTab() {
const columns: ColumnsType<NATRule> = [
{ title: '#', dataIndex: 'priority', key: 'priority', width: 70 },
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
{ title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', width: 100, render: (k: NATRule['kind']) => <Tag color={KIND_COLORS[k]}>{k.toUpperCase()}</Tag> },
{
title: t('fw.nat.name'), key: 'name', width: 200,
render: (_, r) => (
<div>
{r.name && <div style={{ fontWeight: 500 }}>{r.name}</div>}
{r.comment && <div style={{ fontSize: 12, color: 'var(--ant-color-text-secondary)' }}>{r.comment}</div>}
{!r.name && !r.comment && <span style={{ color: 'var(--ant-color-text-quaternary)' }}></span>}
</div>
),
},
{
title: t('fw.nat.match'), key: 'match',
render: (_, r) => (
@@ -126,11 +136,11 @@ export default function NATRulesTab() {
{r.proto && <Tag>{r.proto}</Tag>}
{r.match_src_cidr && <code>src={r.match_src_cidr}</code>}
{r.match_dst_cidr && <code>dst={r.match_dst_cidr}</code>}
{r.match_dport_start && <code>dport={r.match_dport_start}{r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</code>}
{r.match_dport_start && <code>dport={r.match_dport_start}{r.match_dport_end && r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}</code>}
</Space>
),
},
{ title: t('fw.nat.target'), key: 'target', render: (_, r) => renderTarget(r) },
{ title: t('fw.nat.target'), key: 'target', width: 200, render: (_, r) => renderTarget(r) },
{
title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80,
render: (v: boolean, row: NATRule) => (

View File

@@ -7,7 +7,8 @@ import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import {
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, EyeOutlined, FireOutlined, PlusOutlined,
ArrowDownOutlined, ArrowUpOutlined, CopyOutlined, DeleteOutlined, EditOutlined,
EyeOutlined, FireOutlined, PlusOutlined, WarningOutlined,
} from '@ant-design/icons'
const { Text } = Typography
@@ -159,10 +160,27 @@ export default function RulesTab() {
if (cidr) return cidr
return 'any'
}
// Auto-generates a human-readable one-liner like "LAN/any → WAN/10.0.0.0/24 · HTTPS"
const autoDescription = (r: FwRule): string => {
const src = renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)
const dst = renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)
const svc = r.service_object_id ? svLabel(r.service_object_id)
: r.service_group_id ? sgLabel(r.service_group_id)
: 'any'
return `${r.src_zone}/${src}${r.dst_zone}/${dst} · ${svc}`
}
const renderService = (objID?: number | null, grpID?: number | null) => {
if (objID) return <Tag style={{ fontFamily: 'monospace', fontSize: 11 }}>{svLabel(objID)}</Tag>
if (grpID) return <Tag color="purple" style={{ fontFamily: 'monospace', fontSize: 11 }}> {sgLabel(grpID)}</Tag>
return <Tag style={{ fontSize: 11, color: '#94A3B8' }}>any</Tag>
return <span className="fw-addr-any">any</span>
}
const renderAddr = (objID?: number | null, grpID?: number | null, cidr?: string | null) => {
const label = renderAddrCompact(objID, grpID, cidr)
if (label === 'any') return <span className="fw-addr-any">any</span>
return <Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>{label}</Text>
}
// ── Filter state ─────────────────────────────────────────────
@@ -294,11 +312,7 @@ export default function RulesTab() {
render: (_, r) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<ZoneBadge zone={r.src_zone} />
{(r.src_address_object_id || r.src_address_group_id || r.src_cidr) && (
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
{renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
</Text>
)}
{renderAddr(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}
</div>
),
},
@@ -311,11 +325,7 @@ export default function RulesTab() {
render: (_, r) => (
<div style={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
<ZoneBadge zone={r.dst_zone} />
{(r.dst_address_object_id || r.dst_address_group_id || r.dst_cidr) && (
<Text style={{ fontSize: 11, color: '#475569', fontFamily: 'monospace' }}>
{renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
</Text>
)}
{renderAddr(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}
</div>
),
},
@@ -327,11 +337,16 @@ export default function RulesTab() {
title: t('fw.rule.name'), key: 'name', ellipsis: true,
render: (_, r) => (
<div>
{r.name && <div style={{ fontWeight: 500, fontSize: 12, color: '#0F172A' }}>{r.name}</div>}
{r.comment && (
<div style={{ fontSize: 11, color: '#94A3B8', marginTop: 1 }}>{r.comment}</div>
)}
{!r.name && !r.comment && <Text type="secondary" style={{ fontSize: 11 }}></Text>}
{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 className="fw-rule-desc">{autoDescription(r)}</div>
}
</div>
),
},
@@ -339,7 +354,13 @@ export default function RulesTab() {
title: t('fw.rule.hits'), key: 'hits', width: 80, align: 'right' as const,
render: (_, r) => {
const c = counterByID.get(r.id)
if (!c || c.packets === 0) return <Text type="secondary" style={{ fontSize: 11 }}></Text>
if (!c || c.packets === 0) return (
<Tooltip title={r.enabled ? t('fw.rule.zeroHitHint') : undefined}>
<span style={{ fontSize: 11, color: r.enabled ? '#FAAD14' : '#CBD5E1' }}>
{r.enabled ? <><WarningOutlined style={{ fontSize: 10, marginRight: 2 }} />0</> : '—'}
</span>
</Tooltip>
)
return (
<Tooltip title={`${c.packets.toLocaleString()} pkts · ${fmtBytes(c.bytes)}`}>
<Text style={{ fontSize: 11, fontVariantNumeric: 'tabular-nums', color: '#0EA5E9' }}>
@@ -372,19 +393,19 @@ export default function RulesTab() {
),
},
{
title: '', key: 'move', width: 60,
title: '', key: 'move', width: 52,
render: (_, row) => {
const idx = sortedRules.findIndex(r => r.id === row.id)
const swapping = swap.isPending
return (
<Space size={2}>
<Space size={1} className="fw-row-actions">
<Tooltip title={t('fw.rule.moveUp')}>
<Button size="small" icon={<ArrowUpOutlined />}
<Button type="text" size="small" icon={<ArrowUpOutlined />}
disabled={isViewer || idx <= 0 || swapping}
onClick={() => swap.mutate({ a: row, b: sortedRules[idx - 1] })} />
</Tooltip>
<Tooltip title={t('fw.rule.moveDown')}>
<Button size="small" icon={<ArrowDownOutlined />}
<Button type="text" size="small" icon={<ArrowDownOutlined />}
disabled={isViewer || idx >= sortedRules.length - 1 || swapping}
onClick={() => swap.mutate({ a: row, b: sortedRules[idx + 1] })} />
</Tooltip>
@@ -393,30 +414,27 @@ export default function RulesTab() {
},
},
{
title: '', key: 'actions', width: 120,
title: '', key: 'actions', width: 88,
render: (_, row) => (
<Space size={4}>
<Space size={0} className="fw-row-actions">
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('common.edit')}>
<Button size="small" disabled={isViewer} onClick={() => editFromRow(row)}>
{t('common.edit')}
</Button>
<Button type="text" size="small" icon={<EditOutlined />}
disabled={isViewer}
onClick={() => editFromRow(row)} />
</Tooltip>
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('fw.rule.duplicate')}>
<Button
size="small"
icon={<CopyOutlined />}
<Button type="text" size="small" icon={<CopyOutlined />}
disabled={isViewer}
loading={duplicate.isPending && duplicate.variables?.id === row.id}
onClick={() => duplicate.mutate(row)}
/>
onClick={() => duplicate.mutate(row)} />
</Tooltip>
{isViewer ? (
<Tooltip title={t('auth.viewerBadge')}>
<Button size="small" danger disabled>{t('common.delete')}</Button>
<Button type="text" size="small" icon={<DeleteOutlined />} danger disabled />
</Tooltip>
) : (
<Popconfirm title={t('fw.rule.deleteConfirm')} onConfirm={() => del.mutate(row.id)}>
<Button size="small" danger>{t('common.delete')}</Button>
<Button type="text" size="small" icon={<DeleteOutlined />} danger />
</Popconfirm>
)}
</Space>
@@ -486,7 +504,14 @@ export default function RulesTab() {
loading={isLoading}
dataSource={filteredRules}
columns={columns}
rowClassName={(row: FwRule) => row.enabled ? '' : 'fw-rule-row--disabled'}
rowClassName={(row: FwRule) => {
const c = counterByID.get(row.id)
const zeroHit = row.enabled && (!c || c.packets === 0)
return [
!row.enabled ? 'fw-rule-row--disabled' : '',
zeroHit ? 'fw-rule-row--zero-hit' : '',
].filter(Boolean).join(' ')
}}
emptyContent={
<EmptyState
icon={<FireOutlined />}