diff --git a/management-ui/src/App.tsx b/management-ui/src/App.tsx index 19856c6..300ee80 100644 --- a/management-ui/src/App.tsx +++ b/management-ui/src/App.tsx @@ -19,6 +19,7 @@ const RoutingRulesPage = lazy(() => import('./pages/RoutingRules')) const NetworksPage = lazy(() => import('./pages/Networks')) const IPAddressesPage = lazy(() => import('./pages/IPAddresses')) const SSLPage = lazy(() => import('./pages/SSL')) +const FirewallPage = lazy(() => import('./pages/Firewall')) const ClusterPage = lazy(() => import('./pages/Cluster')) const SettingsPage = lazy(() => import('./pages/Settings')) @@ -97,6 +98,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index c7caa29..5623ae3 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -6,6 +6,7 @@ import { ClusterOutlined, DashboardOutlined, DatabaseOutlined, + FireOutlined, GlobalOutlined, NodeIndexOutlined, SafetyCertificateOutlined, @@ -52,6 +53,12 @@ const NAV: NavSection[] = [ { path: '/ssl', labelKey: 'nav.ssl', icon: }, ], }, + { + labelKey: 'nav.section.security', + items: [ + { path: '/firewall', labelKey: 'nav.firewall', icon: }, + ], + }, { labelKey: 'nav.section.system', items: [ diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 229447d..678dced 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -12,16 +12,76 @@ "ipAddresses": "IP-Adressen", "ssl": "SSL-Zertifikate", "vpn": "VPN", - "firewall": "Firewall", + "firewall": "Firewall (v2)", "cluster": "Cluster", "settings": "Einstellungen", "section": { "overview": "Übersicht", "routing": "Routing", "network": "Netzwerk", + "security": "Sicherheit", "system": "System" } }, + "fw": { + "title": "Firewall", + "intro": "Fortigate-Style: Regeln aus Zonen × Adress-Objekten/Gruppen × Services/Service-Gruppen × Action. NAT separat. Top-down, first-match.", + "tabs": { + "rules": "Regeln", + "nat": "NAT", + "addrObj": "Adress-Objekte", + "addrGrp": "Adress-Gruppen", + "services": "Services", + "svcGrp": "Service-Gruppen" + }, + "ao": { + "name": "Name", "kind": "Typ", "value": "Wert", "description": "Beschreibung", + "add": "Adress-Objekt hinzufügen", "edit": "Adress-Objekt bearbeiten", + "deleteConfirm": "Adress-Objekt {{name}} wirklich löschen?" + }, + "ag": { + "name": "Name", "members": "Mitglieder", "description": "Beschreibung", + "add": "Adress-Gruppe hinzufügen", "edit": "Adress-Gruppe bearbeiten", + "selectMembers": "Adress-Objekte wählen", + "deleteConfirm": "Adress-Gruppe {{name}} wirklich löschen?" + }, + "svc": { + "name": "Name", "proto": "Protokoll", "ports": "Ports", + "portStart": "Port (Start)", "portEnd": "Port (Ende)", + "description": "Beschreibung", "builtinHint": "Vordefiniert — nicht editierbar", + "add": "Service hinzufügen", "edit": "Service bearbeiten", + "deleteConfirm": "Service {{name}} wirklich löschen?" + }, + "sg": { + "name": "Name", "members": "Mitglieder", "description": "Beschreibung", + "add": "Service-Gruppe hinzufügen", "edit": "Service-Gruppe bearbeiten", + "selectMembers": "Services wählen", + "deleteConfirm": "Service-Gruppe {{name}} wirklich löschen?" + }, + "rule": { + "name": "Name", "priority": "Priority", "enabled": "Aktiv", "log": "Logging", + "action": "Aktion", "src": "Quelle", "dst": "Ziel", "service": "Service", + "srcZone": "Quell-Zone", "dstZone": "Ziel-Zone", + "srcKind": "Quell-Typ", "dstKind": "Ziel-Typ", + "object": "Adress-Objekt", "group": "Adress-Gruppe", + "serviceKind": "Service-Typ", "serviceGroup": "Service-Gruppe", + "comment": "Kommentar", + "add": "Regel hinzufügen", "edit": "Regel bearbeiten", + "deleteConfirm": "Diese Regel wirklich löschen?" + }, + "nat": { + "name": "Name", "priority": "Priority", "kind": "Typ", "enabled": "Aktiv", + "match": "Match", "target": "Ziel", + "inZone": "Eingangs-Zone", "outZone": "Ausgangs-Zone", "proto": "Protokoll", + "matchSrcCidr": "Source-CIDR (Match)", "matchDstCidr": "Dest-CIDR (Match)", + "matchDstCidrHint": "leer = jede dest-IP (z.B. öffentliche IP der Box)", + "dportStart": "Port (Start)", "dportEnd": "Port (Ende)", + "targetAddr": "Ziel-Adresse", "targetPortStart": "Ziel-Port (Start)", "targetPortEnd": "Ziel-Port (Ende)", + "comment": "Kommentar", + "add": "NAT-Regel hinzufügen", "edit": "NAT-Regel bearbeiten", + "deleteConfirm": "Diese NAT-Regel wirklich löschen?" + } + }, "networks": { "title": "Netzwerk-Interfaces", "intro": "Verwalte WAN-, LAN-, VLAN- und Bond-Interfaces. Read-only-Discovery der Kernel-Interfaces oben; deklarierte Konfiguration unten — runtime-Apply via systemd-networkd folgt in einem späteren Release.", diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 6117962..20ab488 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -19,9 +19,69 @@ "overview": "Overview", "routing": "Routing", "network": "Network", + "security": "Security", "system": "System" } }, + "fw": { + "title": "Firewall", + "intro": "Fortigate-style: rules built from zones × address objects/groups × services/service groups × action. NAT is separate. Top-down, first-match.", + "tabs": { + "rules": "Rules", + "nat": "NAT", + "addrObj": "Address objects", + "addrGrp": "Address groups", + "services": "Services", + "svcGrp": "Service groups" + }, + "ao": { + "name": "Name", "kind": "Kind", "value": "Value", "description": "Description", + "add": "Add address object", "edit": "Edit address object", + "deleteConfirm": "Really delete address object {{name}}?" + }, + "ag": { + "name": "Name", "members": "Members", "description": "Description", + "add": "Add address group", "edit": "Edit address group", + "selectMembers": "Select address objects", + "deleteConfirm": "Really delete address group {{name}}?" + }, + "svc": { + "name": "Name", "proto": "Protocol", "ports": "Ports", + "portStart": "Port (start)", "portEnd": "Port (end)", + "description": "Description", "builtinHint": "Built-in — not editable", + "add": "Add service", "edit": "Edit service", + "deleteConfirm": "Really delete service {{name}}?" + }, + "sg": { + "name": "Name", "members": "Members", "description": "Description", + "add": "Add service group", "edit": "Edit service group", + "selectMembers": "Select services", + "deleteConfirm": "Really delete service group {{name}}?" + }, + "rule": { + "name": "Name", "priority": "Priority", "enabled": "Enabled", "log": "Log", + "action": "Action", "src": "Source", "dst": "Destination", "service": "Service", + "srcZone": "Source zone", "dstZone": "Dest. zone", + "srcKind": "Source kind", "dstKind": "Dest. kind", + "object": "Address object", "group": "Address group", + "serviceKind": "Service kind", "serviceGroup": "Service group", + "comment": "Comment", + "add": "Add rule", "edit": "Edit rule", + "deleteConfirm": "Really delete this rule?" + }, + "nat": { + "name": "Name", "priority": "Priority", "kind": "Kind", "enabled": "Enabled", + "match": "Match", "target": "Target", + "inZone": "Ingress zone", "outZone": "Egress zone", "proto": "Protocol", + "matchSrcCidr": "Source CIDR (match)", "matchDstCidr": "Dest. CIDR (match)", + "matchDstCidrHint": "empty = any dest IP (e.g. box's public IP)", + "dportStart": "Port (start)", "dportEnd": "Port (end)", + "targetAddr": "Target address", "targetPortStart": "Target port (start)", "targetPortEnd": "Target port (end)", + "comment": "Comment", + "add": "Add NAT rule", "edit": "Edit NAT rule", + "deleteConfirm": "Really delete this NAT rule?" + } + }, "networks": { "title": "Network interfaces", "intro": "Manage WAN, LAN, VLAN and bond interfaces. Read-only kernel discovery above; declared configuration below — runtime apply via systemd-networkd lands in a later release.", diff --git a/management-ui/src/pages/Firewall/AddressGroups.tsx b/management-ui/src/pages/Firewall/AddressGroups.tsx new file mode 100644 index 0000000..10c08b7 --- /dev/null +++ b/management-ui/src/pages/Firewall/AddressGroups.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react' +import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' +import type { AddressGroup, AddressObject } from './types' + +interface FormValues { + name: string + description?: string + member_ids?: number[] +} + +async function listGroups(): Promise { + const r = await apiClient.get('/firewall/address-groups') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { address_groups?: AddressGroup[] }).address_groups ?? [] +} +async function listObjects(): Promise { + const r = await apiClient.get('/firewall/address-objects') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { address_objects?: AddressObject[] }).address_objects ?? [] +} + +export default function AddressGroupsTab() { + const { t } = useTranslation() + const qc = useQueryClient() + const { data: groups, isLoading } = useQuery({ queryKey: ['fw', 'addr-grp'], queryFn: listGroups }) + const { data: objects } = useQuery({ queryKey: ['fw', 'addr-obj'], queryFn: listObjects }) + + const objLabel = (id: number) => objects?.find(o => o.id === id)?.name ?? `#${id}` + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const create = useMutation({ + mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/address-groups', v) }, + onSuccess: () => { + message.success(t('common.save')); setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'addr-grp'] }) + }, + }) + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { await apiClient.put(`/firewall/address-groups/${id}`, v) }, + onSuccess: () => { + message.success(t('common.save')); setEditing(null); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'addr-grp'] }) + }, + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/firewall/address-groups/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'addr-grp'] }) }, + }) + + const columns: ColumnsType = [ + { title: t('fw.ag.name'), dataIndex: 'name', key: 'name' }, + { + title: t('fw.ag.members'), key: 'members', + render: (_, row) => ( + + {(row.member_ids ?? []).map((id) => {objLabel(id)})} + {(row.member_ids?.length ?? 0) === 0 && } + + ), + }, + { title: t('fw.ag.description'), dataIndex: 'description', key: 'desc', render: (v?: string) => v ?? '—' }, + { + title: t('common.edit'), key: 'actions', + render: (_, row) => ( + + + del.mutate(row.id)}> + + + + ), + }, + ] + + return ( + <> + + + { setEditing(null); setCreating(false) }} + onOk={() => { void form.submit() }} + confirmLoading={create.isPending || update.isPending} + > +
{ if (editing) update.mutate({ id: editing.id, v }); else create.mutate(v) }} + > + + + + + + + +
+ + ) +} diff --git a/management-ui/src/pages/Firewall/AddressObjects.tsx b/management-ui/src/pages/Firewall/AddressObjects.tsx new file mode 100644 index 0000000..0c918cd --- /dev/null +++ b/management-ui/src/pages/Firewall/AddressObjects.tsx @@ -0,0 +1,120 @@ +import { useState } from 'react' +import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' +import type { AddressObject } from './types' + +interface FormValues { + name: string + kind: AddressObject['kind'] + value: string + description?: string +} + +async function listAO(): Promise { + const r = await apiClient.get('/firewall/address-objects') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { address_objects?: AddressObject[] }).address_objects ?? [] +} + +const KIND_PLACEHOLDER: Record = { + host: '192.0.2.10', + network: '10.0.0.0/24', + range: '10.0.0.10-10.0.0.50', + fqdn: 'webhook.example.com', +} + +export default function AddressObjectsTab() { + const { t } = useTranslation() + const qc = useQueryClient() + const { data, isLoading } = useQuery({ queryKey: ['fw', 'addr-obj'], queryFn: listAO }) + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const create = useMutation({ + mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/address-objects', v) }, + onSuccess: () => { + message.success(t('common.save')); setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'addr-obj'] }) + }, + }) + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { await apiClient.put(`/firewall/address-objects/${id}`, v) }, + onSuccess: () => { + message.success(t('common.save')); setEditing(null); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'addr-obj'] }) + }, + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/firewall/address-objects/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'addr-obj'] }) }, + }) + + const columns: ColumnsType = [ + { title: t('fw.ao.name'), dataIndex: 'name', key: 'name' }, + { title: t('fw.ao.kind'), dataIndex: 'kind', key: 'kind', render: (k: string) => {k} }, + { title: t('fw.ao.value'), dataIndex: 'value', key: 'value', render: (v: string) => {v} }, + { title: t('fw.ao.description'), dataIndex: 'description', key: 'desc', render: (v?: string) => v ?? '—' }, + { + title: t('common.edit'), key: 'actions', + render: (_, row) => ( + + + del.mutate(row.id)}> + + + + ), + }, + ] + + return ( + <> + +
+ { setEditing(null); setCreating(false) }} + onOk={() => { void form.submit() }} + confirmLoading={create.isPending || update.isPending} + > +
{ if (editing) update.mutate({ id: editing.id, v }); else create.mutate(v) }} + > + + + + + + + )} + + + + + +
+ + ) +} diff --git a/management-ui/src/pages/Firewall/NATRules.tsx b/management-ui/src/pages/Firewall/NATRules.tsx new file mode 100644 index 0000000..800082c --- /dev/null +++ b/management-ui/src/pages/Firewall/NATRules.tsx @@ -0,0 +1,225 @@ +import { useState } from 'react' +import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' +import type { NATRule } from './types' + +interface FormValues { + name?: string + priority: number + enabled: boolean + kind: NATRule['kind'] + in_zone?: string + out_zone?: string + proto?: 'tcp' | 'udp' | 'any' + match_src_cidr?: string + match_dst_cidr?: string + match_dport_start?: number + match_dport_end?: number + target_addr?: string + target_port_start?: number + target_port_end?: number + comment?: string +} + +const ZONES_FOR_NAT = ['wan', 'lan', 'dmz', 'mgmt', 'cluster'] as const + +async function listNAT(): Promise { + const r = await apiClient.get('/firewall/nat-rules') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { nat_rules?: NATRule[] }).nat_rules ?? [] +} + +const KIND_COLORS: Record = { + dnat: 'blue', + snat: 'purple', + masquerade: 'gold', +} + +export default function NATRulesTab() { + const { t } = useTranslation() + const qc = useQueryClient() + const { data, isLoading } = useQuery({ queryKey: ['fw', 'nat'], queryFn: listNAT }) + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const create = useMutation({ + mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/nat-rules', v) }, + onSuccess: () => { + message.success(t('common.save')); setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) + }, + }) + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { await apiClient.put(`/firewall/nat-rules/${id}`, v) }, + onSuccess: () => { + message.success(t('common.save')); setEditing(null); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) + }, + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/firewall/nat-rules/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'nat'] }) }, + }) + + const renderTarget = (r: NATRule) => { + if (r.kind === 'masquerade') return {r.out_zone ?? '?'}-iface IP + if (!r.target_addr) return '—' + return {r.target_addr}{r.target_port_start ? `:${r.target_port_start}${r.target_port_end !== r.target_port_start ? `-${r.target_port_end}` : ''}` : ''} + } + + const columns: ColumnsType = [ + { title: '#', dataIndex: 'priority', key: 'priority', width: 70 }, + { title: t('fw.nat.kind'), dataIndex: 'kind', key: 'kind', render: (k: NATRule['kind']) => {k.toUpperCase()} }, + { + title: t('fw.nat.match'), key: 'match', + render: (_, r) => ( + + {r.in_zone && in:{r.in_zone}} + {r.out_zone && out:{r.out_zone}} + {r.proto && {r.proto}} + {r.match_src_cidr && src={r.match_src_cidr}} + {r.match_dst_cidr && dst={r.match_dst_cidr}} + {r.match_dport_start && dport={r.match_dport_start}{r.match_dport_end !== r.match_dport_start ? `-${r.match_dport_end}` : ''}} + + ), + }, + { title: t('fw.nat.target'), key: 'target', render: (_, r) => renderTarget(r) }, + { title: t('fw.nat.enabled'), dataIndex: 'enabled', key: 'enabled', render: (v: boolean) => v ? '✓' : '—' }, + { + title: t('common.edit'), key: 'actions', + render: (_, row) => ( + + + del.mutate(row.id)}> + + + + ), + }, + ] + + return ( + <> + +
+ { setEditing(null); setCreating(false) }} + onOk={() => { void form.submit() }} + confirmLoading={create.isPending || update.isPending} + width={560} + > +
{ if (editing) update.mutate({ id: editing.id, v }); else create.mutate(v) }} + > + + + + + + + + + ({ value: z, label: z }))} /> + + )} + {(kind === 'snat' || kind === 'masquerade') && ( + + ({ value: p, label: p }))} /> + + + + + {kind === 'dnat' && ( + <> + + + + + + + + + + + + + )} + {kind !== 'masquerade' && ( + <> + + + + {kind === 'dnat' && ( + + + + + + + + + )} + + )} + + ) + }} + + + + + + +
+ + ) +} diff --git a/management-ui/src/pages/Firewall/Rules.tsx b/management-ui/src/pages/Firewall/Rules.tsx new file mode 100644 index 0000000..a39e2f9 --- /dev/null +++ b/management-ui/src/pages/Firewall/Rules.tsx @@ -0,0 +1,299 @@ +import { useState } from 'react' +import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Table, Tag, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' +import type { AddressGroup, AddressObject, FwRule, FwService, ServiceGroup, Zone } from './types' +import { ZONES } from './types' + +interface FormValues { + name?: string + priority: number + enabled: boolean + action: FwRule['action'] + src_zone: Zone + src_kind: 'object' | 'group' | 'cidr' | 'any' + src_address_object_id?: number + src_address_group_id?: number + src_cidr?: string + dst_zone: Zone + dst_kind: 'object' | 'group' | 'cidr' | 'any' + dst_address_object_id?: number + dst_address_group_id?: number + dst_cidr?: string + service_kind: 'object' | 'group' | 'any' + service_object_id?: number + service_group_id?: number + log: boolean + comment?: string +} + +const ACTION_COLORS: Record = { + accept: 'green', + drop: 'red', + reject: 'orange', +} + +async function listRules(): Promise { + const r = await apiClient.get('/firewall/rules') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { rules?: FwRule[] }).rules ?? [] +} +async function listAO(): Promise { + const r = await apiClient.get('/firewall/address-objects') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { address_objects?: AddressObject[] }).address_objects ?? [] +} +async function listAG(): Promise { + const r = await apiClient.get('/firewall/address-groups') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { address_groups?: AddressGroup[] }).address_groups ?? [] +} +async function listSv(): Promise { + const r = await apiClient.get('/firewall/services') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { services?: FwService[] }).services ?? [] +} +async function listSG(): Promise { + const r = await apiClient.get('/firewall/service-groups') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { service_groups?: ServiceGroup[] }).service_groups ?? [] +} + +function buildPayload(v: FormValues) { + const out: Partial = { + name: v.name, priority: v.priority, enabled: v.enabled, action: v.action, + src_zone: v.src_zone, dst_zone: v.dst_zone, log: v.log, comment: v.comment, + src_address_object_id: null, src_address_group_id: null, src_cidr: null, + dst_address_object_id: null, dst_address_group_id: null, dst_cidr: null, + service_object_id: null, service_group_id: null, + } + if (v.src_kind === 'object') out.src_address_object_id = v.src_address_object_id ?? null + if (v.src_kind === 'group') out.src_address_group_id = v.src_address_group_id ?? null + if (v.src_kind === 'cidr') out.src_cidr = v.src_cidr ?? null + if (v.dst_kind === 'object') out.dst_address_object_id = v.dst_address_object_id ?? null + if (v.dst_kind === 'group') out.dst_address_group_id = v.dst_address_group_id ?? null + if (v.dst_kind === 'cidr') out.dst_cidr = v.dst_cidr ?? null + if (v.service_kind === 'object') out.service_object_id = v.service_object_id ?? null + if (v.service_kind === 'group') out.service_group_id = v.service_group_id ?? null + return out +} + +export default function RulesTab() { + const { t } = useTranslation() + const qc = useQueryClient() + 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 }) + const { data: svs } = useQuery({ queryKey: ['fw', 'svc'], queryFn: listSv }) + const { data: sgs } = useQuery({ queryKey: ['fw', 'svc-grp'], queryFn: listSG }) + + const aoLabel = (id?: number | null) => aos?.find(o => o.id === id)?.name ?? `#${id}` + const agLabel = (id?: number | null) => ags?.find(g => g.id === id)?.name ?? `#${id}` + 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 renderService = (objID?: number | null, grpID?: number | null) => { + if (objID) return {svLabel(objID)} + if (grpID) return grp:{sgLabel(grpID)} + return any + } + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const create = useMutation({ + mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/rules', buildPayload(v)) }, + onSuccess: () => { + message.success(t('common.save')); setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) + }, + }) + const update = useMutation({ + 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'] }) + }, + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/firewall/rules/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'rules'] }) }, + }) + + const editFromRow = (r: FwRule) => { + setEditing(r) + form.setFieldsValue({ + name: r.name ?? undefined, + priority: r.priority, enabled: r.enabled, action: r.action, + src_zone: r.src_zone, dst_zone: r.dst_zone, log: r.log, comment: r.comment ?? undefined, + src_kind: r.src_address_object_id ? 'object' : r.src_address_group_id ? 'group' : r.src_cidr ? 'cidr' : 'any', + src_address_object_id: r.src_address_object_id ?? undefined, + src_address_group_id: r.src_address_group_id ?? undefined, + src_cidr: r.src_cidr ?? undefined, + dst_kind: r.dst_address_object_id ? 'object' : r.dst_address_group_id ? 'group' : r.dst_cidr ? 'cidr' : 'any', + dst_address_object_id: r.dst_address_object_id ?? undefined, + dst_address_group_id: r.dst_address_group_id ?? undefined, + dst_cidr: r.dst_cidr ?? undefined, + service_kind: r.service_object_id ? 'object' : r.service_group_id ? 'group' : 'any', + service_object_id: r.service_object_id ?? undefined, + service_group_id: r.service_group_id ?? undefined, + }) + } + + 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: 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)}, + }, + { + 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)}, + }, + { + title: t('fw.rule.service'), key: 'svc', + render: (_, r) => renderService(r.service_object_id, r.service_group_id), + }, + { title: t('fw.rule.enabled'), dataIndex: 'enabled', key: 'enabled', render: (v: boolean) => v ? '✓' : '—' }, + { title: t('fw.rule.name'), dataIndex: 'name', key: 'name', render: (v?: string) => v ?? '—' }, + { + title: t('common.edit'), key: 'actions', + render: (_, row) => ( + + + del.mutate(row.id)}> + + + + ), + }, + ] + + return ( + <> + +
+ { setEditing(null); setCreating(false) }} + onOk={() => { void form.submit() }} + confirmLoading={create.isPending || update.isPending} + width={620} + > +
{ if (editing) update.mutate({ id: editing.id, v }); else create.mutate(v) }} + > + + + + + + + + + + ({ value: z, label: z }))} /> + + + ({ value: o.id, label: `${o.name} (${o.kind}: ${o.value})` }))} /> + + } + if (k === 'group') { + return + + + } + return null + }} + + + ))} + + + + ({ + value: s.id, + label: `${s.name} (${s.proto}${s.port_start ? ' '+s.port_start : ''})`, + }))} /> + + } + if (k === 'group') { + return + + + +
+ + ) +} diff --git a/management-ui/src/pages/Firewall/ServiceGroups.tsx b/management-ui/src/pages/Firewall/ServiceGroups.tsx new file mode 100644 index 0000000..6367b38 --- /dev/null +++ b/management-ui/src/pages/Firewall/ServiceGroups.tsx @@ -0,0 +1,129 @@ +import { useState } from 'react' +import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' +import type { FwService, ServiceGroup } from './types' + +interface FormValues { + name: string + description?: string + member_ids?: number[] +} + +async function listGroups(): Promise { + const r = await apiClient.get('/firewall/service-groups') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { service_groups?: ServiceGroup[] }).service_groups ?? [] +} +async function listServices(): Promise { + const r = await apiClient.get('/firewall/services') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { services?: FwService[] }).services ?? [] +} + +export default function ServiceGroupsTab() { + const { t } = useTranslation() + const qc = useQueryClient() + const { data: groups, isLoading } = useQuery({ queryKey: ['fw', 'svc-grp'], queryFn: listGroups }) + const { data: services } = useQuery({ queryKey: ['fw', 'svc'], queryFn: listServices }) + + const svcLabel = (id: number) => services?.find(s => s.id === id)?.name ?? `#${id}` + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const create = useMutation({ + mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/service-groups', v) }, + onSuccess: () => { + message.success(t('common.save')); setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'svc-grp'] }) + }, + }) + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { await apiClient.put(`/firewall/service-groups/${id}`, v) }, + onSuccess: () => { + message.success(t('common.save')); setEditing(null); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'svc-grp'] }) + }, + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/firewall/service-groups/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'svc-grp'] }) }, + }) + + const columns: ColumnsType = [ + { title: t('fw.sg.name'), dataIndex: 'name', key: 'name' }, + { + title: t('fw.sg.members'), key: 'members', + render: (_, row) => ( + + {(row.member_ids ?? []).map((id) => {svcLabel(id)})} + {(row.member_ids?.length ?? 0) === 0 && } + + ), + }, + { title: t('fw.sg.description'), dataIndex: 'description', key: 'desc', render: (v?: string) => v ?? '—' }, + { + title: t('common.edit'), key: 'actions', + render: (_, row) => ( + + + del.mutate(row.id)}> + + + + ), + }, + ] + + return ( + <> + +
+ { setEditing(null); setCreating(false) }} + onOk={() => { void form.submit() }} + confirmLoading={create.isPending || update.isPending} + > +
{ if (editing) update.mutate({ id: editing.id, v }); else create.mutate(v) }} + > + + + + + + + +
+ + ) +} diff --git a/management-ui/src/pages/Firewall/Services.tsx b/management-ui/src/pages/Firewall/Services.tsx new file mode 100644 index 0000000..be24c3f --- /dev/null +++ b/management-ui/src/pages/Firewall/Services.tsx @@ -0,0 +1,144 @@ +import { useState } from 'react' +import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Table, Tag, Tooltip, message } from 'antd' +import type { ColumnsType } from 'antd/es/table' +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useTranslation } from 'react-i18next' + +import apiClient, { isEnvelope } from '../../api/client' +import type { FwService } from './types' + +interface FormValues { + name: string + proto: FwService['proto'] + port_start?: number + port_end?: number + description?: string +} + +async function listServices(): Promise { + const r = await apiClient.get('/firewall/services') + if (!isEnvelope(r.data)) return [] + return (r.data.data as { services?: FwService[] }).services ?? [] +} + +export default function ServicesTab() { + const { t } = useTranslation() + const qc = useQueryClient() + const { data, isLoading } = useQuery({ queryKey: ['fw', 'svc'], queryFn: listServices }) + + const [editing, setEditing] = useState(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + const create = useMutation({ + mutationFn: async (v: FormValues) => { await apiClient.post('/firewall/services', v) }, + onSuccess: () => { + message.success(t('common.save')); setCreating(false); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'svc'] }) + }, + }) + const update = useMutation({ + mutationFn: async ({ id, v }: { id: number; v: FormValues }) => { await apiClient.put(`/firewall/services/${id}`, v) }, + onSuccess: () => { + message.success(t('common.save')); setEditing(null); form.resetFields() + void qc.invalidateQueries({ queryKey: ['fw', 'svc'] }) + }, + }) + const del = useMutation({ + mutationFn: async (id: number) => { await apiClient.delete(`/firewall/services/${id}`) }, + onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw', 'svc'] }) }, + }) + + const columns: ColumnsType = [ + { + title: t('fw.svc.name'), dataIndex: 'name', key: 'name', + render: (s: string, row) => {s}{row.builtin && builtin}, + }, + { title: t('fw.svc.proto'), dataIndex: 'proto', key: 'proto', render: (p: string) => {p} }, + { + title: t('fw.svc.ports'), key: 'ports', + render: (_, row) => row.proto === 'tcp' || row.proto === 'udp' + ? {row.port_start === row.port_end ? row.port_start : `${row.port_start}-${row.port_end}`} + : '—', + }, + { title: t('fw.svc.description'), dataIndex: 'description', key: 'desc', render: (v?: string) => v ?? '—' }, + { + title: t('common.edit'), key: 'actions', + render: (_, row) => ( + + + del.mutate(row.id)} + disabled={row.builtin} + > + + + + ), + }, + ] + + return ( + <> + +
+ { setEditing(null); setCreating(false) }} + onOk={() => { void form.submit() }} + confirmLoading={create.isPending || update.isPending} + > +
{ + // For non-tcp/udp protocols, leave ports empty + const cleaned = (v.proto === 'tcp' || v.proto === 'udp') ? v : { ...v, port_start: undefined, port_end: undefined } + if (editing) update.mutate({ id: editing.id, v: cleaned }); else create.mutate(cleaned) + }} + > + + + + + + + +
+ + ) +} diff --git a/management-ui/src/pages/Firewall/index.tsx b/management-ui/src/pages/Firewall/index.tsx new file mode 100644 index 0000000..d12ee4c --- /dev/null +++ b/management-ui/src/pages/Firewall/index.tsx @@ -0,0 +1,30 @@ +import { Tabs, Typography } from 'antd' +import { useTranslation } from 'react-i18next' + +import AddressObjectsTab from './AddressObjects' +import AddressGroupsTab from './AddressGroups' +import ServicesTab from './Services' +import ServiceGroupsTab from './ServiceGroups' +import RulesTab from './Rules' +import NATRulesTab from './NATRules' + +export default function FirewallPage() { + const { t } = useTranslation() + + const tabs = [ + { key: 'rules', label: t('fw.tabs.rules'), children: }, + { key: 'nat', label: t('fw.tabs.nat'), children: }, + { key: 'addrObj', label: t('fw.tabs.addrObj'), children: }, + { key: 'addrGrp', label: t('fw.tabs.addrGrp'), children: }, + { key: 'services', label: t('fw.tabs.services'), children: }, + { key: 'svcGrp', label: t('fw.tabs.svcGrp'), children: }, + ] + + return ( +
+ {t('fw.title')} + {t('fw.intro')} + +
+ ) +} diff --git a/management-ui/src/pages/Firewall/types.ts b/management-ui/src/pages/Firewall/types.ts new file mode 100644 index 0000000..9ac459c --- /dev/null +++ b/management-ui/src/pages/Firewall/types.ts @@ -0,0 +1,80 @@ +// Shared types for the Firewall page tabs. + +export interface AddressObject { + id: number + name: string + kind: 'host' | 'network' | 'range' | 'fqdn' + value: string + description?: string | null + created_at: string + updated_at: string +} + +export interface AddressGroup { + id: number + name: string + description?: string | null + member_ids?: number[] + created_at: string + updated_at: string +} + +export interface FwService { + id: number + name: string + proto: 'tcp' | 'udp' | 'icmp' | 'icmpv6' | 'any' + port_start?: number | null + port_end?: number | null + builtin: boolean + description?: string | null +} + +export interface ServiceGroup { + id: number + name: string + description?: string | null + member_ids?: number[] +} + +export type Zone = 'wan' | 'lan' | 'dmz' | 'mgmt' | 'cluster' | 'any' + +export interface FwRule { + id: number + name?: string | null + priority: number + enabled: boolean + action: 'accept' | 'drop' | 'reject' + src_zone: Zone + src_address_object_id?: number | null + src_address_group_id?: number | null + src_cidr?: string | null + dst_zone: Zone + dst_address_object_id?: number | null + dst_address_group_id?: number | null + dst_cidr?: string | null + service_object_id?: number | null + service_group_id?: number | null + log: boolean + comment?: string | null +} + +export interface NATRule { + id: number + name?: string | null + priority: number + enabled: boolean + kind: 'dnat' | 'snat' | 'masquerade' + in_zone?: string | null + out_zone?: string | null + proto?: 'tcp' | 'udp' | 'any' | null + match_src_cidr?: string | null + match_dst_cidr?: string | null + match_dport_start?: number | null + match_dport_end?: number | null + target_addr?: string | null + target_port_start?: number | null + target_port_end?: number | null + comment?: string | null +} + +export const ZONES: Zone[] = ['any', 'wan', 'lan', 'dmz', 'mgmt', 'cluster']