diff --git a/VERSION b/VERSION index 21e8796..af0b7dd 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.0.3 +1.0.6 diff --git a/cmd/edgeguard-api/main.go b/cmd/edgeguard-api/main.go index f06a52a..554553d 100644 --- a/cmd/edgeguard-api/main.go +++ b/cmd/edgeguard-api/main.go @@ -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) diff --git a/cmd/edgeguard-ctl/main.go b/cmd/edgeguard-ctl/main.go index 13445b0..5e73db9 100644 --- a/cmd/edgeguard-ctl/main.go +++ b/cmd/edgeguard-ctl/main.go @@ -9,7 +9,7 @@ import ( "os" ) -var version = "1.0.3" +var version = "1.0.6" const usage = `edgeguard-ctl — EdgeGuard CLI diff --git a/cmd/edgeguard-scheduler/main.go b/cmd/edgeguard-scheduler/main.go index 31d949f..d3a9a45 100644 --- a/cmd/edgeguard-scheduler/main.go +++ b/cmd/edgeguard-scheduler/main.go @@ -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) diff --git a/internal/database/migrations/0012_firewall_zones.sql b/internal/database/migrations/0012_firewall_zones.sql new file mode 100644 index 0000000..c700921 --- /dev/null +++ b/internal/database/migrations/0012_firewall_zones.sql @@ -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 diff --git a/internal/handlers/firewall.go b/internal/handlers/firewall.go index c638889..4c100f6 100644 --- a/internal/handlers/firewall.go +++ b/internal/handlers/firewall.go @@ -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 { diff --git a/internal/handlers/networks.go b/internal/handlers/networks.go index 013d2a1..d132d93 100644 --- a/internal/handlers/networks.go +++ b/internal/handlers/networks.go @@ -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) { diff --git a/internal/haproxy/haproxy.cfg.tpl b/internal/haproxy/haproxy.cfg.tpl index 23e6ea9..1111f07 100644 --- a/internal/haproxy/haproxy.cfg.tpl +++ b/internal/haproxy/haproxy.cfg.tpl @@ -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}} diff --git a/internal/haproxy/haproxy.go b/internal/haproxy/haproxy.go index 6f741fb..44891e5 100644 --- a/internal/haproxy/haproxy.go +++ b/internal/haproxy/haproxy.go @@ -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 diff --git a/internal/models/firewall_zone.go b/internal/models/firewall_zone.go new file mode 100644 index 0000000..5f6a8a8 --- /dev/null +++ b/internal/models/firewall_zone.go @@ -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" } diff --git a/internal/services/firewall/zones.go b/internal/services/firewall/zones.go new file mode 100644 index 0000000..e73fd53 --- /dev/null +++ b/internal/services/firewall/zones.go @@ -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 +} diff --git a/management-ui/package.json b/management-ui/package.json index 92acca3..801d8c9 100644 --- a/management-ui/package.json +++ b/management-ui/package.json @@ -1,7 +1,7 @@ { "name": "edgeguard-management-ui", "private": true, - "version": "1.0.3", + "version": "1.0.6", "type": "module", "scripts": { "dev": "vite", diff --git a/management-ui/src/components/Layout/Sidebar.tsx b/management-ui/src/components/Layout/Sidebar.tsx index ed9fb78..583fb00 100644 --- a/management-ui/src/components/Layout/Sidebar.tsx +++ b/management-ui/src/components/Layout/Sidebar.tsx @@ -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() diff --git a/management-ui/src/i18n/locales/de/common.json b/management-ui/src/i18n/locales/de/common.json index 3031b7f..f46f3e6 100644 --- a/management-ui/src/i18n/locales/de/common.json +++ b/management-ui/src/i18n/locales/de/common.json @@ -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?" }, diff --git a/management-ui/src/i18n/locales/en/common.json b/management-ui/src/i18n/locales/en/common.json index 13c9a58..5a9edfa 100644 --- a/management-ui/src/i18n/locales/en/common.json +++ b/management-ui/src/i18n/locales/en/common.json @@ -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}}?" }, diff --git a/management-ui/src/pages/Backends/index.tsx b/management-ui/src/pages/Backends/index.tsx index e2d94bd..9537145 100644 --- a/management-ui/src/pages/Backends/index.tsx +++ b/management-ui/src/pages/Backends/index.tsx @@ -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 { return payload.backends ?? [] } +interface DomainLite { + id: number + name: string + active: boolean + primary_backend_id?: number | null +} +async function listDomains(): Promise { + 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(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 {t('backends.noDomain')} + return {ds.map(d => {d.name})} + }, + }, { title: t('backends.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') }, { title: t('backends.actions'), key: 'actions', diff --git a/management-ui/src/pages/Domains/index.tsx b/management-ui/src/pages/Domains/index.tsx index 0a112b5..61daa0b 100644 --- a/management-ui/src/pages/Domains/index.tsx +++ b/management-ui/src/pages/Domains/index.tsx @@ -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 { return payload.domains ?? [] } +interface BackendLite { + id: number + name: string + address: string + port: number + active: boolean +} +async function listBackends(): Promise { + 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(null) const [creating, setCreating] = useState(false) @@ -85,6 +100,16 @@ export default function DomainsPage() { const columns: ColumnsType = [ { 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 {t('domains.noBackend')} + const b = backendById(id) + return b + ? {b.name} ({b.address}:{b.port}) + : #{id} + }, + }, { 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() { + + ({ value: z, label: z }))} /> + ({ value: z, label: z }))} /> + ({ value: z, label: z }))} /> + ({ value: k, label: k }))} /> diff --git a/management-ui/src/pages/Firewall/Zones.tsx b/management-ui/src/pages/Firewall/Zones.tsx new file mode 100644 index 0000000..2cd63b5 --- /dev/null +++ b/management-ui/src/pages/Firewall/Zones.tsx @@ -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 { + 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(null) + const [creating, setCreating] = useState(false) + const [form] = Form.useForm() + + 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 = [ + { title: t('fw.zone.name'), dataIndex: 'name', key: 'name', + render: (s: string, row) => row.builtin + ? {s}{t('fw.zone.builtin')} + : {s}, + }, + { title: t('fw.zone.description'), dataIndex: 'description', key: 'description', + render: (v?: string | null) => v ?? '—' }, + { + title: t('common.actions'), key: 'actions', + render: (_, row) => ( + + + {row.builtin + ? + + + : del.mutate(row.id)} + > + + } + + ), + }, + ] + + return ( + <> + + + + { setEditing(null); setCreating(false); form.resetFields() }} + onOk={() => { void form.submit() }} + confirmLoading={upsert.isPending} + width={520} + > +
upsert.mutate(v)}> + + + + + + +
+
+ + ) +} diff --git a/management-ui/src/pages/Firewall/index.tsx b/management-ui/src/pages/Firewall/index.tsx index d12ee4c..4458c70 100644 --- a/management-ui/src/pages/Firewall/index.tsx +++ b/management-ui/src/pages/Firewall/index.tsx @@ -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: }, { key: 'nat', label: t('fw.tabs.nat'), children: }, + { key: 'zones', label: t('fw.tabs.zones'), 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: }, diff --git a/management-ui/src/pages/Firewall/types.ts b/management-ui/src/pages/Firewall/types.ts index 9ac459c..24d15a6 100644 --- a/management-ui/src/pages/Firewall/types.ts +++ b/management-ui/src/pages/Firewall/types.ts @@ -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'] diff --git a/management-ui/src/pages/Networks/index.tsx b/management-ui/src/pages/Networks/index.tsx index 908997c..2b044d1 100644 --- a/management-ui/src/pages/Networks/index.tsx +++ b/management-ui/src/pages/Networks/index.tsx @@ -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 { return (r.data.data as { interfaces?: SystemInterface[] }).interfaces ?? [] } +interface FwZone { id: number; name: string; description?: string | null; builtin: boolean } +async function listZones(): Promise { + 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(null) const [creating, setCreating] = useState(false) @@ -86,8 +94,16 @@ export default function NetworksPage() { onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) }, }) - const roleColor: Record = { - 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 = { 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 = [ @@ -105,7 +121,7 @@ export default function NetworksPage() { }, { title: t('networks.role'), dataIndex: 'role', key: 'role', - render: (r: NetworkInterface['role']) => {r.toUpperCase()}, + render: (r: string) => {r.toUpperCase()}, }, { 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 }}
- - ({ + value: z.name, + label: z.builtin ? z.name.toUpperCase() : `${z.name.toUpperCase()} (custom)`, + }))} + />