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

@@ -152,7 +152,10 @@
"emptyTitle": "Noch keine eigenen Firewall-Regeln.",
"emptyDesc": "Die System-Regeln oben halten SSH (rate-limited), HTTPS :443 und Mgmt-UI :3443 immer offen (Anti-Lockout). Eigene Regeln für app-spezifische Inbound-Ports oder zonenübergreifende Forwards anlegen.",
"logEnabled": "Logging aktiv — gematchte Pakete werden ins Firewall-Log geschrieben",
"ruleDisabled": "Regel deaktiviert"
"ruleDisabled": "Regel deaktiviert",
"enabled": "Aktiv",
"unnamed": "(kein Name)",
"zeroHitHint": "Keine Treffer seit dem letzten Neustart — möglicherweise ungenutzte oder überlagerte Regel"
},
"kpi": {
"policyRules": "Policy-Regeln",
@@ -281,7 +284,12 @@
"loggedInAs": "Angemeldet als",
"forgotPassword": "Passwort vergessen?",
"viewerBadge": "Nur lesen",
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen."
"viewerHint": "Dieser Account hat die Rolle Betrachter — Änderungen sind gesperrt. Ein Admin kann die Rolle anpassen.",
"totp": {
"prompt": "Bitte gib den 6-stelligen Code aus deiner Authenticator-App ein.",
"verify": "Code bestätigen",
"invalidCode": "Ungültiger Code"
}
},
"reset": {
"title": "Admin-Passwort zurücksetzen",
@@ -392,6 +400,17 @@
"backends": "Backends",
"attached": "{{count}}/{{total}} Domains haben einen Primary-Backend"
},
"vipCard": {
"title": "VIP / VRRP",
"noVips": "Keine VIPs konfiguriert",
"keepalivedInactive": "keepalived läuft nicht",
"state": {
"MASTER": "MASTER",
"BACKUP": "BACKUP",
"FAULT": "FAULT",
"UNKNOWN": "Unbekannt"
}
},
"systemCard": {
"title": "System",
"version": "Version",
@@ -399,9 +418,16 @@
"ifaces": "Interfaces",
"wg": "WireGuard"
},
"networkServicesCard": {
"title": "Netzwerk-Dienste",
"configure": "Konfigurieren"
},
"alertsCard": {
"title": "Aktuelle Alerts",
"viewAll": "Alle anzeigen"
"viewAll": "Alle anzeigen",
"summary": "{{critical}} kritisch · {{warning}} Warnung",
"summaryWarning": "{{warning}} Warnung",
"summaryCritical": "{{critical}} kritisch"
},
"downBackendsAlert": "{{count}} Backend(s) komplett ausgefallen — kein Server UP",
"maintenanceAlert": "{{count}} Domain(s) im Wartungs-Modus",
@@ -664,7 +690,18 @@
"hintPrimary": "Auf dem Primary: edgeguard-ctl cluster-init-replication",
"hintStandby": "Auf dem Secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
"hintKeepalived": "Keepalived auf beiden Nodes: sudo systemctl enable --now keepalived",
"hintFailover": "Bei Failover: edgeguard-ctl promote (auf dem Secondary)"
"hintFailover": "Bei Failover: edgeguard-ctl promote (auf dem Secondary)",
"splitBrainSection": "Split-Brain-Schutz (Dual-Path VRRP + Gateway-Tracking)",
"hbInterface": "Heartbeat-Interface",
"hbInterfaceHelp": "Zweites Interface für die VI_HB-Instanz — VRRP-Advertisements laufen hier unabhängig von VI_1. Leer lassen um zu deaktivieren.",
"hbSrcIp": "Heartbeat-Quell-IP",
"hbSrcIpHelp": "Eigene IP auf dem Heartbeat-Interface (unicast_src_ip für VI_HB).",
"hbPeerIp": "Heartbeat-Peer-IP",
"hbPeerIpHelp": "Peer-IP auf dem Heartbeat-Interface (unicast_peer für VI_HB).",
"hbRouterId": "Heartbeat Router-ID",
"hbRouterIdHelp": "VRRP virtual_router_id für VI_HB — muss sich von der Haupt-Router-ID unterscheiden. Standard: 52.",
"gwCheckIp": "Gateway-Check-IP",
"gwCheckIpHelp": "Upstream-Gateway-IP die alle 5 s angepingt wird. Nicht erreichbar → Priorität sinkt um 110 → Failover wird ausgelöst. Leer lassen um zu deaktivieren."
},
"loadTitle": "Per-Node Resources (mTLS-Aggregator)",
"loadEmpty": "Keine Node-Resources verfügbar — Agent-Listener nicht erreichbar?",
@@ -699,7 +736,27 @@
"step3SetupDesc": "Setup-Wizard auf dem neuen Knoten öffnen (https://<node-fqdn>:3443/setup), \"Vorhandenem Cluster beitreten\" wählen, Primary-FQDN ({{primaryFqdn}}) eingeben und den Token oben einfügen.",
"generateNewToken": "Neuen Token generieren",
"setupWizardHint": "Setup-Wizard auf dem neuen Knoten öffnen",
"newNodeFqdnLabel": "FQDN des neuen Knotens"
"newNodeFqdnLabel": "FQDN des neuen Knotens",
"vipTest": {
"cardTitle": "VIP-Schwenk Test",
"cardDesc": "Verschiebt einen VIP temporär auf den Secondary um zu prüfen ob die Dienste korrekt antworten. Keepalived ist nicht beteiligt — reiner ip addr add/del Test.",
"colAddress": "VIP-Adresse",
"colInterface": "Interface",
"colActiveOn": "Aktiv auf",
"swingBtn": "→ Secondary",
"restoreBtn": "← Primary",
"swingOk": "VIP erfolgreich auf Secondary geschwenkt",
"restoreOk": "VIP zurück auf Primary",
"swingFailed": "VIP-Schwenk fehlgeschlagen",
"restoreFailed": "VIP-Rückschwenk fehlgeschlagen",
"noVips": "Keine VIPs konfiguriert (ip_addresses mit is_vip=true)",
"steps": "Schritte",
"stepOk": "OK",
"stepFail": "Fehler",
"confirmSwing": "{{addr}} auf Secondary verschieben?",
"confirmRestore": "{{addr}} zurück auf Primary?",
"unknown": "unbekannt"
}
},
"ssl": {
"title": "SSL-Zertifikate",
@@ -1126,7 +1183,16 @@
"flushCacheFailed": "Cache-Flush fehlgeschlagen",
"upstreamForwardsInvalid": "Jeder Forwarder muss eine gültige IP sein (z.B. 1.1.1.1 oder 9.9.9.9)",
"accessACLInvalid": "Jeder Eintrag muss eine gültige IP oder CIDR sein (z.B. 10.0.0.0/8)",
"cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein"
"cacheTTLError": "Cache-Max-TTL muss ≥ Cache-Min-TTL sein",
"cacheSection": "Cache",
"prefetch": "Häufige Records vorausladen",
"prefetchExtra": "Häufig abgefragte Records werden vor Ablauf der TTL neu aufgelöst — reduziert Latenz für bekannte Namen.",
"serveExpired": "Abgelaufene Records ausliefern",
"serveExpiredExtra": "Stale Cache-Einträge zurückgeben wenn Upstream-Resolver nicht erreichbar ist. Reduziert SERVFAIL bei Ausfällen.",
"msgCacheSizeMB": "Message-Cache (MB)",
"msgCacheSizeMBExtra": "RAM für DNS-Antwort-Cache (msg-cache-size). Standard 64 MB.",
"rrsetCacheSizeMB": "RRset-Cache (MB)",
"rrsetCacheSizeMBExtra": "RAM für Resource-Record-Cache (rrset-cache-size). Sollte ~2x Message-Cache sein. Standard 128 MB."
}
},
"fwd": {
@@ -1180,6 +1246,28 @@
"dstdom_regex": "dstdom_regex — Domain-Regex",
"srcdom_regex": "srcdom_regex — Quell-Domain-Regex",
"browser": "browser — User-Agent-Regex"
},
"settings": {
"title": "Proxy-Einstellungen",
"listenAddresses": "Listen-Adressen",
"listenAddressesExtra": "Komma-getrennte IPs auf denen Squid lauscht (z.B. 10.0.5.1, 10.0.20.1). Leer = alle Interfaces.",
"listenPort": "Port",
"listenPortExtra": "Standard: 3128.",
"saveFailed": "Einstellungen konnten nicht gespeichert werden.",
"cacheSection": "Cache",
"cacheMemMB": "RAM-Cache (MB)",
"cacheMemMBExtra": "RAM den Squid für Caching nutzt (cache_mem). Standard 64 MB.",
"cacheDirMB": "Disk-Cache (MB)",
"cacheDirMBExtra": "Speicherplatz für den UFS-Cache. Standard 100 MB.",
"maxObjSizeMB": "Max. Objektgröße (MB)",
"maxObjSizeMBExtra": "Größtes Objekt das Squid cached. Größere Objekte werden direkt durchgeleitet. Standard 4 MB.",
"timeoutSection": "Timeouts",
"connectTimeout": "Verbindungs-Timeout (s)",
"connectTimeoutExtra": "Sekunden, die Squid beim Verbindungsaufbau zum Upstream wartet.",
"readTimeout": "Lese-Timeout (s)",
"readTimeoutExtra": "Sekunden zwischen aufeinanderfolgenden Lesevorgängen vom Upstream.",
"requestTimeout": "Anfrage-Timeout (s)",
"requestTimeoutExtra": "Maximale Zeit für einen vollständigen Request/Response-Zyklus."
}
},
"common": {
@@ -1205,6 +1293,8 @@
"retry": "Erneut versuchen",
"close": "Schließen",
"refresh": "Aktualisieren",
"back": "Zurück",
"next": "Weiter",
"up": "UP",
"down": "DOWN",
"relTime": {
@@ -1542,7 +1632,24 @@
"errorEmailTaken": "Diese E-Mail-Adresse wird bereits verwendet.",
"cannotDeleteSelf": "Das eigene Konto kann nicht gelöscht werden.",
"you": "Ich",
"never": "Nie"
"never": "Nie",
"totp": {
"on": "2FA",
"off": "",
"setup": "2FA einrichten",
"manage": "2FA verwalten",
"disable": "2FA deaktivieren",
"disableFor": "2FA für {{email}} deaktivieren",
"enabled": "2FA wurde aktiviert",
"disabled": "2FA wurde deaktiviert",
"setupTitle": "Zwei-Faktor-Authentifizierung einrichten",
"manageTitle": "Zwei-Faktor-Authentifizierung",
"scanHint": "Scanne den QR-Code mit Google Authenticator, Authy oder einer kompatiblen App.",
"enterCode": "Gib den 6-stelligen Code aus deiner Authenticator-App ein:",
"confirm": "Bestätigen & aktivieren",
"alreadyEnabled": "2FA ist für diesen Account aktiv.",
"disableHint": "Klicke auf 'Deaktivieren' um 2FA für diesen Account zu entfernen."
}
},
"audit": {
"title": "Audit-Log",

View File

@@ -152,7 +152,10 @@
"emptyTitle": "No custom firewall rules yet.",
"emptyDesc": "The system rules above keep SSH (rate-limited), HTTPS :443 and the mgmt UI :3443 open (anti-lockout). Add custom rules for app-specific inbound ports or cross-zone forwards.",
"logEnabled": "Logging active — matched packets are written to the firewall log",
"ruleDisabled": "Rule disabled"
"ruleDisabled": "Rule disabled",
"enabled": "Active",
"unnamed": "(unnamed)",
"zeroHitHint": "No hits since last restart — possibly unused or shadowed rule"
},
"kpi": {
"policyRules": "Policy Rules",
@@ -281,7 +284,12 @@
"loggedInAs": "Signed in as",
"forgotPassword": "Forgot your password?",
"viewerBadge": "Read-only",
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role."
"viewerHint": "Your account has viewer role — all changes are blocked. Contact an admin to change your role.",
"totp": {
"prompt": "Enter the 6-digit code from your authenticator app.",
"verify": "Verify code",
"invalidCode": "Invalid code"
}
},
"reset": {
"title": "Reset admin password",
@@ -392,6 +400,17 @@
"backends": "Backends",
"attached": "{{count}}/{{total}} domains have a primary backend"
},
"vipCard": {
"title": "VIP / VRRP",
"noVips": "No VIPs configured",
"keepalivedInactive": "keepalived not running",
"state": {
"MASTER": "MASTER",
"BACKUP": "BACKUP",
"FAULT": "FAULT",
"UNKNOWN": "Unknown"
}
},
"systemCard": {
"title": "System",
"version": "Version",
@@ -399,9 +418,16 @@
"ifaces": "Interfaces",
"wg": "WireGuard"
},
"networkServicesCard": {
"title": "Network services",
"configure": "Configure"
},
"alertsCard": {
"title": "Recent alerts",
"viewAll": "View all"
"title": "Active alerts",
"viewAll": "View all",
"summary": "{{critical}} critical · {{warning}} warning",
"summaryWarning": "{{warning}} warning",
"summaryCritical": "{{critical}} critical"
},
"downBackendsAlert": "{{count}} backend(s) completely down — no server UP",
"maintenanceAlert": "{{count}} domain(s) in maintenance mode",
@@ -664,7 +690,18 @@
"hintPrimary": "On primary: edgeguard-ctl cluster-init-replication",
"hintStandby": "On secondary: edgeguard-ctl cluster-setup-standby <primary-ip>",
"hintKeepalived": "Keepalived on both nodes: sudo systemctl enable --now keepalived",
"hintFailover": "On failover: edgeguard-ctl promote (on the secondary node)"
"hintFailover": "On failover: edgeguard-ctl promote (on the secondary node)",
"splitBrainSection": "Split-brain protection (dual-path VRRP + gateway tracking)",
"hbInterface": "Heartbeat interface",
"hbInterfaceHelp": "Second interface for VI_HB instance — VRRP advertisements run here independently of VI_1. Leave empty to disable.",
"hbSrcIp": "Heartbeat source IP",
"hbSrcIpHelp": "Own IP on the heartbeat interface (unicast_src_ip for VI_HB).",
"hbPeerIp": "Heartbeat peer IP",
"hbPeerIpHelp": "Peer IP on the heartbeat interface (unicast_peer for VI_HB).",
"hbRouterId": "Heartbeat router ID",
"hbRouterIdHelp": "VRRP virtual_router_id for VI_HB — must differ from main Router ID. Default: 52.",
"gwCheckIp": "Gateway check IP",
"gwCheckIpHelp": "Upstream gateway IP to ping every 5 s. If unreachable: priority drops by 110 → failover triggers. Leave empty to disable."
},
"loadTitle": "Per-node resources (mTLS aggregator)",
"loadEmpty": "No node resources available — agent listener unreachable?",
@@ -699,7 +736,27 @@
"step3SetupDesc": "Open the setup wizard on the new node (https://<node-fqdn>:3443/setup), choose \"Join existing cluster\", enter the primary FQDN ({{primaryFqdn}}) and paste the token above.",
"generateNewToken": "Generate new token",
"setupWizardHint": "Open the setup wizard on the new node",
"newNodeFqdnLabel": "New node FQDN"
"newNodeFqdnLabel": "New node FQDN",
"vipTest": {
"cardTitle": "VIP failover test",
"cardDesc": "Temporarily move a VIP to the secondary to test that services respond correctly. Keepalived is not involved — this is a raw ip addr add/del test.",
"colAddress": "VIP address",
"colInterface": "Interface",
"colActiveOn": "Active on",
"swingBtn": "→ Secondary",
"restoreBtn": "← Primary",
"swingOk": "VIP successfully moved to secondary",
"restoreOk": "VIP restored to primary",
"swingFailed": "VIP swing failed",
"restoreFailed": "VIP restore failed",
"noVips": "No VIPs configured (ip_addresses with is_vip=true)",
"steps": "Steps",
"stepOk": "OK",
"stepFail": "Failed",
"confirmSwing": "Move {{addr}} to secondary?",
"confirmRestore": "Restore {{addr}} to primary?",
"unknown": "unknown"
}
},
"ssl": {
"title": "SSL certificates",
@@ -1126,7 +1183,16 @@
"flushCacheFailed": "Flush failed",
"upstreamForwardsInvalid": "Each forwarder must be a valid IP (e.g. 1.1.1.1 or 9.9.9.9)",
"accessACLInvalid": "Each entry must be a valid IP or CIDR (e.g. 10.0.0.0/8 or 192.168.1.0/24)",
"cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL"
"cacheTTLError": "Cache max-TTL must be ≥ cache min-TTL",
"cacheSection": "Cache",
"prefetch": "Prefetch popular records",
"prefetchExtra": "Re-fetch records before TTL expires if queried frequently — reduces latency for hot names.",
"serveExpired": "Serve expired records",
"serveExpiredExtra": "Return stale cache entries when upstream resolvers are unreachable. Reduces SERVFAIL during outages.",
"msgCacheSizeMB": "Message cache (MB)",
"msgCacheSizeMBExtra": "RAM for DNS response cache (msg-cache-size). Default 64 MB.",
"rrsetCacheSizeMB": "RRset cache (MB)",
"rrsetCacheSizeMBExtra": "RAM for resource-record cache (rrset-cache-size). Should be ~2x message cache. Default 128 MB."
}
},
"fwd": {
@@ -1180,6 +1246,28 @@
"dstdom_regex": "dstdom_regex — destination domain regex",
"srcdom_regex": "srcdom_regex — source domain regex",
"browser": "browser — User-Agent regex"
},
"settings": {
"title": "Proxy settings",
"listenAddresses": "Listen addresses",
"listenAddressesExtra": "Comma-separated IPs Squid listens on (e.g. 10.0.5.1, 10.0.20.1). Leave empty to listen on all interfaces.",
"listenPort": "Port",
"listenPortExtra": "Default: 3128.",
"saveFailed": "Settings could not be saved.",
"cacheSection": "Cache",
"cacheMemMB": "In-memory cache (MB)",
"cacheMemMBExtra": "RAM used by Squid for caching (cache_mem). Default 64 MB.",
"cacheDirMB": "Disk cache (MB)",
"cacheDirMBExtra": "Disk space for the UFS cache. Default 100 MB.",
"maxObjSizeMB": "Max. object size (MB)",
"maxObjSizeMBExtra": "Largest object Squid will cache. Objects above this are fetched fresh. Default 4 MB.",
"timeoutSection": "Timeouts",
"connectTimeout": "Connect timeout (s)",
"connectTimeoutExtra": "Seconds to wait when opening a connection to the upstream server.",
"readTimeout": "Read timeout (s)",
"readTimeoutExtra": "Seconds Squid waits between consecutive reads from the upstream.",
"requestTimeout": "Request timeout (s)",
"requestTimeoutExtra": "Maximum time for a complete request/response cycle."
}
},
"common": {
@@ -1205,6 +1293,8 @@
"retry": "Retry",
"close": "Close",
"refresh": "Refresh",
"back": "Back",
"next": "Next",
"up": "UP",
"down": "DOWN",
"relTime": {
@@ -1542,7 +1632,24 @@
"errorEmailTaken": "This email address is already in use.",
"cannotDeleteSelf": "You cannot delete your own account.",
"you": "You",
"never": "Never"
"never": "Never",
"totp": {
"on": "2FA",
"off": "",
"setup": "Set up 2FA",
"manage": "Manage 2FA",
"disable": "Disable 2FA",
"disableFor": "Disable 2FA for {{email}}",
"enabled": "2FA has been enabled",
"disabled": "2FA has been disabled",
"setupTitle": "Set up two-factor authentication",
"manageTitle": "Two-factor authentication",
"scanHint": "Scan the QR code with Google Authenticator, Authy, or any compatible app.",
"enterCode": "Enter the 6-digit code from your authenticator app:",
"confirm": "Confirm & activate",
"alreadyEnabled": "2FA is active for this account.",
"disableHint": "Click 'Disable 2FA' to remove two-factor authentication from this account."
}
},
"audit": {
"title": "Audit log",

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"

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Alert, Button, Card, Col, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
import { Alert, Button, Card, Col, Divider, Drawer, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tabs, Tag, Tooltip, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { BarChartOutlined, CheckCircleOutlined, ClearOutlined, CloseCircleOutlined, GlobalOutlined, NodeIndexOutlined, PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@@ -43,6 +43,10 @@ interface Settings {
qname_minimisation: boolean
cache_min_ttl: number
cache_max_ttl: number
prefetch: boolean
serve_expired: boolean
msg_cache_size_mb: number
rrset_cache_size_mb: number
}
const RECORD_TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'SRV', 'NS', 'PTR', 'CAA']
@@ -464,6 +468,7 @@ function SettingsTab() {
for (const i of sys ?? []) {
if (i.ifname === 'lo') continue
for (const a of i.addr_info ?? []) {
if (a.local.startsWith('fe80:')) continue
ipOptions.push({
value: a.local,
label: `${a.local}${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,
@@ -477,7 +482,20 @@ function SettingsTab() {
.split(',')
.map(s => s.trim())
.filter(Boolean),
} : undefined
} : {
listen_addresses: [],
listen_port: 53,
upstream_forwards: '1.1.1.1, 9.9.9.9',
access_acl: '10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16',
dnssec: false,
qname_minimisation: true,
cache_min_ttl: 60,
cache_max_ttl: 86400,
prefetch: false,
serve_expired: false,
msg_cache_size_mb: 64,
rrset_cache_size_mb: 128,
}
const save = useMutation({
mutationFn: async (v: SettingsForm) => {
@@ -566,9 +584,19 @@ function SettingsTab() {
<Form.Item label={t('dns.settings.qnameMin')} name="qname_minimisation" valuePropName="checked">
<Switch />
</Form.Item>
<Space>
<Form.Item label={t('dns.settings.prefetch')} name="prefetch" valuePropName="checked"
extra={t('dns.settings.prefetchExtra')}>
<Switch />
</Form.Item>
<Form.Item label={t('dns.settings.serveExpired')} name="serve_expired" valuePropName="checked"
extra={t('dns.settings.serveExpiredExtra')}>
<Switch />
</Form.Item>
<Divider plain>{t('dns.settings.cacheSection')}</Divider>
<Space wrap>
<Form.Item label={t('dns.settings.cacheMin')} name="cache_min_ttl" dependencies={['cache_max_ttl']}>
<InputNumber min={0} style={{ width: 120 }} />
<InputNumber min={0} style={{ width: 130 }} addonAfter="s" />
</Form.Item>
<Form.Item
label={t('dns.settings.cacheMax')}
@@ -586,7 +614,15 @@ function SettingsTab() {
}),
]}
>
<InputNumber min={60} style={{ width: 120 }} />
<InputNumber min={60} style={{ width: 130 }} addonAfter="s" />
</Form.Item>
<Form.Item label={t('dns.settings.msgCacheSizeMB')} name="msg_cache_size_mb"
extra={t('dns.settings.msgCacheSizeMBExtra')}>
<InputNumber min={8} max={4096} style={{ width: 130 }} addonAfter="MB" />
</Form.Item>
<Form.Item label={t('dns.settings.rrsetCacheSizeMB')} name="rrset_cache_size_mb"
extra={t('dns.settings.rrsetCacheSizeMBExtra')}>
<InputNumber min={16} max={8192} style={{ width: 130 }} addonAfter="MB" />
</Form.Item>
</Space>
<Form.Item>

File diff suppressed because it is too large Load Diff

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 />}

View File

@@ -1,6 +1,6 @@
import { useMemo, useState } from 'react'
import {
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message,
Alert, Button, Card, Col, Form, Input, InputNumber, Modal, Progress, Row, Select, Space, Statistic, Switch, Tag, Tooltip, Typography, message, Divider,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { ArrowDownOutlined, ArrowUpOutlined, BarChartOutlined, CheckCircleOutlined, CloseCircleOutlined, CloudServerOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'
@@ -31,6 +31,18 @@ interface ACL {
interface ServiceStatus { label: string; unit: string; active: boolean; state: string }
interface ProxySettings {
id: number
listen_addresses: string
listen_port: number
cache_mem_mb: number
cache_dir_mb: number
max_obj_size_mb: number
connect_timeout: number
read_timeout: number
request_timeout: number
}
interface FormValues {
name: string
acl_type: string
@@ -98,6 +110,27 @@ export default function ForwardProxyPage() {
refetchInterval: 30_000,
})
const { data: settings, isLoading: settingsLoading } = useQuery({
queryKey: ['fwd-proxy', 'settings'],
queryFn: async () => {
const r = await apiClient.get('/forward-proxy/settings')
if (!isEnvelope(r.data)) return null
return r.data.data as ProxySettings
},
})
const [settingsForm] = Form.useForm<ProxySettings>()
const saveSettings = useMutation({
mutationFn: async (v: ProxySettings) => {
await apiClient.put('/forward-proxy/settings', v)
},
onSuccess: () => {
message.success(t('common.save'))
void qc.invalidateQueries({ queryKey: ['fwd-proxy', 'settings'] })
},
onError: () => message.error(t('fwd.settings.saveFailed')),
})
const [editing, setEditing] = useState<ACL | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<FormValues>()
@@ -284,6 +317,97 @@ export default function ForwardProxyPage() {
</Card>
)}
<Card
size="small"
className="mb-12"
title={t('fwd.settings.title')}
loading={settingsLoading}
>
<Form
form={settingsForm}
layout="vertical"
initialValues={settings ?? {
listen_addresses: '', listen_port: 3128,
cache_mem_mb: 64, cache_dir_mb: 100, max_obj_size_mb: 4,
connect_timeout: 60, read_timeout: 300, request_timeout: 300,
}}
key={settings?.id ?? 'loading'}
onFinish={(v) => saveSettings.mutate(v)}
>
<Row gutter={16}>
<Col xs={24} md={16}>
<Form.Item
label={t('fwd.settings.listenAddresses')}
name="listen_addresses"
extra={t('fwd.settings.listenAddressesExtra')}
>
<Input placeholder="10.0.5.1, 10.0.20.1" disabled={isViewer} />
</Form.Item>
</Col>
<Col xs={24} md={8}>
<Form.Item
label={t('fwd.settings.listenPort')}
name="listen_port"
extra={t('fwd.settings.listenPortExtra')}
>
<InputNumber min={1} max={65535} style={{ width: '100%' }} disabled={isViewer} />
</Form.Item>
</Col>
</Row>
<Divider plain>{t('fwd.settings.cacheSection')}</Divider>
<Row gutter={16}>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.cacheMemMB')} name="cache_mem_mb" extra={t('fwd.settings.cacheMemMBExtra')}>
<InputNumber min={16} max={8192} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.cacheDirMB')} name="cache_dir_mb" extra={t('fwd.settings.cacheDirMBExtra')}>
<InputNumber min={100} max={102400} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.maxObjSizeMB')} name="max_obj_size_mb" extra={t('fwd.settings.maxObjSizeMBExtra')}>
<InputNumber min={1} max={1024} style={{ width: '100%' }} addonAfter="MB" disabled={isViewer} />
</Form.Item>
</Col>
</Row>
<Divider plain>{t('fwd.settings.timeoutSection')}</Divider>
<Row gutter={16}>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.connectTimeout')} name="connect_timeout" extra={t('fwd.settings.connectTimeoutExtra')}>
<InputNumber min={5} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.readTimeout')} name="read_timeout" extra={t('fwd.settings.readTimeoutExtra')}>
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
</Form.Item>
</Col>
<Col xs={24} sm={8}>
<Form.Item label={t('fwd.settings.requestTimeout')} name="request_timeout" extra={t('fwd.settings.requestTimeoutExtra')}>
<InputNumber min={30} max={3600} style={{ width: '100%' }} addonAfter="s" disabled={isViewer} />
</Form.Item>
</Col>
</Row>
<Tooltip title={isViewer ? t('auth.viewerBadge') : undefined}>
<Button
type="primary"
htmlType="submit"
loading={saveSettings.isPending}
disabled={isViewer}
>
{t('common.save')}
</Button>
</Tooltip>
</Form>
</Card>
<Divider />
<DataTable
rowKey="id"
loading={isLoading}

View File

@@ -193,26 +193,6 @@ export default function IPAddressesPage() {
subtitle={t('ips.intro')}
/>
<Card title={t('ips.systemDiscovered')} size="small" className="mb-12">
{(sysAddrs ?? []).length === 0
? <Typography.Text type="secondary"></Typography.Text>
: (
<DataTable
size="small"
rowKey={(r) => `${r.ifname}-${r.address}`}
dataSource={sysAddrs ?? []}
columns={[
{ title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => <code>{s}</code> },
{ title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => <code>{row.address}/{row.prefix}</code> },
{ title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => <Tag>{f === 'inet' ? 'IPv4' : 'IPv6'}</Tag> },
]}
/>
)
}
</Card>
<Typography.Title level={5} style={{ marginTop: 8 }}>{t('ips.managedTitle')}</Typography.Title>
<DataTable
rowKey="id"
loading={isLoading}
@@ -240,6 +220,24 @@ export default function IPAddressesPage() {
/>
}
/>
<Card title={t('ips.systemDiscovered')} size="small" className="mt-12">
{(sysAddrs ?? []).length === 0
? <Typography.Text type="secondary"></Typography.Text>
: (
<DataTable
size="small"
rowKey={(r) => `${r.ifname}-${r.address}`}
dataSource={sysAddrs ?? []}
columns={[
{ title: t('ips.interface'), dataIndex: 'ifname', key: 'ifname', render: (s: string) => <code>{s}</code> },
{ title: t('ips.address'), key: 'addr', render: (_, row: SystemAddress) => <code>{row.address}/{row.prefix}</code> },
{ title: t('ips.family'), dataIndex: 'family', key: 'family', render: (f: string) => <Tag>{f === 'inet' ? 'IPv4' : 'IPv6'}</Tag> },
]}
/>
)
}
</Card>
<Modal
title={editing ? t('ips.editAddress') : t('ips.addAddress')}
open={editing !== null || creating}

View File

@@ -1,4 +1,6 @@
import { useState } from 'react'
import { Button, Card, Form, Input, message, Typography } from 'antd'
import { KeyOutlined } from '@ant-design/icons'
import { Link, useNavigate } from 'react-router-dom'
import { useTranslation } from 'react-i18next'
@@ -17,20 +19,24 @@ interface LoginValues {
export default function LoginPage({ onLogin }: Props) {
const { t } = useTranslation()
const navigate = useNavigate()
const [totpRequired, setTotpRequired] = useState(false)
const [totpCode, setTotpCode] = useState('')
const [verifying, setVerifying] = useState(false)
const onFinish = async (vals: LoginValues) => {
try {
const r = await apiClient.post('/auth/login', vals)
if (isEnvelope(r.data)) {
const u = r.data.data as SessionUser
onLogin(u)
navigate('/dashboard', { replace: true })
if (!isEnvelope(r.data)) return
const d = r.data.data as { totp_required?: boolean } & SessionUser
if (d.totp_required) {
setTotpRequired(true)
return
}
onLogin(d)
navigate('/dashboard', { replace: true })
} catch (e: unknown) {
const err = e as { message?: string; status?: number }
if (err.status === 503) {
// setup-mode → drop to wizard
navigate('/setup', { replace: true })
return
}
@@ -38,36 +44,73 @@ export default function LoginPage({ onLogin }: Props) {
}
}
const onTOTPVerify = async () => {
if (!totpCode || totpCode.length < 6) return
setVerifying(true)
try {
const r = await apiClient.post('/auth/totp-verify', { code: totpCode })
if (isEnvelope(r.data)) {
onLogin(r.data.data as SessionUser)
navigate('/dashboard', { replace: true })
}
} catch (e: unknown) {
const err = e as { message?: string }
message.error(err.message ?? t('auth.totp.invalidCode'))
setTotpCode('')
} finally {
setVerifying(false)
}
}
return (
<div style={{ display: 'flex', minHeight: '100vh', alignItems: 'center', justifyContent: 'center', background: '#f0f2f5' }}>
<Card style={{ width: 400 }}>
<Typography.Title level={3} style={{ textAlign: 'center', marginBottom: 24 }}>
{t('app.title')}
</Typography.Title>
<Form layout="vertical" onFinish={onFinish}>
<Form.Item
label={t('auth.email')}
name="email"
rules={[{ required: true, type: 'email' }]}
>
<Input autoComplete="email" autoFocus />
</Form.Item>
<Form.Item
label={t('auth.password')}
name="password"
rules={[{ required: true }]}
>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>
{t('auth.login')}
{!totpRequired ? (
<Form layout="vertical" onFinish={onFinish}>
<Form.Item label={t('auth.email')} name="email" rules={[{ required: true, type: 'email' }]}>
<Input autoComplete="email" autoFocus />
</Form.Item>
<Form.Item label={t('auth.password')} name="password" rules={[{ required: true }]}>
<Input.Password autoComplete="current-password" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" block>{t('auth.login')}</Button>
</Form.Item>
</Form>
) : (
<div>
<Typography.Paragraph style={{ textAlign: 'center' }}>
<KeyOutlined style={{ fontSize: 32, color: '#1677ff', marginBottom: 8 }} /><br />
{t('auth.totp.prompt')}
</Typography.Paragraph>
<Input
size="large"
maxLength={6}
placeholder="000000"
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
onPressEnter={onTOTPVerify}
style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, marginBottom: 16 }}
autoFocus
/>
<Button type="primary" block loading={verifying} onClick={onTOTPVerify}>
{t('auth.totp.verify')}
</Button>
</Form.Item>
</Form>
<div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
</div>
<Button type="link" block style={{ marginTop: 8 }} onClick={() => { setTotpRequired(false); setTotpCode('') }}>
{t('common.back')}
</Button>
</div>
)}
{!totpRequired && (
<div style={{ textAlign: 'center', fontSize: 12 }}>
<Link to="/reset-password">{t('auth.forgotPassword')}</Link>
</div>
)}
</Card>
</div>
)

View File

@@ -381,6 +381,7 @@ function SettingsTab() {
for (const i of sys ?? []) {
if (i.ifname === 'lo') continue
for (const a of i.addr_info ?? []) {
if (a.local.startsWith('fe80:')) continue
ipOptions.push({
value: a.local,
label: `${a.local}${i.ifname} (${a.family === 'inet' ? 'IPv4' : 'IPv6'})`,

View File

@@ -39,6 +39,11 @@ interface VIPSettingsValues {
vip_interface?: string
vip_auth_pass?: string
vrrp_router_id?: number
hb_interface?: string
hb_src_ip?: string
hb_peer_ip?: string
hb_router_id?: number
gw_check_ip?: string
}
export default function SettingsPage() {
@@ -839,6 +844,31 @@ export default function SettingsPage() {
extra={t('cluster.vipCard.vrrpRouterIdHelp')}>
<InputNumber min={1} max={255} disabled={isViewer} />
</Form.Item>
<Typography.Text strong style={{ display: 'block', marginBottom: 12, marginTop: 8 }}>
{t('cluster.vipCard.splitBrainSection')}
</Typography.Text>
<Form.Item label={t('cluster.vipCard.hbInterface')} name="hb_interface"
extra={t('cluster.vipCard.hbInterfaceHelp')}>
<Input placeholder="eth1" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.hbSrcIp')} name="hb_src_ip"
extra={t('cluster.vipCard.hbSrcIpHelp')}>
<Input placeholder="192.168.1.1" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.hbPeerIp')} name="hb_peer_ip"
extra={t('cluster.vipCard.hbPeerIpHelp')}>
<Input placeholder="192.168.1.2" disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.hbRouterId')} name="hb_router_id"
extra={t('cluster.vipCard.hbRouterIdHelp')}>
<InputNumber min={1} max={255} disabled={isViewer} />
</Form.Item>
<Form.Item label={t('cluster.vipCard.gwCheckIp')} name="gw_check_ip"
extra={t('cluster.vipCard.gwCheckIpHelp')}>
<Input placeholder="89.163.205.1" disabled={isViewer} />
</Form.Item>
{!isViewer && (
<Form.Item>
<Button type="primary" htmlType="submit" loading={updateVIP.isPending}>

View File

@@ -1,9 +1,9 @@
import { useState } from 'react'
import {
Button, Form, Input, Modal, Select, Space, Switch, Tag, Tooltip, Typography, message,
Button, Form, Input, Modal, QRCode, Select, Space, Switch, Tag, Tooltip, Typography, message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { KeyOutlined, PlusOutlined, TeamOutlined } from '@ant-design/icons'
import { KeyOutlined, LockOutlined, PlusOutlined, SafetyCertificateOutlined, TeamOutlined } from '@ant-design/icons'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
@@ -21,6 +21,7 @@ interface User {
email: string
role: string
active: boolean
totp_enabled: boolean
last_login_at: string | null
created_at: string
}
@@ -45,9 +46,14 @@ export default function UsersPage() {
const { data: users, isLoading } = useQuery({ queryKey: ['users'], queryFn: listUsers })
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState<User | null>(null)
const [pwTarget, setPwTarget] = useState<User | null>(null)
const [creating, setCreating] = useState(false)
const [editing, setEditing] = useState<User | null>(null)
const [pwTarget, setPwTarget] = useState<User | null>(null)
const [totpTarget, setTotpTarget] = useState<User | null>(null)
const [totpStep, setTotpStep] = useState(0)
const [totpSecret, setTotpSecret] = useState('')
const [totpUri, setTotpUri] = useState('')
const [totpCode, setTotpCode] = useState('')
const [createForm] = Form.useForm<CreateValues>()
const [editForm] = Form.useForm<EditValues>()
const [pwForm] = Form.useForm<PwValues>()
@@ -80,6 +86,44 @@ export default function UsersPage() {
onSuccess: invalidate,
onError: (e: Error) => message.error(e.message),
})
const disableTOTPMut = useMutation({
mutationFn: (id: number) => apiClient.delete(`/users/${id}/totp`),
onSuccess: () => { message.success(t('users.totp.disabled')); invalidate() },
onError: (e: Error) => message.error(e.message),
})
const openTOTPSetup = async (row: User) => {
setTotpTarget(row)
setTotpStep(0)
setTotpCode('')
if (row.email === me?.actor) {
// own account — generate secret via self-service endpoint
try {
const r = await apiClient.post('/auth/totp/setup')
if (isEnvelope(r.data)) {
const d = r.data.data as { secret: string; uri: string }
setTotpSecret(d.secret)
setTotpUri(d.uri)
}
} catch (e: unknown) {
message.error((e as Error).message)
setTotpTarget(null)
}
}
}
const confirmTOTP = async () => {
if (!totpTarget) return
try {
await apiClient.post('/auth/totp/confirm', { secret: totpSecret, code: totpCode })
message.success(t('users.totp.enabled'))
setTotpTarget(null)
invalidate()
} catch (e: unknown) {
message.error((e as Error).message ?? t('auth.totp.invalidCode'))
setTotpCode('')
}
}
const roleOptions = [
{ value: 'admin', label: t('users.roleAdmin') },
@@ -104,6 +148,12 @@ export default function UsersPage() {
</Tag>
),
},
{
title: '2FA', key: 'totp', width: 70,
render: (_, row) => row.totp_enabled
? <Tag color="green" icon={<SafetyCertificateOutlined />}>{t('users.totp.on')}</Tag>
: <Tag color="default">{t('users.totp.off')}</Tag>,
},
{
title: t('users.active'), dataIndex: 'active', key: 'active', width: 80,
render: (v: boolean, row: User) => (
@@ -127,7 +177,7 @@ export default function UsersPage() {
: <Text type="secondary" style={{ fontSize: 12 }}>{t('users.never')}</Text>,
},
{
title: t('common.actions'), key: 'actions', width: 120,
title: t('common.actions'), key: 'actions', width: 160,
render: (_, row) => (
<Space size={4}>
<Tooltip title={isViewer ? t('auth.viewerBadge') : t('users.setPassword')}>
@@ -135,6 +185,22 @@ export default function UsersPage() {
disabled={isViewer}
onClick={() => { setPwTarget(row); pwForm.resetFields() }} />
</Tooltip>
{/* 2FA button: setup for own account, disable for others */}
{row.email === me?.actor ? (
<Tooltip title={row.totp_enabled ? t('users.totp.manage') : t('users.totp.setup')}>
<Button type="text" size="small"
icon={<LockOutlined style={{ color: row.totp_enabled ? '#52c41a' : undefined }} />}
onClick={() => void openTOTPSetup(row)} />
</Tooltip>
) : (
row.totp_enabled && !isViewer && (
<Tooltip title={t('users.totp.disableFor', { email: row.email })}>
<Button type="text" size="small" danger icon={<LockOutlined />}
loading={disableTOTPMut.isPending && disableTOTPMut.variables === row.id}
onClick={() => disableTOTPMut.mutate(row.id)} />
</Tooltip>
)
)}
<ActionButtons
onEdit={() => {
setEditing(row)
@@ -150,13 +216,11 @@ export default function UsersPage() {
},
]
const isSelfTOTP = totpTarget?.email === me?.actor
return (
<div>
<PageHeader
icon={<TeamOutlined />}
title={t('users.title')}
subtitle={t('users.intro')}
/>
<PageHeader icon={<TeamOutlined />} title={t('users.title')} subtitle={t('users.intro')} />
<DataTable
rowKey="id"
@@ -195,16 +259,10 @@ export default function UsersPage() {
/>
{/* Create modal */}
<Modal
title={t('users.addUser')}
open={creating}
<Modal title={t('users.addUser')} open={creating}
onCancel={() => { setCreating(false); createForm.resetFields() }}
onOk={() => void createForm.submit()}
confirmLoading={createMut.isPending}
destroyOnHidden
>
<Form form={createForm} layout="vertical"
onFinish={(v) => createMut.mutate(v)}>
onOk={() => void createForm.submit()} confirmLoading={createMut.isPending} destroyOnHidden>
<Form form={createForm} layout="vertical" onFinish={(v) => createMut.mutate(v)}>
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
<Input autoFocus autoComplete="off" />
</Form.Item>
@@ -223,14 +281,9 @@ export default function UsersPage() {
</Modal>
{/* Edit modal */}
<Modal
title={t('users.editUser')}
open={editing !== null}
<Modal title={t('users.editUser')} open={editing !== null}
onCancel={() => { setEditing(null); editForm.resetFields() }}
onOk={() => void editForm.submit()}
confirmLoading={updateMut.isPending}
destroyOnHidden
>
onOk={() => void editForm.submit()} confirmLoading={updateMut.isPending} destroyOnHidden>
<Form form={editForm} layout="vertical"
onFinish={(v) => editing && updateMut.mutate({ id: editing.id, v })}>
<Form.Item label={t('users.email')} name="email" rules={[{ required: true, type: 'email' }]}>
@@ -246,14 +299,10 @@ export default function UsersPage() {
</Modal>
{/* Set password modal */}
<Modal
title={`${t('users.setPasswordTitle')}${pwTarget?.email ?? ''}`}
<Modal title={`${t('users.setPasswordTitle')}${pwTarget?.email ?? ''}`}
open={pwTarget !== null}
onCancel={() => { setPwTarget(null); pwForm.resetFields() }}
onOk={() => void pwForm.submit()}
confirmLoading={pwMut.isPending}
destroyOnHidden
>
onOk={() => void pwForm.submit()} confirmLoading={pwMut.isPending} destroyOnHidden>
<Form form={pwForm} layout="vertical"
onFinish={(v) => pwTarget && pwMut.mutate({ id: pwTarget.id, v })}>
<Form.Item label={t('users.newPassword')} name="password"
@@ -263,6 +312,61 @@ export default function UsersPage() {
</Form.Item>
</Form>
</Modal>
{/* TOTP setup modal (own account only) */}
<Modal
title={isSelfTOTP ? t('users.totp.setupTitle') : t('users.totp.manageTitle')}
open={totpTarget !== null}
onCancel={() => setTotpTarget(null)}
footer={totpStep === 1
? [
<Button key="back" onClick={() => setTotpStep(0)}>{t('common.back')}</Button>,
<Button key="confirm" type="primary" onClick={() => void confirmTOTP()}>{t('users.totp.confirm')}</Button>,
]
: [
<Button key="cancel" onClick={() => setTotpTarget(null)}>{t('common.cancel')}</Button>,
totpTarget?.totp_enabled
? <Button key="disable" danger onClick={() => { if (totpTarget) { disableTOTPMut.mutate(totpTarget.id); setTotpTarget(null) } }}>{t('users.totp.disable')}</Button>
: <Button key="next" type="primary" onClick={() => setTotpStep(1)}>{t('common.next')}</Button>,
]
}
destroyOnHidden
>
{totpStep === 0 && (
<div style={{ textAlign: 'center' }}>
{totpTarget?.totp_enabled ? (
<>
<SafetyCertificateOutlined style={{ fontSize: 48, color: '#52c41a', marginBottom: 16 }} />
<Typography.Paragraph>{t('users.totp.alreadyEnabled')}</Typography.Paragraph>
<Typography.Paragraph type="secondary">{t('users.totp.disableHint')}</Typography.Paragraph>
</>
) : (
<>
<Typography.Paragraph>{t('users.totp.scanHint')}</Typography.Paragraph>
{totpUri && <QRCode value={totpUri} size={200} style={{ margin: '0 auto 16px' }} />}
<Typography.Paragraph type="secondary" copyable={{ text: totpSecret }} style={{ fontFamily: 'monospace', fontSize: 13 }}>
{totpSecret}
</Typography.Paragraph>
</>
)}
</div>
)}
{totpStep === 1 && (
<div style={{ textAlign: 'center' }}>
<Typography.Paragraph>{t('users.totp.enterCode')}</Typography.Paragraph>
<Input
size="large"
maxLength={6}
placeholder="000000"
value={totpCode}
onChange={e => setTotpCode(e.target.value.replace(/\D/g, ''))}
onPressEnter={() => void confirmTOTP()}
style={{ textAlign: 'center', letterSpacing: 8, fontSize: 20, width: 200 }}
autoFocus
/>
</div>
)}
</Modal>
</div>
)
}

View File

@@ -2850,6 +2850,17 @@ h1, h2, h3, h4, h5, h6 {
.fw-rule-row--disabled td:first-child {
opacity: 1 !important;
}
/* Strikethrough on disabled rule name */
.fw-rule-row--disabled .fw-rule-name {
text-decoration: line-through;
text-decoration-color: #94A3B8;
}
/* Zero-hit rule: amber left border (potentially unused/shadowed policy) */
.fw-rule-row--zero-hit td:first-child {
border-left: 3px solid #FAAD14 !important;
padding-left: 5px;
}
/* Enabled status dot */
.fw-rule-dot {
@@ -2869,6 +2880,31 @@ h1, h2, h3, h4, h5, h6 {
line-height: 1;
}
/* Action buttons: hidden by default, reveal on row hover */
.fw-row-actions {
opacity: 0;
transition: opacity 0.12s ease;
}
.ant-table-row:hover .fw-row-actions {
opacity: 1;
}
/* Natural-language rule description */
.fw-rule-desc {
font-size: 11px;
color: #94A3B8;
margin-top: 2px;
font-family: ui-monospace, 'Cascadia Code', monospace;
letter-spacing: 0.01em;
}
/* "any" address label */
.fw-addr-any {
font-size: 11px;
color: #94A3B8;
font-style: italic;
}
/* Filter bar */
.fw-filter-bar {
display: flex;