feat: Networks-Members für bridge/bond + System-Rules-Card + Theme-Revert

* Migration 0011: members JSONB für network_interfaces. Bridge/bond
  brauchen ≥1 Member (NOT VALID-Constraint, schont bestehende Rows).
  vlan/wireguard/ethernet ignorieren das Feld.
* Backend-Validation pro Typ: vlan→parent+vlan_id, bridge/bond→members,
  ethernet/wireguard→keins. Repo serialisiert via JSONB.
* Form Networks: Members-Multi-Select für bridge/bond, Composition-
  Spalte zeigt vlan-tag bzw. Member-Liste.
* Firewall-Rules-Tab zeigt jetzt SystemRulesCard ganz oben — Anti-
  Lockout (SSH/443), stateful baseline, default-deny-Erklärung.
* Theme-Tokens 1:1 mail-gateway: fontSize 13, controlHeight 34
  (vorher zu dichtes 12/28). Density kommt vom DataTable size="small".
* Makefile publish-amd64 lädt jetzt auch edgeguard-ui_*_all.deb und
  edgeguard_*_all.deb hoch (vorher nur api).
* Version 1.0.0 → 1.0.3.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-10 16:19:07 +02:00
parent 0de0a1580a
commit aa14b6b2be
19 changed files with 278 additions and 45 deletions

View File

@@ -1,7 +1,7 @@
{
"name": "edgeguard-management-ui",
"private": true,
"version": "1.0.0",
"version": "1.0.3",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -29,9 +29,9 @@ const queryClient = new QueryClient({
},
})
// Theme tokens 1:1 wie mail-gateway/enconf — colorPrimary, font,
// borderRadius, controlHeight. enterprise.css ergänzt mit eigenen
// Layout-Klassen (.app-layout, .sidebar, .header, …).
// Theme tokens 1:1 wie mail-gateway/enconf — Body-Density bekommen
// wir über AntD `size="small"` auf den Tables (DataTable hat das per
// default), nicht über kleinere Theme-Tokens.
const antdTheme = {
token: {
colorPrimary: '#0EA5E9',

View File

@@ -109,6 +109,7 @@ export default function DataTable<T extends object>(
</div>
)}
<Table<T>
size="small"
{...rest}
dataSource={filtered}
columns={enhancedCols}

View File

@@ -68,7 +68,7 @@ const NAV: NavSection[] = [
},
]
const VERSION = '1.0.0'
const VERSION = '1.0.3'
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
const { t } = useTranslation()

View File

