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

@@ -1 +1 @@
1.0.3
1.0.6

View File

@@ -35,7 +35,7 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
)
var version = "1.0.3"
var version = "1.0.6"
func main() {
addr := os.Getenv("EDGEGUARD_API_ADDR")
@@ -127,6 +127,7 @@ func main() {
ifsRepo := networkifs.New(pool)
ipsRepo := ipaddresses.New(pool)
tlsRepo := tlscerts.New(pool)
fwZones := firewall.NewZonesRepo(pool)
fwAddrObj := firewall.NewAddressObjectsRepo(pool)
fwAddrGrp := firewall.NewAddressGroupsRepo(pool)
fwSvc := firewall.NewServicesRepo(pool)
@@ -147,7 +148,7 @@ func main() {
handlers.NewDomainsHandler(domainsRepo, routingRepo, auditRepo, nodeID).Register(authed)
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID).Register(authed)
handlers.NewRoutingRulesHandler(routingRepo, auditRepo, nodeID).Register(authed)
handlers.NewNetworksHandler(ifsRepo, ipsRepo, auditRepo, nodeID).Register(authed)
handlers.NewNetworksHandler(ifsRepo, ipsRepo, fwZones, auditRepo, nodeID).Register(authed)
handlers.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed)
handlers.NewClusterHandler(clusterStore, nodeID).Register(authed)
handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed)
@@ -156,7 +157,7 @@ func main() {
fwReloader := func(ctx context.Context) error {
return firewallrender.New(pool).Render(ctx)
}
handlers.NewFirewallHandler(fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader).Register(authed)
handlers.NewFirewallHandler(fwZones, fwAddrObj, fwAddrGrp, fwSvc, fwSvcGrp, fwRules, fwNAT, auditRepo, nodeID, fwReloader).Register(authed)
}
mountUI(r)

View File

