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

@@ -1,6 +1,6 @@
import { Alert, Button, Card, Descriptions, Input, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
import { Alert, Button, Card, Descriptions, Input, List, Popconfirm, Space, Spin, Table, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined } from '@ant-design/icons'
import { ApartmentOutlined, CopyOutlined, DeleteOutlined, KeyOutlined, ReloadOutlined, SwapOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -87,6 +87,28 @@ interface CertStatus {
peer?: CertInfo
}
interface VIPInfo {
id: number
address: string
prefix: number
device: string
}
interface VIPStatusEntry {
vip: VIPInfo
active_on: string[]
}
interface VIPTestStep {
step: string
ok: boolean
message?: string
}
interface VIPTestResult {
steps: VIPTestStep[]
}
function statusTag(s: HANode['status'], t: (k: string) => string) {
switch (s) {
case 'online': return <Tag color="green">{t('cluster.status.online')}</Tag>
@@ -272,6 +294,42 @@ export default function ClusterPage() {
onError: () => message.error(t('cluster.joinTokenFailed')),
})
const isClusterMode = data?.mode === 'cluster'
const vipStatusQuery = useQuery({
queryKey: ['cluster', 'vip-status'],
queryFn: async () => {
const r = await apiClient.get('/cluster/vip-status')
const payload = isEnvelope(r.data) ? (r.data.data as { vips?: VIPStatusEntry[] }) : null
return payload?.vips ?? []
},
enabled: isClusterMode,
refetchInterval: 30_000,
retry: 1,
})
const [vipTestResult, setVipTestResult] = useState<{ id: number; steps: VIPTestStep[] } | null>(null)
const vipSwing = useMutation({
mutationFn: async ({ id, action }: { id: number; action: 'to_secondary' | 'restore' }) => {
const r = await apiClient.post('/cluster/vip-test', { ip_address_id: id, action })
return isEnvelope(r.data) ? (r.data.data as VIPTestResult) : null
},
onSuccess: (result, { id, action }) => {
if (result) setVipTestResult({ id, steps: result.steps })
const allOk = result?.steps.every(s => s.ok) ?? false
if (allOk) {
const key = action === 'to_secondary' ? 'cluster.vipTest.swingOk' : 'cluster.vipTest.restoreOk'
void message.success(t(key))
} else {
const key = action === 'to_secondary' ? 'cluster.vipTest.swingFailed' : 'cluster.vipTest.restoreFailed'
void message.error(t(key))
}
void vipStatusQuery.refetch()
},
onError: (e: Error) => void message.error(e.message),
})
const primaryFqdn = data?.local_node?.fqdn ?? window.location.hostname
const peerColumns: ColumnsType<HANode> = [
@@ -641,6 +699,134 @@ export default function ClusterPage() {
</Card>
)}
{/* ── VIP-Schwenk Test ─────────────────────────────────── */}
{isClusterMode && (
<Card
size="small"
title={<Space><SwapOutlined />{t('cluster.vipTest.cardTitle')}</Space>}
className="mb-16"
extra={
<Button size="small" icon={<ReloadOutlined />} onClick={() => void vipStatusQuery.refetch()}>
{t('common.refresh')}
</Button>
}
>
<Alert
type="info"
showIcon
message={t('cluster.vipTest.cardDesc')}
className="mb-12"
/>
{vipStatusQuery.isLoading ? (
<Spin />
) : (vipStatusQuery.data?.length ?? 0) === 0 ? (
<Text type="secondary">{t('cluster.vipTest.noVips')}</Text>
) : (
<Table<VIPStatusEntry>
size="small"
rowKey={r => String(r.vip.id)}
dataSource={vipStatusQuery.data ?? []}
pagination={false}
expandable={{
expandedRowRender: r => {
const res = vipTestResult?.id === r.vip.id ? vipTestResult : null
if (!res) return null
return (
<List
size="small"
dataSource={res.steps}
renderItem={s => (
<List.Item>
<Space>
<Tag color={s.ok ? 'green' : 'red'}>{s.ok ? t('cluster.vipTest.stepOk') : t('cluster.vipTest.stepFail')}</Tag>
<Text style={{ fontFamily: 'monospace', fontSize: 12 }}>{s.step}</Text>
{s.message && <Text type="danger" style={{ fontSize: 12 }}>{s.message}</Text>}
</Space>
</List.Item>
)}
/>
)
},
rowExpandable: r => vipTestResult?.id === r.vip.id,
}}
columns={[
{
title: t('cluster.vipTest.colAddress'),
key: 'address',
render: (_, r) => (
<Text style={{ fontFamily: 'monospace' }}>{r.vip.address}/{r.vip.prefix}</Text>
),
},
{
title: t('cluster.vipTest.colInterface'),
key: 'device',
width: 120,
render: (_, r) => <Tag>{r.vip.device}</Tag>,
},
{
title: t('cluster.vipTest.colActiveOn'),
key: 'activeOn',
render: (_, r) => {
if (!r.active_on || r.active_on.length === 0) {
return <Tag color="red">{t('cluster.vipTest.unknown')}</Tag>
}
return (
<Space size={4}>
{r.active_on.map(fqdn => <Tag key={fqdn} color="green">{fqdn}</Tag>)}
</Space>
)
},
},
{
title: t('common.actions'),
key: 'actions',
width: 200,
render: (_, r) => {
const localFqdn = data?.local_node?.fqdn
const onLocal = r.active_on?.includes(localFqdn ?? '') ?? false
const onPeer = r.active_on?.some(f => f !== localFqdn) ?? false
const loading = vipSwing.isPending && (vipSwing.variables as { id: number })?.id === r.vip.id
return (
<Space size={4}>
{!isViewer && !onPeer && (
<Popconfirm
title={t('cluster.vipTest.confirmSwing', { addr: r.vip.address })}
okText={t('common.yes')}
cancelText={t('common.no')}
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'to_secondary' })}
>
<Button size="small" loading={loading && onLocal}>
{t('cluster.vipTest.swingBtn')}
</Button>
</Popconfirm>
)}
{!isViewer && onPeer && (
<Popconfirm
title={t('cluster.vipTest.confirmRestore', { addr: r.vip.address })}
okText={t('common.yes')}
cancelText={t('common.no')}
onConfirm={() => vipSwing.mutate({ id: r.vip.id, action: 'restore' })}
>
<Button size="small" type="primary" loading={loading}>
{t('cluster.vipTest.restoreBtn')}
</Button>
</Popconfirm>
)}
{isViewer && (
<Tooltip title={t('auth.viewerBadge')}>
<Button size="small" disabled>{t('cluster.vipTest.swingBtn')}</Button>
</Tooltip>
)}
</Space>
)
},
},
]}
/>
)}
</Card>
)}
{/* ── Per-Node Resources ────────────────────────────────── */}
<Card
size="small"