feat: Zonen als first-class Entity + Domain↔Backend-Verknüpfung sichtbar

* Migration 0012: firewall_zones (id, name UNIQUE, description, builtin),
  Seed wan/lan/dmz/mgmt/cluster als builtin. CHECK-Constraints auf
  network_interfaces.role + firewall_rules.{src,dst}_zone +
  firewall_nat_rules.{in,out}_zone gedroppt — Validation lebt jetzt
  app-side (Handler prüft Existenz in firewall_zones).
* Backend: firewall.ZonesRepo (CRUD + Exists + References-Lookup),
  /api/v1/firewall/zones, builtin geschützt (Name nicht änderbar,
  Delete blockiert), Rename eines Custom-Zone aktuell ohne Cascade
  (Handler-Sorge bei Rules/NAT/Networks).
* Handler-Validation in CreateRule/UpdateRule/CreateNAT/UpdateNAT +
  NetworksHandler: Zone-Existence-Check pro Mutation, 400 bei Tippfehler.
* Frontend: Firewall-Tab "Zonen" (CRUD mit builtin-Schutz). Networks-
  Form lädt Rollen aus /firewall/zones (statt hardcoded Liste); Rules-
  und NAT-Forms ziehen die Zone-Auswahl ebenfalls aus der API.
* Domain-Form bekommt Primary-Backend-Picker (Field war im Modell,
  fehlte im UI). Backends-Tabelle zeigt umgekehrt welche Domains
  darauf zeigen — bidirektionale Sicht ohne Schemaänderung.
* HAProxy-Renderer: safeID-FuncMap escaped Server-Namen mit Whitespace
  ("Control Master 1" → "Control_Master_1"). Vorher ist haproxy beim
  Reload an Spaces im Backend-Namen kaputt gegangen.
* Version 1.0.3 → 1.0.6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Debian
2026-05-10 18:05:27 +02:00
parent aa14b6b2be
commit 51ea1fc802
23 changed files with 782 additions and 37 deletions

View File

@@ -7,8 +7,8 @@ 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'
import { ZONES } from './types'
import type { AddressGroup, AddressObject, FwRule, FwService, FwZone, ServiceGroup, Zone } from './types'
import { ZONES_FALLBACK } from './types'
interface FormValues {
name?: string
@@ -63,6 +63,11 @@ async function listSG(): Promise<ServiceGroup[]> {
if (!isEnvelope(r.data)) return []
return (r.data.data as { service_groups?: ServiceGroup[] }).service_groups ?? []
}
async function listZones(): Promise<FwZone[]> {
const r = await apiClient.get('/firewall/zones')
if (!isEnvelope(r.data)) return []
return (r.data.data as { zones?: FwZone[] }).zones ?? []
}
function buildPayload(v: FormValues) {
const out: Partial<FwRule> = {
@@ -91,6 +96,13 @@ export default function RulesTab() {
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 { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZones })
// 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
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}`
@@ -234,7 +246,7 @@ export default function RulesTab() {
{(['src', 'dst'] as const).map((side) => (
<Space key={side} size="middle" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start' }}>
<Form.Item label={t(`fw.rule.${side}Zone`)} name={`${side}_zone`} rules={[{ required: true }]}>
<Select style={{ width: 120 }} options={ZONES.map(z => ({ value: z, label: z }))} />
<Select style={{ width: 140 }} options={zoneOptions.map(z => ({ value: z, label: z }))} />
</Form.Item>
<Form.Item label={t(`fw.rule.${side}Kind`)} name={`${side}_kind`} rules={[{ required: true }]}>
<Select style={{ width: 120 }} options={(['any','object','group','cidr'] as const).map(k => ({ value: k, label: k }))} />