diff --git a/VERSION b/VERSION index 8b8c523..5c7cd93 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.135 +1.1.136 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index 7135575..1e6557e 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -60,7 +60,7 @@ import ( usersvc "git.netcell-it.de/projekte/edgeguard-native/internal/services/users" ) -var version = "1.1.135" +var version = "1.1.136" func main() { addr := os.Getenv("EDGEGUARD_API_ADDR") diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index df07f21..79611be 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -11,7 +11,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/setup" ) -var version = "1.1.135" +var version = "1.1.136" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index bdaf35d..f1dca79 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -41,7 +41,7 @@ import ( "git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts" ) -var version = "1.1.135" +var version = "1.1.136" const ( // renewTickInterval — how often we re-evaluate expiring certs. diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 56ae180..1ffd4d0 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -112,7 +112,28 @@ "moveUp": "Nach oben (höhere Priorität)", "moveDown": "Nach unten (niedrigere Priorität)", "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." + "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" + }, + "kpi": { + "policyRules": "Policy-Regeln", + "active": "aktiv", + "disabled": "deaktiviert", + "natRules": "NAT-Regeln", + "natHint": "DNAT / SNAT / Masquerade", + "zones": "Zonen", + "zonesHint": "Netzwerksegmente", + "defaultPolicy": "Default-Policy", + "nftActive": "nftables aktiv", + "nftInactive": "nftables inaktiv" + }, + "filter": { + "search": "Name / Kommentar suchen…", + "allActions": "Alle Aktionen", + "allZones": "Alle Zonen", + "noResults": "Keine Regeln entsprechen dem Filter", + "noResultsHint": "Filter zurücksetzen um alle Regeln zu sehen." }, "nat": { "name": "Name", "priority": "Priority", "kind": "Typ", "enabled": "Aktiv", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 3d9b345..169b15b 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -112,7 +112,28 @@ "moveUp": "Move up (higher priority)", "moveDown": "Move down (lower priority)", "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." + "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" + }, + "kpi": { + "policyRules": "Policy Rules", + "active": "active", + "disabled": "disabled", + "natRules": "NAT Rules", + "natHint": "DNAT / SNAT / Masquerade", + "zones": "Zones", + "zonesHint": "Network segments", + "defaultPolicy": "Default Policy", + "nftActive": "nftables active", + "nftInactive": "nftables inactive" + }, + "filter": { + "search": "Search name / comment…", + "allActions": "All actions", + "allZones": "All zones", + "noResults": "No rules match the filter", + "noResultsHint": "Clear the filter to see all rules." }, "nat": { "name": "Name", "priority": "Priority", "kind": "Kind", "enabled": "Enabled", diff --git a/management-ui/src/pages/Firewall/Rules.tsx b/management-ui/src/pages/Firewall/Rules.tsx index 8e129c0..a503cd0 100644 --- a/management-ui/src/pages/Firewall/Rules.tsx +++ b/management-ui/src/pages/Firewall/Rules.tsx @@ -1,9 +1,14 @@ import { useMemo, useState } from 'react' -import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Tooltip, Typography, message } from 'antd' +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, FireOutlined } from '@ant-design/icons' +import { + ArrowDownOutlined, ArrowUpOutlined, EyeOutlined, FireOutlined, PlusOutlined, +} from '@ant-design/icons' const { Text } = Typography @@ -37,12 +42,6 @@ interface FormValues { comment?: string } -const ACTION_COLORS: Record = { - accept: 'green', - drop: 'red', - reject: 'orange', -} - interface RuleCounter { rule_id: number; packets: number; bytes: number } async function listCounters(): Promise { @@ -110,10 +109,28 @@ function buildPayload(v: FormValues) { return out } +// ── Zone badge with semantic colors ────────────────────────────── +const ZONE_CLASSES: Record = { + wan: 'fw-zone-badge--wan', lan: 'fw-zone-badge--lan', + dmz: 'fw-zone-badge--dmz', mgmt: 'fw-zone-badge--mgmt', + cluster: 'fw-zone-badge--cluster', vpn: 'fw-zone-badge--vpn', + any: 'fw-zone-badge--any', +} + +function ZoneBadge({ zone }: { zone: string }) { + const cls = ZONE_CLASSES[zone] ?? 'fw-zone-badge--default' + return {zone} +} + +function ActionBadge({ action }: { action: FwRule['action'] }) { + return {action.toUpperCase()} +} + export default function RulesTab() { const { t } = useTranslation() const qc = useQueryClient() const isViewer = useAuthStore((s) => s.user?.role) === 'viewer' + const { data: rules, isLoading } = useQuery({ queryKey: ['fw', 'rules'], queryFn: listRules }) const { data: aos } = useQuery({ queryKey: ['fw', 'addr-obj'], queryFn: listAO }) const { data: ags } = useQuery({ queryKey: ['fw', 'addr-grp'], queryFn: listAG }) @@ -127,8 +144,6 @@ export default function RulesTab() { }) const counterByID = new Map((counters ?? []).map(c => [c.rule_id, c])) - // Picker options: 'any' (special) + every zone the operator has - // declared. Fallback to the seed list while the query is loading. const zoneOptions: Zone[] = zones && zones.length > 0 ? ['any', ...zones.map((z) => z.name)] : ZONES_FALLBACK @@ -138,22 +153,46 @@ export default function RulesTab() { const svLabel = (id?: number | null) => svs?.find(s => s.id === id)?.name ?? `#${id}` const sgLabel = (id?: number | null) => sgs?.find(g => g.id === id)?.name ?? `#${id}` - const renderSide = (objID?: number | null, grpID?: number | null, cidr?: string | null) => { - if (objID) return obj:{aoLabel(objID)} - if (grpID) return grp:{agLabel(grpID)} - if (cidr) return {cidr} - return any + const renderAddrCompact = (objID?: number | null, grpID?: number | null, cidr?: string | null): string => { + if (objID) return aoLabel(objID) + if (grpID) return `⊂ ${agLabel(grpID)}` + if (cidr) return cidr + return 'any' } const renderService = (objID?: number | null, grpID?: number | null) => { - if (objID) return {svLabel(objID)} - if (grpID) return grp:{sgLabel(grpID)} - return any + if (objID) return {svLabel(objID)} + if (grpID) return ⊂ {sgLabel(grpID)} + return any } + // ── Filter state ───────────────────────────────────────────── + const [searchText, setSearchText] = useState('') + const [filterAction, setFilterAction] = useState('') + const [filterZone, setFilterZone] = useState('') + const [editing, setEditing] = useState(null) const [creating, setCreating] = useState(false) const [form] = Form.useForm() + const sortedRules = useMemo( + () => [...(rules ?? [])].sort((a, b) => a.priority - b.priority), + [rules], + ) + + const filteredRules = useMemo(() => { + let r = sortedRules + if (searchText) { + const q = searchText.toLowerCase() + r = r.filter(rule => + (rule.name ?? '').toLowerCase().includes(q) || + (rule.comment ?? '').toLowerCase().includes(q), + ) + } + if (filterAction) r = r.filter(rule => rule.action === filterAction) + if (filterZone) r = r.filter(rule => rule.src_zone === filterZone || rule.dst_zone === filterZone) + return r + }, [sortedRules, searchText, filterAction, filterZone]) + const create = useMutation({ mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/rules', buildPayload(v)) }, onSuccess: () => { @@ -163,7 +202,9 @@ export default function RulesTab() { onError: (e: Error) => message.error(e.message), }) const update = useMutation({ - mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { await apiClient.put(`/firewall/rules/${id}`, buildPayload(v)) }, + mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { + await apiClient.put(`/firewall/rules/${id}`, buildPayload(v)) + }, onSuccess: () => { message.success(t('common.save')); setEditing(null); form.resetFields() void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) @@ -183,11 +224,6 @@ export default function RulesTab() { onError: (e: Error) => message.error(e.message), }) - const sortedRules = useMemo( - () => [...(rules ?? [])].sort((a, b) => a.priority - b.priority), - [rules], - ) - const swap = useMutation({ mutationFn: async ({ a, b }: { a: FwRule; b: FwRule }) => { await apiClient.put(`/firewall/rules/${a.id}`, { ...a, priority: b.priority }) @@ -218,25 +254,96 @@ export default function RulesTab() { } const columns: ColumnsType = [ - { title: '#', dataIndex: 'priority', key: 'priority', width: 70 }, { - title: t('fw.rule.action'), dataIndex: 'action', key: 'action', - render: (a: FwRule['action']) => {a.toUpperCase()}, + title: '', key: 'dot', width: 28, + render: (_, row) => ( + + + + ), + }, + { + title: '#', dataIndex: 'priority', key: 'priority', width: 52, + render: (v: number) => ( + {v} + ), + }, + { + title: t('fw.rule.action'), key: 'action', width: 90, + render: (_, r) => , }, { title: t('fw.rule.src'), key: 'src', - render: (_, r) => {r.src_zone}{renderSide(r.src_address_object_id, r.src_address_group_id, r.src_cidr)}, + render: (_, r) => ( +
+ + {(r.src_address_object_id || r.src_address_group_id || r.src_cidr) && ( + + {renderAddrCompact(r.src_address_object_id, r.src_address_group_id, r.src_cidr)} + + )} +
+ ), + }, + { + title: '', key: 'arrow', width: 24, align: 'center' as const, + render: () => , }, { title: t('fw.rule.dst'), key: 'dst', - render: (_, r) => {r.dst_zone}{renderSide(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)}, + render: (_, r) => ( +
+ + {(r.dst_address_object_id || r.dst_address_group_id || r.dst_cidr) && ( + + {renderAddrCompact(r.dst_address_object_id, r.dst_address_group_id, r.dst_cidr)} + + )} +
+ ), }, { - title: t('fw.rule.service'), key: 'svc', + title: t('fw.rule.service'), key: 'svc', width: 130, render: (_, r) => renderService(r.service_object_id, r.service_group_id), }, { - title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', width: 80, + title: t('fw.rule.name'), key: 'name', ellipsis: true, + render: (_, r) => ( +
+ {r.name &&
{r.name}
} + {r.comment && ( +
{r.comment}
+ )} + {!r.name && !r.comment && } +
+ ), + }, + { + 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 + return ( + + + {c.packets >= 1_000_000 + ? `${(c.packets / 1_000_000).toFixed(1)}M` + : c.packets >= 1_000 + ? `${(c.packets / 1_000).toFixed(1)}k` + : c.packets.toLocaleString()} + + + ) + }, + }, + { + title: 'Log', key: 'log', width: 38, align: 'center' as const, + render: (_, r) => r.log + ? + : null, + }, + { + title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', width: 68, render: (v: boolean, row: FwRule) => ( ), }, - { title: t('fw.rule.name'), dataIndex: 'name', key: 'name', render: (v?: string) => v ?? '—' }, { - title: t('fw.rule.hits'), key: 'hits', width: 90, - render: (_, r) => { - const c = counterByID.get(r.id) - if (!c) return - return ( - - {c.packets.toLocaleString()} - - ) - }, - }, - { - title: '', key: 'move', width: 64, + title: '', key: 'move', width: 60, render: (_, row) => { const idx = sortedRules.findIndex(r => r.id === row.id) const swapping = swap.isPending return ( - + + + {isViewer ? ( @@ -317,31 +407,78 @@ export default function RulesTab() { }) } + const activeFilters = !!(searchText || filterAction || filterZone) + return ( <> - - - + {/* ── Filter bar ─────────────────────────────────────── */} +
+ setSearchText(e.target.value)} + onSearch={v => setSearchText(v)} + /> + setFilterZone(v ?? '')} + options={zoneOptions.filter(z => z !== 'any').map(z => ({ value: z, label: z }))} + /> + {activeFilters && ( + + {filteredRules.length} / {sortedRules.length} + + )} +
+ + + +
+
+ row.enabled ? '' : 'fw-rule-row--disabled'} emptyContent={ } - title={t('fw.rule.emptyTitle')} - description={t('fw.rule.emptyDesc')} + title={activeFilters ? t('fw.filter.noResults') : t('fw.rule.emptyTitle')} + description={activeFilters ? t('fw.filter.noResultsHint') : t('fw.rule.emptyDesc')} action={ - - - + !activeFilters ? ( + + + + ) : undefined } /> } /> + { void form.submit() }} confirmLoading={create.isPending || update.isPending} width={620} + destroyOnHidden >
{ + try { + const r = await apiClient.get('/firewall/rules') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { rules?: FwRule[] }).rules ?? [] + } catch { return [] } +} +async function listNATCount(): Promise { + try { + const r = await apiClient.get('/firewall/nat-rules') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { nat_rules?: NATRule[] }).nat_rules ?? [] + } catch { return [] } +} +async function listZonesCount(): Promise { + try { + const r = await apiClient.get('/firewall/zones') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { zones?: FwZone[] }).zones ?? [] + } catch { return [] } +} + +function FirewallKPIStrip({ nftablesActive }: { nftablesActive: boolean | undefined }) { + const { t } = useTranslation() + const { data: rules } = useQuery({ queryKey: ['fw', 'rules'], queryFn: listRulesCount, staleTime: 30_000 }) + const { data: natRules } = useQuery({ queryKey: ['fw', 'nat-rules'], queryFn: listNATCount, staleTime: 30_000 }) + const { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZonesCount, staleTime: 30_000 }) + + const totalRules = rules?.length ?? 0 + const activeRules = rules?.filter(r => r.enabled).length ?? 0 + const totalNAT = natRules?.length ?? 0 + const totalZones = zones?.length ?? 0 + + return ( +
+
+
{t('fw.kpi.policyRules')}
+
{totalRules}
+
+ {activeRules} {t('fw.kpi.active')} · {totalRules - activeRules} {t('fw.kpi.disabled')} +
+
+
+
{t('fw.kpi.natRules')}
+
{totalNAT}
+
{t('fw.kpi.natHint')}
+
+
+
{t('fw.kpi.zones')}
+
{totalZones}
+
{t('fw.kpi.zonesHint')}
+
+
+
{t('fw.kpi.defaultPolicy')}
+
INPUT DROP
+
+ {nftablesActive === true && ● {t('fw.kpi.nftActive')}} + {nftablesActive === false && ● {t('fw.kpi.nftInactive')}} + {nftablesActive === undefined && ···} +
+
+
+ ) +} + export default function FirewallPage() { const { t } = useTranslation() @@ -30,7 +96,7 @@ export default function FirewallPage() { const nftables = services?.find(s => s.unit === 'nftables.service' || s.unit === 'nftables') const tabs = [ - { key: 'rules', label: t('fw.tabs.rules'), children: }, + { key: 'rules', label: {t('fw.tabs.rules')}, children: }, { key: 'nat', label: t('fw.tabs.nat'), children: }, { key: 'zones', label: t('fw.tabs.zones'), children: }, { key: 'addrObj', label: t('fw.tabs.addrObj'), children: }, @@ -46,11 +112,7 @@ export default function FirewallPage() { icon={} title={t('fw.title')} subtitle={t('fw.intro')} - /> - : } color={nftables.active ? 'green' : 'red'} @@ -62,6 +124,11 @@ export default function FirewallPage() { )} /> + + ) } diff --git a/management-ui/src/styles/enterprise.css b/management-ui/src/styles/enterprise.css index 41570cc..0e582b5 100644 --- a/management-ui/src/styles/enterprise.css +++ b/management-ui/src/styles/enterprise.css @@ -2764,3 +2764,131 @@ h1, h2, h3, h4, h5, h6 { align-items: center; justify-content: space-between; } + +/* ── Firewall Enterprise Redesign ─────────────────────────────────── */ + +/* KPI strip */ +.fw-kpi-strip { + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 20px; +} +.fw-kpi-card { + flex: 1; + min-width: 150px; + background: #fff; + border: 1px solid #E2E8F0; + border-radius: 8px; + padding: 14px 18px; + display: flex; + flex-direction: column; + gap: 3px; +} +.fw-kpi-card--policy { + border-left: 3px solid #EF4444; +} +.fw-kpi-label { + font-size: 10px; + font-weight: 600; + color: #64748B; + text-transform: uppercase; + letter-spacing: .06em; +} +.fw-kpi-value { + font-size: 26px; + font-weight: 700; + color: #0F172A; + line-height: 1.1; + font-variant-numeric: tabular-nums; +} +.fw-kpi-value--mono { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + color: #B91C1C; + font-weight: 700; + letter-spacing: .03em; +} +.fw-kpi-sub { + font-size: 11px; + color: #94A3B8; +} + +/* Action badge */ +.fw-action-badge { + display: inline-flex; + align-items: center; + font-size: 11px; + font-weight: 700; + letter-spacing: .06em; + padding: 3px 9px; + border-radius: 4px; + font-family: 'JetBrains Mono', monospace; + white-space: nowrap; +} +.fw-action-badge--accept { background: #DCFCE7; color: #15803D; } +.fw-action-badge--drop { background: #FEE2E2; color: #B91C1C; } +.fw-action-badge--reject { background: #FEF3C7; color: #B45309; } + +/* Zone badge */ +.fw-zone-badge { + display: inline-flex; + align-items: center; + font-size: 10px; + font-weight: 600; + letter-spacing: .05em; + padding: 2px 7px; + border-radius: 3px; + text-transform: uppercase; + font-family: 'JetBrains Mono', monospace; + white-space: nowrap; +} +.fw-zone-badge--wan { background: #FEE2E2; color: #B91C1C; border: 1px solid #FECACA; } +.fw-zone-badge--lan { background: #DCFCE7; color: #15803D; border: 1px solid #BBF7D0; } +.fw-zone-badge--dmz { background: #FEF3C7; color: #B45309; border: 1px solid #FDE68A; } +.fw-zone-badge--mgmt { background: #DBEAFE; color: #1D4ED8; border: 1px solid #BFDBFE; } +.fw-zone-badge--cluster { background: #F3E8FF; color: #7E22CE; border: 1px solid #E9D5FF; } +.fw-zone-badge--vpn { background: #E0F2FE; color: #0369A1; border: 1px solid #BAE6FD; } +.fw-zone-badge--any { background: #F1F5F9; color: #475569; border: 1px solid #CBD5E1; } +.fw-zone-badge--default { background: #F1F5F9; color: #475569; border: 1px solid #CBD5E1; } + +/* Rule row */ +.fw-rule-row--disabled td { + opacity: 0.38; +} +.fw-rule-row--disabled td:first-child { + opacity: 1 !important; +} + +/* Enabled status dot */ +.fw-rule-dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; +} +.fw-rule-dot--on { background: #22C55E; box-shadow: 0 0 0 2px #DCFCE7; } +.fw-rule-dot--off { background: #CBD5E1; } + +/* Flow arrow */ +.fw-flow-arrow { + color: #94A3B8; + font-size: 15px; + line-height: 1; +} + +/* Filter bar */ +.fw-filter-bar { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 12px; + align-items: center; +} +.fw-filter-bar-right { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; +}