@@ -9,7 +9,7 @@ import (
"os"
)
var version = "1.0.3"
var version = "1.0.6"
const usage = `edgeguard-ctl — EdgeGuard CLI

View File

@@ -5,7 +5,7 @@ import (
"time"
)
var version = "1.0.3"
var version = "1.0.6"
func main() {
log.Printf("edgeguard-scheduler %s starting", version)

View File

@@ -0,0 +1,48 @@
-- +goose Up
-- +goose StatementBegin
-- firewall_zones promotes zones from a hard-coded enum
-- (wan/lan/dmz/mgmt/cluster) into a first-class entity. Operators
-- can add their own (e.g. iot, guest, voip) without a schema
-- change. Existing role/zone TEXT columns on network_interfaces,
-- firewall_rules and firewall_nat_rules continue to store the
-- zone NAME — referential integrity is enforced at the application
-- layer (handler validates name exists in firewall_zones), not by
-- a hard FK, so 'any' on rules and NULL on NAT keep working
-- without special-casing.
--
-- builtin = TRUE marks the seed zones; the API rejects DELETE on
-- those rows to prevent the operator from removing a zone the
-- renderer still expects.
CREATE TABLE IF NOT EXISTS firewall_zones (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE,
description TEXT,
builtin BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT firewall_zones_name_check CHECK (name ~ '^[a-z][a-z0-9_-]{0,31}$')
);
INSERT INTO firewall_zones (name, description, builtin) VALUES
('wan', 'Public-facing internet uplink', TRUE),
('lan', 'Internal trusted network', TRUE),
('dmz', 'Quarantined service network', TRUE),
('mgmt', 'Admin-only management network', TRUE),
('cluster', 'Inter-node cluster traffic (KeyDB / mTLS API)', TRUE)
ON CONFLICT (name) DO NOTHING;
-- Drop the hard-coded CHECK constraints so the operator can declare
-- new zones without the SQL layer rejecting them. App-side
-- validation in the handlers takes over.
ALTER TABLE network_interfaces DROP CONSTRAINT IF EXISTS network_interfaces_role_check;
ALTER TABLE firewall_rules DROP CONSTRAINT IF EXISTS firewall_rules_src_zone_check;
ALTER TABLE firewall_rules DROP CONSTRAINT IF EXISTS firewall_rules_dst_zone_check;
ALTER TABLE firewall_nat_rules DROP CONSTRAINT IF EXISTS firewall_nat_rules_zone_check;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS firewall_zones;
-- +goose StatementEnd

View File

@@ -29,6 +29,7 @@ import (
// Validation lebt im Handler (DB lässt mehr zu als die Anwendung erlauben
// will — exactly-one-of-Constraints sind in Postgres mühsam).
type FirewallHandler struct {
Zones *firewall.ZonesRepo
AddrObjects *firewall.AddressObjectsRepo
AddrGroups *firewall.AddressGroupsRepo
Services *firewall.ServicesRepo
@@ -47,6 +48,7 @@ type FirewallHandler struct {
}
func NewFirewallHandler(
zn *firewall.ZonesRepo,
ao *firewall.AddressObjectsRepo,
ag *firewall.AddressGroupsRepo,
sv *firewall.ServicesRepo,
@@ -58,6 +60,7 @@ func NewFirewallHandler(
reloader func(ctx context.Context) error,
) *FirewallHandler {
return &FirewallHandler{
Zones: zn,
AddrObjects: ao, AddrGroups: ag,
Services: sv, ServiceGroups: sg,
Rules: rl, NATRules: nat,
@@ -82,6 +85,13 @@ func (h *FirewallHandler) reload(ctx context.Context, op string) {
func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
g := rg.Group("/firewall")
zn := g.Group("/zones")
zn.GET("", h.ListZone)
zn.POST("", h.CreateZone)
zn.GET("/:id", h.GetZone)
zn.PUT("/:id", h.UpdateZone)
zn.DELETE("/:id", h.DeleteZone)
ao := g.Group("/address-objects")
ao.GET("", h.ListAddrObj)
ao.POST("", h.CreateAddrObj)
@@ -125,6 +135,155 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
nat.DELETE("/:id", h.DeleteNAT)
}
// ── Zones ──────────────────────────────────────────────────────────────
func (h *FirewallHandler) ListZone(c *gin.Context) {
out, err := h.Zones.List(c.Request.Context())
if err != nil {
response.Internal(c, err)
return
}
response.OK(c, gin.H{"zones": out})
}
func (h *FirewallHandler) GetZone(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
x, err := h.Zones.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, firewall.ErrZoneNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
response.OK(c, x)
}
func (h *FirewallHandler) CreateZone(c *gin.Context) {
var req models.FirewallZone
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if !zoneNamePattern(req.Name) {
response.BadRequest(c, errors.New("zone name must match [a-z][a-z0-9_-]{0,31}"))
return
}
out, err := h.Zones.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.zone.create", out.Name, out, h.NodeID)
response.Created(c, out)
}
func (h *FirewallHandler) UpdateZone(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
var req models.FirewallZone
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, err)
return
}
if req.Name != "" && !zoneNamePattern(req.Name) {
response.BadRequest(c, errors.New("zone name must match [a-z][a-z0-9_-]{0,31}"))
return
}
out, err := h.Zones.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, firewall.ErrZoneNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.zone.update", out.Name, out, h.NodeID)
response.OK(c, out)
}
func (h *FirewallHandler) DeleteZone(c *gin.Context) {
id, ok := parseID(c)
if !ok {
return
}
cur, err := h.Zones.Get(c.Request.Context(), id)
if err != nil {
if errors.Is(err, firewall.ErrZoneNotFound) {
response.NotFound(c, err)
return
}
response.Internal(c, err)
return
}
refs, err := h.Zones.References(c.Request.Context(), cur.Name)
if err != nil {
response.Internal(c, err)
return
}
if refs > 0 {
response.BadRequest(c, fmt.Errorf("zone %q is still in use by %d objects", cur.Name, refs))
return
}
if err := h.Zones.Delete(c.Request.Context(), id); err != nil {
response.BadRequest(c, err)
return
}
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.zone.delete", cur.Name, gin.H{"id": id}, h.NodeID)
response.NoContent(c)
}
// zoneNamePattern mirrors the SQL CHECK on firewall_zones.name —
// duplicated app-side so we can return a friendly message instead
// of leaking the constraint violation.
func zoneNamePattern(s string) bool {
if s == "" || len(s) > 32 {
return false
}
if !(s[0] >= 'a' && s[0] <= 'z') {
return false
}
for i := 1; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z':
case c >= '0' && c <= '9':
case c == '_' || c == '-':
default:
return false
}
}
return true
}
// resolveZone returns nil if name is empty / "any", or if the zone
// exists in firewall_zones. Used by rule + NAT validation so a typo
// in src_zone/dst_zone gets a 400 instead of silently never matching
// in the renderer.
func (h *FirewallHandler) resolveZone(ctx context.Context, name string, allowAny bool) error {
if name == "" {
return nil
}
if allowAny && name == "any" {
return nil
}
ok, err := h.Zones.Exists(ctx, name)
if err != nil {
return err
}
if !ok {
return fmt.Errorf("zone %q does not exist", name)
}
return nil
}
// ── Address Objects ────────────────────────────────────────────────────
func (h *FirewallHandler) ListAddrObj(c *gin.Context) {
@@ -507,6 +666,14 @@ func (h *FirewallHandler) CreateRule(c *gin.Context) {
response.BadRequest(c, err)
return
}
if err := h.resolveZone(c.Request.Context(), req.SrcZone, true); err != nil {
response.BadRequest(c, fmt.Errorf("src_zone: %w", err))
return
}
if err := h.resolveZone(c.Request.Context(), req.DstZone, true); err != nil {
response.BadRequest(c, fmt.Errorf("dst_zone: %w", err))
return
}
out, err := h.Rules.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
@@ -530,6 +697,14 @@ func (h *FirewallHandler) UpdateRule(c *gin.Context) {
response.BadRequest(c, err)
return
}
if err := h.resolveZone(c.Request.Context(), req.SrcZone, true); err != nil {
response.BadRequest(c, fmt.Errorf("src_zone: %w", err))
return
}
if err := h.resolveZone(c.Request.Context(), req.DstZone, true); err != nil {
response.BadRequest(c, fmt.Errorf("dst_zone: %w", err))
return
}
out, err := h.Rules.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, firewall.ErrRuleNotFound) {
@@ -599,6 +774,10 @@ func (h *FirewallHandler) CreateNAT(c *gin.Context) {
response.BadRequest(c, err)
return
}
if err := h.checkNATZones(c.Request.Context(), req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.NATRules.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
@@ -622,6 +801,10 @@ func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
response.BadRequest(c, err)
return
}
if err := h.checkNATZones(c.Request.Context(), req); err != nil {
response.BadRequest(c, err)
return
}
out, err := h.NATRules.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, firewall.ErrNATRuleNotFound) {
@@ -759,6 +942,23 @@ func validateNAT(n models.FirewallNATRule) error {
return nil
}
// checkNATZones validates in_zone / out_zone (both nullable) point
// to existing zones — NAT zones don't accept "any" because the
// renderer needs a concrete iface group to attach the chain.
func (h *FirewallHandler) checkNATZones(ctx context.Context, n models.FirewallNATRule) error {
if n.InZone != nil {
if err := h.resolveZone(ctx, *n.InZone, false); err != nil {
return fmt.Errorf("in_zone: %w", err)
}
}
if n.OutZone != nil {
if err := h.resolveZone(ctx, *n.OutZone, false); err != nil {
return fmt.Errorf("out_zone: %w", err)
}
}
return nil
}
// countNonNil counts how many of the supplied pointers are non-nil
// (and, for *string, non-empty). Used by validateRule.
func countNonNil(args ...any) int {

View File

@@ -9,19 +9,24 @@ import (
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/firewall"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/ipaddresses"
"git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
)
type NetworksHandler struct {
Repo *networkifs.Repo
IPs *ipaddresses.Repo
Audit *audit.Repo
NodeID string
Repo *networkifs.Repo
IPs *ipaddresses.Repo
Zones *firewall.ZonesRepo
Audit *audit.Repo
NodeID string
}
func NewNetworksHandler(repo *networkifs.Repo, ips *ipaddresses.Repo, a *audit.Repo, nodeID string) *NetworksHandler {
return &NetworksHandler{Repo: repo, IPs: ips, Audit: a, NodeID: nodeID}
func NewNetworksHandler(
repo *networkifs.Repo, ips *ipaddresses.Repo,
zones *firewall.ZonesRepo, a *audit.Repo, nodeID string,
) *NetworksHandler {
return &NetworksHandler{Repo: repo, IPs: ips, Zones: zones, Audit: a, NodeID: nodeID}
}
func (h *NetworksHandler) Register(rg *gin.RouterGroup) {
@@ -70,6 +75,13 @@ func (h *NetworksHandler) Create(c *gin.Context) {
response.BadRequest(c, err)
return
}
if ok, err := h.Zones.Exists(c.Request.Context(), req.Role); err != nil {
response.Internal(c, err)
return
} else if !ok {
response.BadRequest(c, errors.New("role: zone "+req.Role+" does not exist"))
return
}
out, err := h.Repo.Create(c.Request.Context(), req)
if err != nil {
response.Internal(c, err)
@@ -93,6 +105,13 @@ func (h *NetworksHandler) Update(c *gin.Context) {
response.BadRequest(c, err)
return
}
if ok, err := h.Zones.Exists(c.Request.Context(), req.Role); err != nil {
response.Internal(c, err)
return
} else if !ok {
response.BadRequest(c, errors.New("role: zone "+req.Role+" does not exist"))
return
}
out, err := h.Repo.Update(c.Request.Context(), id, req)
if err != nil {
if errors.Is(err, networkifs.ErrNotFound) {

View File

@@ -70,5 +70,5 @@ backend api_backend
{{- range .Backends}}
backend eg_backend_{{.ID}}
server {{.Name}} {{.Address}}:{{.Port}}{{if .HealthCheckPath}} check inter 5s{{end}}
server {{.Name | safeID}} {{.Address}}:{{.Port}}{{if .HealthCheckPath}} check inter 5s{{end}}
{{- end}}

View File

@@ -11,6 +11,7 @@ import (
_ "embed"
"fmt"
"path/filepath"
"strings"
"text/template"
"github.com/jackc/pgx/v5/pgxpool"
@@ -25,7 +26,33 @@ import (
//go:embed haproxy.cfg.tpl
var cfgTpl string
var tpl = template.Must(template.New("haproxy").Parse(cfgTpl))
// safeID converts a free-form display name like "Control Master 1"
// into a single token HAProxy accepts as a server-id (no spaces /
// special chars). Anything outside [a-zA-Z0-9_-] becomes '_'.
func safeID(s string) string {
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
switch {
case r >= 'a' && r <= 'z',
r >= 'A' && r <= 'Z',
r >= '0' && r <= '9',
r == '-', r == '_':
b.WriteRune(r)
default:
b.WriteByte('_')
}
}
out := b.String()
if out == "" {
out = "unnamed"
}
return out
}
var tpl = template.Must(template.New("haproxy").Funcs(template.FuncMap{
"safeID": safeID,
}).Parse(cfgTpl))
type Generator struct {
Pool *pgxpool.Pool

View File

@@ -0,0 +1,14 @@
package models
import "time"
type FirewallZone struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"column:name;uniqueIndex" json:"name"`
Description *string `gorm:"column:description" json:"description,omitempty"`
Builtin bool `gorm:"column:builtin" json:"builtin"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (FirewallZone) TableName() string { return "firewall_zones" }

View File

@@ -0,0 +1,147 @@
package firewall
import (
"context"
"errors"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"git.netcell-it.de/projekte/edgeguard-native/internal/models"
)
var ErrZoneNotFound = errors.New("zone not found")
type ZonesRepo struct {
Pool *pgxpool.Pool
}
func NewZonesRepo(pool *pgxpool.Pool) *ZonesRepo { return &ZonesRepo{Pool: pool} }
const zoneBaseSelect = `
SELECT id, name, description, builtin, created_at, updated_at
FROM firewall_zones
`
func (r *ZonesRepo) List(ctx context.Context) ([]models.FirewallZone, error) {
rows, err := r.Pool.Query(ctx, zoneBaseSelect+" ORDER BY name ASC")
if err != nil {
return nil, err
}
defer rows.Close()
out := make([]models.FirewallZone, 0, 8)
for rows.Next() {
z, err := scanZone(rows)
if err != nil {
return nil, err
}
out = append(out, *z)
}
return out, rows.Err()
}
func (r *ZonesRepo) Get(ctx context.Context, id int64) (*models.FirewallZone, error) {
row := r.Pool.QueryRow(ctx, zoneBaseSelect+" WHERE id = $1", id)
z, err := scanZone(row)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return nil, ErrZoneNotFound
}
return nil, err
}
return z, nil
}
// Exists is used by the rules / nat / iface handlers to validate
// that the zone name they got from the operator references a real
// zone (or the special 'any' which the rule layer handles itself).
func (r *ZonesRepo) Exists(ctx context.Context, name string) (bool, error) {
var n int
err := r.Pool.QueryRow(ctx, `SELECT 1 FROM firewall_zones WHERE name = $1`, name).Scan(&n)
if errors.Is(err, pgx.ErrNoRows) {
return false, nil
}
if err != nil {
return false, err
}
return true, nil
}
func (r *ZonesRepo) Create(ctx context.Context, z models.FirewallZone) (*models.FirewallZone, error) {
row := r.Pool.QueryRow(ctx, `
INSERT INTO firewall_zones (name, description, builtin)
VALUES ($1, $2, FALSE)
RETURNING id, name, description, builtin, created_at, updated_at`,
z.Name, z.Description)
return scanZone(row)
}
// Update — builtin zones may have their description tweaked but
// not their name (the renderer + iface rows reference zones by
// name and a rename would silently dangle them). Custom zones can
// be renamed; the handler is responsible for cascading the new
// name into network_interfaces.role / firewall_rules.src_zone /
// dst_zone / firewall_nat_rules.in_zone / out_zone if needed.
func (r *ZonesRepo) Update(ctx context.Context, id int64, z models.FirewallZone) (*models.FirewallZone, error) {
cur, err := r.Get(ctx, id)
if err != nil {
return nil, err
}
name := z.Name
if cur.Builtin {
name = cur.Name
}
row := r.Pool.QueryRow(ctx, `
UPDATE firewall_zones SET
name = $1, description = $2, updated_at = NOW()
WHERE id = $3
RETURNING id, name, description, builtin, created_at, updated_at`,
name, z.Description, id)
return scanZone(row)
}
// Delete — builtin zones are non-deletable; for custom zones the
// caller must check for references in network_interfaces /
// firewall_rules / firewall_nat_rules first (handler concern).
func (r *ZonesRepo) Delete(ctx context.Context, id int64) error {
cur, err := r.Get(ctx, id)
if err != nil {
return err
}
if cur.Builtin {
return errors.New("builtin zone cannot be deleted")
}
tag, err := r.Pool.Exec(ctx, `DELETE FROM firewall_zones WHERE id = $1`, id)
if err != nil {
return err
}
if tag.RowsAffected() == 0 {
return ErrZoneNotFound
}
return nil
}
// References returns the count of foreign uses of this zone (by
// name) so the handler can surface a "zone is still in use" error
// instead of letting a cascade go silent.
func (r *ZonesRepo) References(ctx context.Context, name string) (int, error) {
var n int
err := r.Pool.QueryRow(ctx, `
SELECT
(SELECT COUNT(*) FROM network_interfaces WHERE role = $1) +
(SELECT COUNT(*) FROM firewall_rules WHERE src_zone = $1 OR dst_zone = $1) +
(SELECT COUNT(*) FROM firewall_nat_rules WHERE in_zone = $1 OR out_zone = $1)`,
name).Scan(&n)
return n, err
}
func scanZone(row interface{ Scan(...any) error }) (*models.FirewallZone, error) {
var z models.FirewallZone
if err := row.Scan(
&z.ID, &z.Name, &z.Description, &z.Builtin,
&z.CreatedAt, &z.UpdatedAt,
); err != nil {
return nil, err
}
return &z, nil
}

View File

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

View File

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

View File

@@ -29,11 +29,23 @@
"tabs": {
"rules": "Regeln",
"nat": "NAT",
"zones": "Zonen",
"addrObj": "Adress-Objekte",
"addrGrp": "Adress-Gruppen",
"services": "Services",
"svcGrp": "Service-Gruppen"
},
"zone": {
"name": "Name",
"description": "Beschreibung",
"builtin": "vordefiniert",
"builtinHint": "Vordefinierte Zonen können nicht gelöscht werden — Renderer und Anti-Lockout-Regeln verlassen sich darauf.",
"builtinNameLocked": "Name vordefiniert — kann nicht geändert werden, weil bestehende Regeln und Interfaces ihn referenzieren.",
"namePattern": "Nur Kleinbuchstaben, Ziffern, _ und -; muss mit Buchstaben beginnen, max. 32 Zeichen.",
"add": "Zone hinzufügen",
"edit": "Zone bearbeiten",
"deleteConfirm": "Zone {{name}} wirklich löschen?"
},
"ao": {
"name": "Name", "kind": "Typ", "value": "Wert", "description": "Beschreibung",
"add": "Adress-Objekt hinzufügen", "edit": "Adress-Objekt bearbeiten",
@@ -110,7 +122,8 @@
"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",
"role": "Zone",
"roleHint": "Zonen kommen aus Firewall → Zonen. Eigene Zonen (z.B. iot, guest) lassen sich dort anlegen.",
"mtu": "MTU",
"active": "Aktiv",
"description": "Beschreibung",
@@ -170,6 +183,9 @@
"name": "Name",
"active": "Aktiv",
"primaryBackend": "Primary-Backend",
"primaryBackendHint": "Catch-all-Backend für Requests, die kein Routing-Regel-Match haben. Optional — leer lassen, wenn alles über Routing-Regeln läuft.",
"selectBackend": "Backend wählen",
"noBackend": "kein Backend",
"httpToHttps": "HTTP→HTTPS",
"hsts": "HSTS",
"notes": "Notizen",
@@ -189,6 +205,8 @@
"target": "Ziel",
"healthCheck": "Health-Check-Pfad",
"active": "Aktiv",
"usedBy": "Genutzt von",
"noDomain": "keine Domain",
"actions": "Aktionen",
"deleteConfirm": "Backend {{name}} wirklich löschen?"
},

View File

@@ -29,11 +29,23 @@
"tabs": {
"rules": "Rules",
"nat": "NAT",
"zones": "Zones",
"addrObj": "Address objects",
"addrGrp": "Address groups",
"services": "Services",
"svcGrp": "Service groups"
},
"zone": {
"name": "Name",
"description": "Description",
"builtin": "built-in",
"builtinHint": "Built-in zones cannot be deleted — the renderer and anti-lockout rules depend on them.",
"builtinNameLocked": "Name is built-in — cannot be changed because existing rules and interfaces reference it.",
"namePattern": "Lowercase letters, digits, _ and -; must start with a letter, up to 32 chars.",
"add": "Add zone",
"edit": "Edit zone",
"deleteConfirm": "Really delete zone {{name}}?"
},
"ao": {
"name": "Name", "kind": "Kind", "value": "Value", "description": "Description",
"add": "Add address object", "edit": "Edit address object",
@@ -110,7 +122,8 @@
"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",
"role": "Zone",
"roleHint": "Zones are managed in Firewall → Zones. Custom zones (e.g. iot, guest) can be added there.",
"mtu": "MTU",
"active": "Active",
"description": "Description",
@@ -170,6 +183,9 @@
"name": "Name",
"active": "Active",
"primaryBackend": "Primary backend",
"primaryBackendHint": "Catch-all backend for requests with no matching routing rule. Optional — leave empty if all traffic is routed via routing rules.",
"selectBackend": "Select backend",
"noBackend": "no backend",
"httpToHttps": "HTTP→HTTPS",
"hsts": "HSTS",
"notes": "Notes",
@@ -189,6 +205,8 @@
"target": "Target",
"healthCheck": "Health check path",
"active": "Active",
"usedBy": "Used by",
"noDomain": "no domain",
"actions": "Actions",
"deleteConfirm": "Really delete backend {{name}}?"
},

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Typography, message } from 'antd'
import { Button, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Switch, Tag, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
@@ -35,11 +35,31 @@ async function listBackends(): Promise<Backend[]> {
return payload.backends ?? []
}
interface DomainLite {
id: number
name: string
active: boolean
primary_backend_id?: number | null
}
async function listDomains(): Promise<DomainLite[]> {
const r = await apiClient.get('/domains')
if (!isEnvelope(r.data)) return []
return (r.data.data as { domains?: DomainLite[] }).domains ?? []
}
export default function BackendsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
const { data: domains } = useQuery({ queryKey: ['domains'], queryFn: listDomains })
// Reverse-lookup: which domains have this backend as primary?
// Read-only — domain ↔ backend coupling is owned by the Domains
// page, but showing it here makes the connection bi-directional
// in the UI.
const domainsForBackend = (id: number) =>
(domains ?? []).filter(d => d.primary_backend_id === id)
const [editing, setEditing] = useState<Backend | null>(null)
const [creating, setCreating] = useState(false)
@@ -82,6 +102,14 @@ export default function BackendsPage() {
render: (_, row) => `${row.address}:${row.port}`,
},
{ title: t('backends.healthCheck'), dataIndex: 'health_check_path', key: 'hc', render: (v?: string) => v ?? '—' },
{
title: t('backends.usedBy'), key: 'used_by',
render: (_, row) => {
const ds = domainsForBackend(row.id)
if (ds.length === 0) return <Tag color="default">{t('backends.noDomain')}</Tag>
return <Space size={4} wrap>{ds.map(d => <Tag key={d.id} color="blue">{d.name}</Tag>)}</Space>
},
},
{ title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
{
title: t('backends.actions'), key: 'actions',

View File

@@ -1,5 +1,5 @@
import { useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Space, Switch, Typography, message } from 'antd'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Switch, Tag, Typography, message } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useTranslation } from 'react-i18next'
@@ -35,6 +35,19 @@ async function listDomains(): Promise<Domain[]> {
return payload.domains ?? []
}
interface BackendLite {
id: number
name: string
address: string
port: number
active: boolean
}
async function listBackends(): Promise<BackendLite[]> {
const r = await apiClient.get('/backends')
if (!isEnvelope(r.data)) return []
return (r.data.data as { backends?: BackendLite[] }).backends ?? []
}
export default function DomainsPage() {
const { t } = useTranslation()
const qc = useQueryClient()
@@ -43,6 +56,8 @@ export default function DomainsPage() {
queryKey: ['domains'],
queryFn: listDomains,
})
const { data: backends } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
const backendById = (id?: number | null) => backends?.find(b => b.id === id)
const [editing, setEditing] = useState<Domain | null>(null)
const [creating, setCreating] = useState(false)
@@ -85,6 +100,16 @@ export default function DomainsPage() {
const columns: ColumnsType<Domain> = [
{ title: t('domains.name'), dataIndex: 'name', key: 'name' },
{
title: t('domains.primaryBackend'), dataIndex: 'primary_backend_id', key: 'primary_backend_id',
render: (id?: number | null) => {
if (!id) return <Tag color="default">{t('domains.noBackend')}</Tag>
const b = backendById(id)
return b
? <Tag color="blue">{b.name} ({b.address}:{b.port})</Tag>
: <Tag color="orange">#{id}</Tag>
},
},
{ title: t('domains.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
{ title: t('domains.httpToHttps'), dataIndex: 'http_to_https', key: 'http_to_https', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
{ title: t('domains.hsts'), dataIndex: 'hsts_enabled', key: 'hsts', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
@@ -150,6 +175,22 @@ export default function DomainsPage() {
<Form.Item label={t('domains.name')} name="name" rules={[{ required: true }]}>
<Input placeholder="example.com" />
</Form.Item>
<Form.Item
label={t('domains.primaryBackend')}
name="primary_backend_id"
extra={t('domains.primaryBackendHint')}
>
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder={t('domains.selectBackend')}
options={(backends ?? []).filter(b => b.active).map(b => ({
value: b.id,
label: `${b.name} (${b.address}:${b.port})`,
}))}
/>
</Form.Item>
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
<Switch />
</Form.Item>

View File

@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
import DataTable from '../../components/DataTable'
import apiClient, { isEnvelope } from '../../api/client'
import type { NATRule } from './types'
import type { FwZone, NATRule } from './types'
interface FormValues {
name?: string
@@ -26,13 +26,16 @@ interface FormValues {
comment?: string
}
const ZONES_FOR_NAT = ['wan', 'lan', 'dmz', 'mgmt', 'cluster'] as const
async function listNAT(): Promise<NATRule[]> {
const r = await apiClient.get('/firewall/nat-rules')
if (!isEnvelope(r.data)) return []
return (r.data.data as { nat_rules?: NATRule[] }).nat_rules ?? []
}
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 ?? []
}
const KIND_COLORS: Record<NATRule['kind'], string> = {
dnat: 'blue',
@@ -44,6 +47,14 @@ export default function NATRulesTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data, isLoading } = useQuery({ queryKey: ['fw', 'nat'], queryFn: listNAT })
const { data: zones } = useQuery({ queryKey: ['fw', 'zones'], queryFn: listZones })
// NAT zones don't accept "any" — the renderer needs a concrete
// iface group to attach DNAT/SNAT/masq chains to. Fallback to the
// seed list while loading.
const zoneOptions: string[] = zones && zones.length > 0
? zones.map((z) => z.name)
: ['wan', 'lan', 'dmz', 'mgmt', 'cluster']
const [editing, setEditing] = useState<NATRule | null>(null)
const [creating, setCreating] = useState(false)
@@ -165,12 +176,12 @@ export default function NATRulesTab() {
<>
{kind === 'dnat' && (
<Form.Item label={t('fw.nat.inZone')} name="in_zone" rules={[{ required: true }]}>
<Select options={ZONES_FOR_NAT.map(z => ({ value: z, label: z }))} />
<Select options={zoneOptions.map(z => ({ value: z, label: z }))} />
</Form.Item>
)}
{(kind === 'snat' || kind === 'masquerade') && (
<Form.Item label={t('fw.nat.outZone')} name="out_zone" rules={[{ required: true }]}>
<Select options={ZONES_FOR_NAT.map(z => ({ value: z, label: z }))} />
<Select options={zoneOptions.map(z => ({ value: z, label: z }))} />
</Form.Item>
)}
<Form.Item label={t('fw.nat.proto')} name="proto">

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

View File

@@ -0,0 +1,116 @@
import { useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Space, 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 DataTable from '../../components/DataTable'
import type { FwZone } from './types'
interface FormValues {
name: string
description?: string
}
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 ?? []
}
export default function ZonesTab() {
const { t } = useTranslation()
const qc = useQueryClient()
const [editing, setEditing] = useState<FwZone | null>(null)
const [creating, setCreating] = useState(false)
const [form] = Form.useForm<FormValues>()
const { data, isLoading } = useQuery({ queryKey: ['fw-zones'], queryFn: listZones })
const upsert = useMutation({
mutationFn: async (vals: FormValues) => {
if (editing) return (await apiClient.put(`/firewall/zones/${editing.id}`, vals)).data
return (await apiClient.post('/firewall/zones', vals)).data
},
onSuccess: () => {
message.success(t('common.save'))
setEditing(null); setCreating(false); form.resetFields()
void qc.invalidateQueries({ queryKey: ['fw-zones'] })
},
onError: (e: Error) => message.error(e.message),
})
const del = useMutation({
mutationFn: async (id: number) => { await apiClient.delete(`/firewall/zones/${id}`) },
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['fw-zones'] }) },
onError: (e: Error) => message.error(e.message),
})
const columns: ColumnsType<FwZone> = [
{ title: t('fw.zone.name'), dataIndex: 'name', key: 'name',
render: (s: string, row) => row.builtin
? <Space><code>{s}</code><Tag color="blue">{t('fw.zone.builtin')}</Tag></Space>
: <code>{s}</code>,
},
{ title: t('fw.zone.description'), dataIndex: 'description', key: 'description',
render: (v?: string | null) => v ?? '—' },
{
title: t('common.actions'), key: 'actions',
render: (_, row) => (
<Space>
<Button size="small" onClick={() => {
setEditing(row)
form.setFieldsValue({ name: row.name, description: row.description ?? undefined })
}}>{t('common.edit')}</Button>
{row.builtin
? <Tooltip title={t('fw.zone.builtinHint')}>
<Button size="small" danger disabled>{t('common.delete')}</Button>
</Tooltip>
: <Popconfirm
title={t('fw.zone.deleteConfirm', { name: row.name })}
onConfirm={() => del.mutate(row.id)}
>
<Button size="small" danger>{t('common.delete')}</Button>
</Popconfirm>}
</Space>
),
},
]
return (
<>
<Button type="primary" className="mb-16" onClick={() => {
setCreating(true); form.resetFields()
}}>
{t('fw.zone.add')}
</Button>
<DataTable rowKey="id" loading={isLoading} dataSource={data ?? []} columns={columns} />
<Modal
title={editing ? t('fw.zone.edit') : t('fw.zone.add')}
open={editing !== null || creating}
onCancel={() => { setEditing(null); setCreating(false); form.resetFields() }}
onOk={() => { void form.submit() }}
confirmLoading={upsert.isPending}
width={520}
>
<Form form={form} layout="vertical" onFinish={(v) => upsert.mutate(v)}>
<Form.Item
label={t('fw.zone.name')}
name="name"
rules={[
{ required: true },
{ pattern: /^[a-z][a-z0-9_-]{0,31}$/, message: t('fw.zone.namePattern') },
]}
extra={editing?.builtin ? t('fw.zone.builtinNameLocked') : undefined}
>
<Input disabled={editing?.builtin} />
</Form.Item>
<Form.Item label={t('fw.zone.description')} name="description">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</>
)
}

View File

@@ -7,6 +7,7 @@ import ServicesTab from './Services'
import ServiceGroupsTab from './ServiceGroups'
import RulesTab from './Rules'
import NATRulesTab from './NATRules'
import ZonesTab from './Zones'
export default function FirewallPage() {
const { t } = useTranslation()
@@ -14,6 +15,7 @@ export default function FirewallPage() {
const tabs = [
{ key: 'rules', label: t('fw.tabs.rules'), children: <RulesTab /> },
{ key: 'nat', label: t('fw.tabs.nat'), children: <NATRulesTab /> },
{ key: 'zones', label: t('fw.tabs.zones'), children: <ZonesTab /> },
{ key: 'addrObj', label: t('fw.tabs.addrObj'), children: <AddressObjectsTab /> },
{ key: 'addrGrp', label: t('fw.tabs.addrGrp'), children: <AddressGroupsTab /> },
{ key: 'services', label: t('fw.tabs.services'), children: <ServicesTab /> },

View File

@@ -36,7 +36,20 @@ export interface ServiceGroup {
member_ids?: number[]
}
export type Zone = 'wan' | 'lan' | 'dmz' | 'mgmt' | 'cluster' | 'any'
// Zone is now a free-form string — operator-managed via the Zones
// tab. 'any' is the special value the firewall_rules layer accepts
// to mean "match any zone". The list at runtime is loaded from
// /api/v1/firewall/zones.
export type Zone = string
export interface FwZone {
id: number
name: string
description?: string | null
builtin: boolean
created_at: string
updated_at: string
}
export interface FwRule {
id: number
@@ -77,4 +90,7 @@ export interface NATRule {
comment?: string | null
}
export const ZONES: Zone[] = ['any', 'wan', 'lan', 'dmz', 'mgmt', 'cluster']
// Fallback list — used only while /firewall/zones hasn't loaded
// yet (initial render of the rule modal). Real list comes from the
// API.
export const ZONES_FALLBACK: Zone[] = ['any', 'wan', 'lan', 'dmz', 'mgmt', 'cluster']

View File

@@ -14,7 +14,7 @@ interface NetworkInterface {
parent?: string | null
vlan_id?: number | null
members: string[]
role: 'wan' | 'lan' | 'dmz' | 'mgmt' | 'cluster'
role: string
mtu?: number | null
active: boolean
description?: string | null
@@ -28,7 +28,7 @@ interface IfaceFormValues {
parent?: string
vlan_id?: number
members?: string[]
role: NetworkInterface['role']
role: string
mtu?: number
active: boolean
description?: string
@@ -54,12 +54,20 @@ async function listSystemInterfaces(): Promise<SystemInterface[]> {
return (r.data.data as { interfaces?: SystemInterface[] }).interfaces ?? []
}
interface FwZone { id: number; name: string; description?: string | null; builtin: boolean }
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 ?? []
}
export default function NetworksPage() {
const { t } = useTranslation()
const qc = useQueryClient()
const { data: ifs, isLoading } = useQuery({ queryKey: ['network-interfaces'], queryFn: listInterfaces })
const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces, refetchInterval: 60_000 })
const { data: zones } = useQuery({ queryKey: ['fw-zones'], queryFn: listZones })
const [editing, setEditing] = useState<NetworkInterface | null>(null)
const [creating, setCreating] = useState(false)
@@ -86,8 +94,16 @@ export default function NetworksPage() {
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) },
})
const roleColor: Record<NetworkInterface['role'], string> = {
wan: 'blue', lan: 'green', dmz: 'orange', mgmt: 'purple', cluster: 'magenta',
// Stable colour palette for role tags. Builtin zones get a fixed
// colour; custom zones cycle through the palette by name hash so
// the same custom zone always shows up in the same shade.
const PALETTE = ['blue', 'green', 'orange', 'purple', 'magenta', 'cyan', 'gold', 'volcano', 'geekblue']
const FIXED: Record<string, string> = { wan: 'blue', lan: 'green', dmz: 'orange', mgmt: 'purple', cluster: 'magenta' }
const roleColor = (r: string): string => {
if (FIXED[r]) return FIXED[r]
let h = 0
for (let i = 0; i < r.length; i++) h = (h * 31 + r.charCodeAt(i)) >>> 0
return PALETTE[h % PALETTE.length]
}
const columns: ColumnsType<NetworkInterface> = [
@@ -105,7 +121,7 @@ export default function NetworksPage() {
},
{
title: t('networks.role'), dataIndex: 'role', key: 'role',
render: (r: NetworkInterface['role']) => <Tag color={roleColor[r]}>{r.toUpperCase()}</Tag>,
render: (r: string) => <Tag color={roleColor(r)}>{r.toUpperCase()}</Tag>,
},
{ title: t('networks.mtu'), dataIndex: 'mtu', key: 'mtu', render: (v?: number) => v ?? '—' },
{ title: t('networks.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
@@ -229,8 +245,19 @@ export default function NetworksPage() {
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() }))} />
<Form.Item
label={t('networks.role')}
name="role"
rules={[{ required: true }]}
extra={t('networks.roleHint')}
>
<Select
showSearch
options={(zones ?? []).map(z => ({
value: z.name,
label: z.builtin ? z.name.toUpperCase() : `${z.name.toUpperCase()} (custom)`,
}))}
/>
</Form.Item>
<Form.Item label={t('networks.mtu')} name="mtu">
<InputNumber min={68} max={9216} style={{ width: '100%' }} placeholder="1500" />