@@ -80,6 +80,16 @@
"comment": "Kommentar",
"add": "NAT-Regel hinzufügen", "edit": "NAT-Regel bearbeiten",
"deleteConfirm": "Diese NAT-Regel wirklich löschen?"
},
"sys": {
"title": "System-Regeln (immer aktiv)",
"chain": "Chain", "match": "Match", "action": "Aktion", "note": "Hinweis",
"policy": "Default-Policy",
"policyValue": "Eingang DROP — alles muss explizit erlaubt werden.",
"order": "Auswertung",
"orderValue": "System-Regeln zuerst, danach Operator-Regeln top-down (priority asc, first-match).",
"lockout": "Anti-Lockout",
"lockoutValue": "SSH (22) und Management-UI (443) sind immer erreichbar — können auch vom Operator nicht versehentlich gesperrt werden."
}
},
"networks": {
@@ -91,8 +101,15 @@
"name": "Name",
"type": "Typ",
"parent": "Parent-Interface",
"selectParent": "Parent wählen",
"vlan": "VLAN",
"vlanId": "VLAN-ID",
"composition": "Zusammensetzung",
"members": "Member-Interfaces",
"selectMembers": "Physische Interfaces wählen",
"membersRequired": "Mindestens ein Member-Interface erforderlich",
"membersHintBridge": "Eine Bridge bündelt mehrere physische Ports auf L2 — typisch zwei Ports für einen Software-Switch.",
"membersHintBond": "Ein Bond aggregiert mehrere physische Ports zu einem logischen Link (LACP / active-backup).",
"role": "Rolle",
"mtu": "MTU",
"active": "Aktiv",

View File

@@ -80,6 +80,16 @@
"comment": "Comment",
"add": "Add NAT rule", "edit": "Edit NAT rule",
"deleteConfirm": "Really delete this NAT rule?"
},
"sys": {
"title": "System rules (always active)",
"chain": "Chain", "match": "Match", "action": "Action", "note": "Note",
"policy": "Default policy",
"policyValue": "Input DROP — everything must be explicitly allowed.",
"order": "Evaluation",
"orderValue": "System rules first, then operator rules top-down (priority asc, first-match).",
"lockout": "Anti-lockout",
"lockoutValue": "SSH (22) and the management UI (443) are always reachable — even the operator can't accidentally lock themselves out."
}
},
"networks": {
@@ -94,6 +104,12 @@
"selectParent": "Select parent",
"vlan": "VLAN",
"vlanId": "VLAN ID",
"composition": "Composition",
"members": "Member interfaces",
"selectMembers": "Select physical interfaces",
"membersRequired": "At least one member interface is required",
"membersHintBridge": "A bridge joins multiple physical ports at L2 — typically two ports for a software switch.",
"membersHintBond": "A bond aggregates multiple physical ports into one logical link (LACP / active-backup).",
"role": "Role",
"mtu": "MTU",
"active": "Active",

View File

@@ -4,6 +4,7 @@ import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
import DataTable from '../../components/DataTable'
import SystemRulesCard from './SystemRules'
import apiClient, { isEnvelope } from '../../api/client'
import type { AddressGroup, AddressObject, FwRule, FwService, ServiceGroup, Zone } from './types'
@@ -186,6 +187,7 @@ export default function RulesTab() {
return (
<>
<SystemRulesCard />
<Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields()
form.setFieldsValue({

View File

@@ -0,0 +1,70 @@
import { Alert, Card, Space, Table, Tag, Typography } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useTranslation } from 'react-i18next'
// SystemRulesCard documents the baseline nftables ruleset that
// EdgeGuard installs unconditionally — anti-lockout, stateful
// session handling, public ingress, cluster mTLS. These rules sit
// in the kernel ruleset BEFORE any operator-defined rule and can
// not be overruled from the UI (they live in the renderer's nft
// template). Showing them here closes the "wait, where does the
// implicit drop come from?"-gap.
interface SystemRule {
key: string
chain: string
match: string
action: string
note?: string
}
const ROWS: SystemRule[] = [
{ key: 'a1', chain: 'input', match: 'tcp dport 22 (rate-limit 10/min)', action: 'accept', note: 'anti-lockout: SSH' },
{ key: 'a2', chain: 'input', match: 'tcp dport 443', action: 'accept', note: 'anti-lockout: Management-UI' },
{ key: 'b1', chain: 'input', match: 'ct state established,related', action: 'accept', note: 'stateful baseline' },
{ key: 'b2', chain: 'input', match: 'ct state invalid', action: 'drop', note: 'stateful baseline' },
{ key: 'b3', chain: 'input', match: 'iif lo', action: 'accept', note: 'loopback' },
{ key: 'c1', chain: 'input', match: 'icmp/icmpv6 (echo, dest-unreach, time-exc.)', action: 'accept', note: 'PMTUD + diag' },
{ key: 'd1', chain: 'input', match: 'tcp dport 80', action: 'accept', note: 'HAProxy ACME + redirect' },
{ key: 'e1', chain: 'input', match: 'tcp dport 8443 ip saddr @peer_ipv4/v6', action: 'accept', note: 'cluster mTLS (peers only)' },
]
const ACTION_COLORS: Record<string, string> = {
accept: 'green', drop: 'red', reject: 'orange',
}
export default function SystemRulesCard() {
const { t } = useTranslation()
const cols: ColumnsType<SystemRule> = [
{ title: t('fw.sys.chain'), dataIndex: 'chain', key: 'chain', width: 80, render: (s: string) => <Tag>{s}</Tag> },
{ title: t('fw.sys.match'), dataIndex: 'match', key: 'match', render: (s: string) => <code>{s}</code> },
{
title: t('fw.sys.action'), dataIndex: 'action', key: 'action', width: 90,
render: (a: string) => <Tag color={ACTION_COLORS[a] ?? 'default'}>{a.toUpperCase()}</Tag>,
},
{ title: t('fw.sys.note'), dataIndex: 'note', key: 'note', render: (v?: string) => v ? <Typography.Text type="secondary">{v}</Typography.Text> : '—' },
]
return (
<Card size="small" title={t('fw.sys.title')} className="mb-12">
<Alert
type="info"
showIcon
className="mb-12"
message={
<Space direction="vertical" size={2}>
<span><b>{t('fw.sys.policy')}:</b> {t('fw.sys.policyValue')}</span>
<span><b>{t('fw.sys.order')}:</b> {t('fw.sys.orderValue')}</span>
<span><b>{t('fw.sys.lockout')}:</b> {t('fw.sys.lockoutValue')}</span>
</Space>
}
/>
<Table
size="small"
rowKey="key"
columns={cols}
dataSource={ROWS}
pagination={false}
/>
</Card>
)
}

View File

@@ -13,6 +13,7 @@ interface NetworkInterface {
type: 'ethernet' | 'vlan' | 'bond' | 'bridge' | 'wireguard'
parent?: string | null
vlan_id?: number | null
members: string[]
role: 'wan' | 'lan' | 'dmz' | 'mgmt' | 'cluster'
mtu?: number | null
active: boolean
@@ -26,6 +27,7 @@ interface IfaceFormValues {
type: NetworkInterface['type']
parent?: string
vlan_id?: number
members?: string[]
role: NetworkInterface['role']
mtu?: number
active: boolean
@@ -92,8 +94,14 @@ export default function NetworksPage() {
{ title: t('networks.name'), dataIndex: 'name', key: 'name', render: (s: string) => <code>{s}</code> },
{ title: t('networks.type'), dataIndex: 'type', key: 'type' },
{
title: t('networks.vlan'), key: 'vlan',
render: (_, row) => row.type === 'vlan' ? <span>{row.parent}.{row.vlan_id}</span> : '—',
title: t('networks.composition'), key: 'composition',
render: (_, row) => {
if (row.type === 'vlan') return <span><code>{row.parent}</code>.{row.vlan_id}</span>
if (row.type === 'bridge' || row.type === 'bond') {
return <Space size={4} wrap>{(row.members ?? []).map((m) => <Tag key={m}>{m}</Tag>)}</Space>
}
return '—'
},
},
{
title: t('networks.role'), dataIndex: 'role', key: 'role',
@@ -109,7 +117,8 @@ export default function NetworksPage() {
setEditing(row)
form.setFieldsValue({
name: row.name, type: row.type, parent: row.parent ?? undefined,
vlan_id: row.vlan_id ?? undefined, role: row.role,
vlan_id: row.vlan_id ?? undefined, members: row.members ?? [],
role: row.role,
mtu: row.mtu ?? undefined, active: row.active,
description: row.description ?? undefined,
})
@@ -183,22 +192,42 @@ export default function NetworksPage() {
]} />
</Form.Item>
<Form.Item noStyle shouldUpdate={(p, c) => p.type !== c.type}>
{({ getFieldValue }) => getFieldValue('type') === 'vlan' ? (
<>
<Form.Item label={t('networks.parent')} name="parent" rules={[{ required: true }]}>
<Select
placeholder={t('networks.selectParent')}
showSearch
options={(sys ?? [])
.filter((i) => i.ifname !== 'lo')
.map((i) => ({ value: i.ifname, label: i.ifname }))}
/>
</Form.Item>
<Form.Item label={t('networks.vlanId')} name="vlan_id" rules={[{ required: true }]}>
<InputNumber min={1} max={4094} style={{ width: '100%' }} />
</Form.Item>
</>
) : null}
{({ getFieldValue }) => {
const tp = getFieldValue('type') as NetworkInterface['type'] | undefined
const sysOptions = (sys ?? [])
.filter((i) => i.ifname !== 'lo')
.map((i) => ({ value: i.ifname, label: i.ifname }))
if (tp === 'vlan') {
return (
<>
<Form.Item label={t('networks.parent')} name="parent" rules={[{ required: true }]}>
<Select placeholder={t('networks.selectParent')} showSearch options={sysOptions} />
</Form.Item>
<Form.Item label={t('networks.vlanId')} name="vlan_id" rules={[{ required: true }]}>
<InputNumber min={1} max={4094} style={{ width: '100%' }} />
</Form.Item>
</>
)
}
if (tp === 'bridge' || tp === 'bond') {
return (
<Form.Item
label={t('networks.members')}
name="members"
rules={[{ required: true, type: 'array', min: 1, message: t('networks.membersRequired') }]}
extra={tp === 'bridge' ? t('networks.membersHintBridge') : t('networks.membersHintBond')}
>
<Select
mode="multiple"
placeholder={t('networks.selectMembers')}
showSearch
options={sysOptions}
/>
</Form.Item>
)
}
return null
}}
</Form.Item>
<Form.Item label={t('networks.role')} name="role" rules={[{ required: true }]}>
<Select options={(['wan','lan','dmz','mgmt','cluster'] as const).map(r => ({ value: r, label: r.toUpperCase() }))} />

View File

@@ -269,7 +269,7 @@ h1, h2, h3, h4, h5, h6 {
/* === CONTENT AREA === */
.content-area {
padding: 16px;
padding: 12px 16px;
flex: 1;
overflow-x: hidden;
min-width: 0;