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:
@@ -35,7 +35,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/tlscerts"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.3"
|
var version = "1.0.6"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
addr := os.Getenv("EDGEGUARD_API_ADDR")
|
||||||
@@ -127,6 +127,7 @@ func main() {
|
|||||||
ifsRepo := networkifs.New(pool)
|
ifsRepo := networkifs.New(pool)
|
||||||
ipsRepo := ipaddresses.New(pool)
|
ipsRepo := ipaddresses.New(pool)
|
||||||
tlsRepo := tlscerts.New(pool)
|
tlsRepo := tlscerts.New(pool)
|
||||||
|
fwZones := firewall.NewZonesRepo(pool)
|
||||||
fwAddrObj := firewall.NewAddressObjectsRepo(pool)
|
fwAddrObj := firewall.NewAddressObjectsRepo(pool)
|
||||||
fwAddrGrp := firewall.NewAddressGroupsRepo(pool)
|
fwAddrGrp := firewall.NewAddressGroupsRepo(pool)
|
||||||
fwSvc := firewall.NewServicesRepo(pool)
|
fwSvc := firewall.NewServicesRepo(pool)
|
||||||
@@ -147,7 +148,7 @@ func main() {
|
|||||||
handlers.NewDomainsHandler(domainsRepo, routingRepo, auditRepo, nodeID).Register(authed)
|
handlers.NewDomainsHandler(domainsRepo, routingRepo, auditRepo, nodeID).Register(authed)
|
||||||
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID).Register(authed)
|
handlers.NewBackendsHandler(backendsRepo, auditRepo, nodeID).Register(authed)
|
||||||
handlers.NewRoutingRulesHandler(routingRepo, 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.NewIPAddressesHandler(ipsRepo, auditRepo, nodeID).Register(authed)
|
||||||
handlers.NewClusterHandler(clusterStore, nodeID).Register(authed)
|
handlers.NewClusterHandler(clusterStore, nodeID).Register(authed)
|
||||||
handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed)
|
handlers.NewTLSCertsHandler(tlsRepo, auditRepo, nodeID, acmeService).Register(authed)
|
||||||
@@ -156,7 +157,7 @@ func main() {
|
|||||||
fwReloader := func(ctx context.Context) error {
|
fwReloader := func(ctx context.Context) error {
|
||||||
return firewallrender.New(pool).Render(ctx)
|
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)
|
mountUI(r)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.3"
|
var version = "1.0.6"
|
||||||
|
|
||||||
const usage = `edgeguard-ctl — EdgeGuard CLI
|
const usage = `edgeguard-ctl — EdgeGuard CLI
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
var version = "1.0.3"
|
var version = "1.0.6"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
log.Printf("edgeguard-scheduler %s starting", version)
|
log.Printf("edgeguard-scheduler %s starting", version)
|
||||||
|
|||||||
48
internal/database/migrations/0012_firewall_zones.sql
Normal file
48
internal/database/migrations/0012_firewall_zones.sql
Normal 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
|
||||||
@@ -29,6 +29,7 @@ import (
|
|||||||
// Validation lebt im Handler (DB lässt mehr zu als die Anwendung erlauben
|
// Validation lebt im Handler (DB lässt mehr zu als die Anwendung erlauben
|
||||||
// will — exactly-one-of-Constraints sind in Postgres mühsam).
|
// will — exactly-one-of-Constraints sind in Postgres mühsam).
|
||||||
type FirewallHandler struct {
|
type FirewallHandler struct {
|
||||||
|
Zones *firewall.ZonesRepo
|
||||||
AddrObjects *firewall.AddressObjectsRepo
|
AddrObjects *firewall.AddressObjectsRepo
|
||||||
AddrGroups *firewall.AddressGroupsRepo
|
AddrGroups *firewall.AddressGroupsRepo
|
||||||
Services *firewall.ServicesRepo
|
Services *firewall.ServicesRepo
|
||||||
@@ -47,6 +48,7 @@ type FirewallHandler struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func NewFirewallHandler(
|
func NewFirewallHandler(
|
||||||
|
zn *firewall.ZonesRepo,
|
||||||
ao *firewall.AddressObjectsRepo,
|
ao *firewall.AddressObjectsRepo,
|
||||||
ag *firewall.AddressGroupsRepo,
|
ag *firewall.AddressGroupsRepo,
|
||||||
sv *firewall.ServicesRepo,
|
sv *firewall.ServicesRepo,
|
||||||
@@ -58,6 +60,7 @@ func NewFirewallHandler(
|
|||||||
reloader func(ctx context.Context) error,
|
reloader func(ctx context.Context) error,
|
||||||
) *FirewallHandler {
|
) *FirewallHandler {
|
||||||
return &FirewallHandler{
|
return &FirewallHandler{
|
||||||
|
Zones: zn,
|
||||||
AddrObjects: ao, AddrGroups: ag,
|
AddrObjects: ao, AddrGroups: ag,
|
||||||
Services: sv, ServiceGroups: sg,
|
Services: sv, ServiceGroups: sg,
|
||||||
Rules: rl, NATRules: nat,
|
Rules: rl, NATRules: nat,
|
||||||
@@ -82,6 +85,13 @@ func (h *FirewallHandler) reload(ctx context.Context, op string) {
|
|||||||
func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
||||||
g := rg.Group("/firewall")
|
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 := g.Group("/address-objects")
|
||||||
ao.GET("", h.ListAddrObj)
|
ao.GET("", h.ListAddrObj)
|
||||||
ao.POST("", h.CreateAddrObj)
|
ao.POST("", h.CreateAddrObj)
|
||||||
@@ -125,6 +135,155 @@ func (h *FirewallHandler) Register(rg *gin.RouterGroup) {
|
|||||||
nat.DELETE("/:id", h.DeleteNAT)
|
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 ────────────────────────────────────────────────────
|
// ── Address Objects ────────────────────────────────────────────────────
|
||||||
|
|
||||||
func (h *FirewallHandler) ListAddrObj(c *gin.Context) {
|
func (h *FirewallHandler) ListAddrObj(c *gin.Context) {
|
||||||
@@ -507,6 +666,14 @@ func (h *FirewallHandler) CreateRule(c *gin.Context) {
|
|||||||
response.BadRequest(c, err)
|
response.BadRequest(c, err)
|
||||||
return
|
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)
|
out, err := h.Rules.Create(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
@@ -530,6 +697,14 @@ func (h *FirewallHandler) UpdateRule(c *gin.Context) {
|
|||||||
response.BadRequest(c, err)
|
response.BadRequest(c, err)
|
||||||
return
|
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)
|
out, err := h.Rules.Update(c.Request.Context(), id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, firewall.ErrRuleNotFound) {
|
if errors.Is(err, firewall.ErrRuleNotFound) {
|
||||||
@@ -599,6 +774,10 @@ func (h *FirewallHandler) CreateNAT(c *gin.Context) {
|
|||||||
response.BadRequest(c, err)
|
response.BadRequest(c, err)
|
||||||
return
|
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)
|
out, err := h.NATRules.Create(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
@@ -622,6 +801,10 @@ func (h *FirewallHandler) UpdateNAT(c *gin.Context) {
|
|||||||
response.BadRequest(c, err)
|
response.BadRequest(c, err)
|
||||||
return
|
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)
|
out, err := h.NATRules.Update(c.Request.Context(), id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, firewall.ErrNATRuleNotFound) {
|
if errors.Is(err, firewall.ErrNATRuleNotFound) {
|
||||||
@@ -759,6 +942,23 @@ func validateNAT(n models.FirewallNATRule) error {
|
|||||||
return nil
|
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
|
// countNonNil counts how many of the supplied pointers are non-nil
|
||||||
// (and, for *string, non-empty). Used by validateRule.
|
// (and, for *string, non-empty). Used by validateRule.
|
||||||
func countNonNil(args ...any) int {
|
func countNonNil(args ...any) int {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/handlers/response"
|
"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/models"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/audit"
|
"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/ipaddresses"
|
||||||
"git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
|
"git.netcell-it.de/projekte/edgeguard-native/internal/services/networkifs"
|
||||||
)
|
)
|
||||||
@@ -16,12 +17,16 @@ import (
|
|||||||
type NetworksHandler struct {
|
type NetworksHandler struct {
|
||||||
Repo *networkifs.Repo
|
Repo *networkifs.Repo
|
||||||
IPs *ipaddresses.Repo
|
IPs *ipaddresses.Repo
|
||||||
|
Zones *firewall.ZonesRepo
|
||||||
Audit *audit.Repo
|
Audit *audit.Repo
|
||||||
NodeID string
|
NodeID string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewNetworksHandler(repo *networkifs.Repo, ips *ipaddresses.Repo, a *audit.Repo, nodeID string) *NetworksHandler {
|
func NewNetworksHandler(
|
||||||
return &NetworksHandler{Repo: repo, IPs: ips, Audit: a, NodeID: nodeID}
|
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) {
|
func (h *NetworksHandler) Register(rg *gin.RouterGroup) {
|
||||||
@@ -70,6 +75,13 @@ func (h *NetworksHandler) Create(c *gin.Context) {
|
|||||||
response.BadRequest(c, err)
|
response.BadRequest(c, err)
|
||||||
return
|
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)
|
out, err := h.Repo.Create(c.Request.Context(), req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Internal(c, err)
|
response.Internal(c, err)
|
||||||
@@ -93,6 +105,13 @@ func (h *NetworksHandler) Update(c *gin.Context) {
|
|||||||
response.BadRequest(c, err)
|
response.BadRequest(c, err)
|
||||||
return
|
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)
|
out, err := h.Repo.Update(c.Request.Context(), id, req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, networkifs.ErrNotFound) {
|
if errors.Is(err, networkifs.ErrNotFound) {
|
||||||
|
|||||||
@@ -70,5 +70,5 @@ backend api_backend
|
|||||||
{{- range .Backends}}
|
{{- range .Backends}}
|
||||||
|
|
||||||
backend eg_backend_{{.ID}}
|
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}}
|
{{- end}}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
_ "embed"
|
_ "embed"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"text/template"
|
"text/template"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -25,7 +26,33 @@ import (
|
|||||||
//go:embed haproxy.cfg.tpl
|
//go:embed haproxy.cfg.tpl
|
||||||
var cfgTpl string
|
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 {
|
type Generator struct {
|
||||||
Pool *pgxpool.Pool
|
Pool *pgxpool.Pool
|
||||||
|
|||||||
14
internal/models/firewall_zone.go
Normal file
14
internal/models/firewall_zone.go
Normal 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" }
|
||||||
147
internal/services/firewall/zones.go
Normal file
147
internal/services/firewall/zones.go
Normal 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
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "edgeguard-management-ui",
|
"name": "edgeguard-management-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.3",
|
"version": "1.0.6",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ const NAV: NavSection[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const VERSION = '1.0.3'
|
const VERSION = '1.0.6'
|
||||||
|
|
||||||
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
export default function Sidebar({ isOpen, onClose }: SidebarProps) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|||||||
@@ -29,11 +29,23 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"rules": "Regeln",
|
"rules": "Regeln",
|
||||||
"nat": "NAT",
|
"nat": "NAT",
|
||||||
|
"zones": "Zonen",
|
||||||
"addrObj": "Adress-Objekte",
|
"addrObj": "Adress-Objekte",
|
||||||
"addrGrp": "Adress-Gruppen",
|
"addrGrp": "Adress-Gruppen",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
"svcGrp": "Service-Gruppen"
|
"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": {
|
"ao": {
|
||||||
"name": "Name", "kind": "Typ", "value": "Wert", "description": "Beschreibung",
|
"name": "Name", "kind": "Typ", "value": "Wert", "description": "Beschreibung",
|
||||||
"add": "Adress-Objekt hinzufügen", "edit": "Adress-Objekt bearbeiten",
|
"add": "Adress-Objekt hinzufügen", "edit": "Adress-Objekt bearbeiten",
|
||||||
@@ -110,7 +122,8 @@
|
|||||||
"membersRequired": "Mindestens ein Member-Interface erforderlich",
|
"membersRequired": "Mindestens ein Member-Interface erforderlich",
|
||||||
"membersHintBridge": "Eine Bridge bündelt mehrere physische Ports auf L2 — typisch zwei Ports für einen Software-Switch.",
|
"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).",
|
"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",
|
"mtu": "MTU",
|
||||||
"active": "Aktiv",
|
"active": "Aktiv",
|
||||||
"description": "Beschreibung",
|
"description": "Beschreibung",
|
||||||
@@ -170,6 +183,9 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"active": "Aktiv",
|
"active": "Aktiv",
|
||||||
"primaryBackend": "Primary-Backend",
|
"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",
|
"httpToHttps": "HTTP→HTTPS",
|
||||||
"hsts": "HSTS",
|
"hsts": "HSTS",
|
||||||
"notes": "Notizen",
|
"notes": "Notizen",
|
||||||
@@ -189,6 +205,8 @@
|
|||||||
"target": "Ziel",
|
"target": "Ziel",
|
||||||
"healthCheck": "Health-Check-Pfad",
|
"healthCheck": "Health-Check-Pfad",
|
||||||
"active": "Aktiv",
|
"active": "Aktiv",
|
||||||
|
"usedBy": "Genutzt von",
|
||||||
|
"noDomain": "keine Domain",
|
||||||
"actions": "Aktionen",
|
"actions": "Aktionen",
|
||||||
"deleteConfirm": "Backend {{name}} wirklich löschen?"
|
"deleteConfirm": "Backend {{name}} wirklich löschen?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -29,11 +29,23 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"rules": "Rules",
|
"rules": "Rules",
|
||||||
"nat": "NAT",
|
"nat": "NAT",
|
||||||
|
"zones": "Zones",
|
||||||
"addrObj": "Address objects",
|
"addrObj": "Address objects",
|
||||||
"addrGrp": "Address groups",
|
"addrGrp": "Address groups",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
"svcGrp": "Service groups"
|
"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": {
|
"ao": {
|
||||||
"name": "Name", "kind": "Kind", "value": "Value", "description": "Description",
|
"name": "Name", "kind": "Kind", "value": "Value", "description": "Description",
|
||||||
"add": "Add address object", "edit": "Edit address object",
|
"add": "Add address object", "edit": "Edit address object",
|
||||||
@@ -110,7 +122,8 @@
|
|||||||
"membersRequired": "At least one member interface is required",
|
"membersRequired": "At least one member interface is required",
|
||||||
"membersHintBridge": "A bridge joins multiple physical ports at L2 — typically two ports for a software switch.",
|
"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).",
|
"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",
|
"mtu": "MTU",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
@@ -170,6 +183,9 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"primaryBackend": "Primary backend",
|
"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",
|
"httpToHttps": "HTTP→HTTPS",
|
||||||
"hsts": "HSTS",
|
"hsts": "HSTS",
|
||||||
"notes": "Notes",
|
"notes": "Notes",
|
||||||
@@ -189,6 +205,8 @@
|
|||||||
"target": "Target",
|
"target": "Target",
|
||||||
"healthCheck": "Health check path",
|
"healthCheck": "Health check path",
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
|
"usedBy": "Used by",
|
||||||
|
"noDomain": "no domain",
|
||||||
"actions": "Actions",
|
"actions": "Actions",
|
||||||
"deleteConfirm": "Really delete backend {{name}}?"
|
"deleteConfirm": "Really delete backend {{name}}?"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -35,11 +35,31 @@ async function listBackends(): Promise<Backend[]> {
|
|||||||
return payload.backends ?? []
|
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() {
|
export default function BackendsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['backends'], queryFn: listBackends })
|
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 [editing, setEditing] = useState<Backend | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
@@ -82,6 +102,14 @@ export default function BackendsPage() {
|
|||||||
render: (_, row) => `${row.address}:${row.port}`,
|
render: (_, row) => `${row.address}:${row.port}`,
|
||||||
},
|
},
|
||||||
{ title: t('backends.healthCheck'), dataIndex: 'health_check_path', key: 'hc', render: (v?: string) => v ?? '—' },
|
{ 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.active'), dataIndex: 'active', key: 'active', render: (v: boolean) => v ? t('common.yes') : t('common.no') },
|
||||||
{
|
{
|
||||||
title: t('backends.actions'), key: 'actions',
|
title: t('backends.actions'), key: 'actions',
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState } from 'react'
|
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 type { ColumnsType } from 'antd/es/table'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -35,6 +35,19 @@ async function listDomains(): Promise<Domain[]> {
|
|||||||
return payload.domains ?? []
|
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() {
|
export default function DomainsPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
@@ -43,6 +56,8 @@ export default function DomainsPage() {
|
|||||||
queryKey: ['domains'],
|
queryKey: ['domains'],
|
||||||
queryFn: listDomains,
|
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 [editing, setEditing] = useState<Domain | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
@@ -85,6 +100,16 @@ export default function DomainsPage() {
|
|||||||
|
|
||||||
const columns: ColumnsType<Domain> = [
|
const columns: ColumnsType<Domain> = [
|
||||||
{ title: t('domains.name'), dataIndex: 'name', key: 'name' },
|
{ 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.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.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') },
|
{ 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 }]}>
|
<Form.Item label={t('domains.name')} name="name" rules={[{ required: true }]}>
|
||||||
<Input placeholder="example.com" />
|
<Input placeholder="example.com" />
|
||||||
</Form.Item>
|
</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">
|
<Form.Item label={t('domains.active')} name="active" valuePropName="checked">
|
||||||
<Switch />
|
<Switch />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import DataTable from '../../components/DataTable'
|
import DataTable from '../../components/DataTable'
|
||||||
|
|
||||||
import apiClient, { isEnvelope } from '../../api/client'
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
import type { NATRule } from './types'
|
import type { FwZone, NATRule } from './types'
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -26,13 +26,16 @@ interface FormValues {
|
|||||||
comment?: string
|
comment?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const ZONES_FOR_NAT = ['wan', 'lan', 'dmz', 'mgmt', 'cluster'] as const
|
|
||||||
|
|
||||||
async function listNAT(): Promise<NATRule[]> {
|
async function listNAT(): Promise<NATRule[]> {
|
||||||
const r = await apiClient.get('/firewall/nat-rules')
|
const r = await apiClient.get('/firewall/nat-rules')
|
||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return []
|
||||||
return (r.data.data as { nat_rules?: NATRule[] }).nat_rules ?? []
|
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> = {
|
const KIND_COLORS: Record<NATRule['kind'], string> = {
|
||||||
dnat: 'blue',
|
dnat: 'blue',
|
||||||
@@ -44,6 +47,14 @@ export default function NATRulesTab() {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
const { data, isLoading } = useQuery({ queryKey: ['fw', 'nat'], queryFn: listNAT })
|
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 [editing, setEditing] = useState<NATRule | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
@@ -165,12 +176,12 @@ export default function NATRulesTab() {
|
|||||||
<>
|
<>
|
||||||
{kind === 'dnat' && (
|
{kind === 'dnat' && (
|
||||||
<Form.Item label={t('fw.nat.inZone')} name="in_zone" rules={[{ required: true }]}>
|
<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>
|
</Form.Item>
|
||||||
)}
|
)}
|
||||||
{(kind === 'snat' || kind === 'masquerade') && (
|
{(kind === 'snat' || kind === 'masquerade') && (
|
||||||
<Form.Item label={t('fw.nat.outZone')} name="out_zone" rules={[{ required: true }]}>
|
<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>
|
||||||
)}
|
)}
|
||||||
<Form.Item label={t('fw.nat.proto')} name="proto">
|
<Form.Item label={t('fw.nat.proto')} name="proto">
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import DataTable from '../../components/DataTable'
|
|||||||
import SystemRulesCard from './SystemRules'
|
import SystemRulesCard from './SystemRules'
|
||||||
|
|
||||||
import apiClient, { isEnvelope } from '../../api/client'
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
import type { AddressGroup, AddressObject, FwRule, FwService, ServiceGroup, Zone } from './types'
|
import type { AddressGroup, AddressObject, FwRule, FwService, FwZone, ServiceGroup, Zone } from './types'
|
||||||
import { ZONES } from './types'
|
import { ZONES_FALLBACK } from './types'
|
||||||
|
|
||||||
interface FormValues {
|
interface FormValues {
|
||||||
name?: string
|
name?: string
|
||||||
@@ -63,6 +63,11 @@ async function listSG(): Promise<ServiceGroup[]> {
|
|||||||
if (!isEnvelope(r.data)) return []
|
if (!isEnvelope(r.data)) return []
|
||||||
return (r.data.data as { service_groups?: ServiceGroup[] }).service_groups ?? []
|
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) {
|
function buildPayload(v: FormValues) {
|
||||||
const out: Partial<FwRule> = {
|
const out: Partial<FwRule> = {
|
||||||
@@ -91,6 +96,13 @@ export default function RulesTab() {
|
|||||||
const { data: ags } = useQuery({ queryKey: ['fw', 'addr-grp'], queryFn: listAG })
|
const { data: ags } = useQuery({ queryKey: ['fw', 'addr-grp'], queryFn: listAG })
|
||||||
const { data: svs } = useQuery({ queryKey: ['fw', 'svc'], queryFn: listSv })
|
const { data: svs } = useQuery({ queryKey: ['fw', 'svc'], queryFn: listSv })
|
||||||
const { data: sgs } = useQuery({ queryKey: ['fw', 'svc-grp'], queryFn: listSG })
|
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 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 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) => (
|
{(['src', 'dst'] as const).map((side) => (
|
||||||
<Space key={side} size="middle" style={{ display: 'flex', flexWrap: 'wrap', alignItems: 'flex-start' }}>
|
<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 }]}>
|
<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>
|
||||||
<Form.Item label={t(`fw.rule.${side}Kind`)} name={`${side}_kind`} rules={[{ required: true }]}>
|
<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 }))} />
|
<Select style={{ width: 120 }} options={(['any','object','group','cidr'] as const).map(k => ({ value: k, label: k }))} />
|
||||||
|
|||||||
116
management-ui/src/pages/Firewall/Zones.tsx
Normal file
116
management-ui/src/pages/Firewall/Zones.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import ServicesTab from './Services'
|
|||||||
import ServiceGroupsTab from './ServiceGroups'
|
import ServiceGroupsTab from './ServiceGroups'
|
||||||
import RulesTab from './Rules'
|
import RulesTab from './Rules'
|
||||||
import NATRulesTab from './NATRules'
|
import NATRulesTab from './NATRules'
|
||||||
|
import ZonesTab from './Zones'
|
||||||
|
|
||||||
export default function FirewallPage() {
|
export default function FirewallPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -14,6 +15,7 @@ export default function FirewallPage() {
|
|||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'rules', label: t('fw.tabs.rules'), children: <RulesTab /> },
|
{ key: 'rules', label: t('fw.tabs.rules'), children: <RulesTab /> },
|
||||||
{ key: 'nat', label: t('fw.tabs.nat'), children: <NATRulesTab /> },
|
{ 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: 'addrObj', label: t('fw.tabs.addrObj'), children: <AddressObjectsTab /> },
|
||||||
{ key: 'addrGrp', label: t('fw.tabs.addrGrp'), children: <AddressGroupsTab /> },
|
{ key: 'addrGrp', label: t('fw.tabs.addrGrp'), children: <AddressGroupsTab /> },
|
||||||
{ key: 'services', label: t('fw.tabs.services'), children: <ServicesTab /> },
|
{ key: 'services', label: t('fw.tabs.services'), children: <ServicesTab /> },
|
||||||
|
|||||||
@@ -36,7 +36,20 @@ export interface ServiceGroup {
|
|||||||
member_ids?: number[]
|
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 {
|
export interface FwRule {
|
||||||
id: number
|
id: number
|
||||||
@@ -77,4 +90,7 @@ export interface NATRule {
|
|||||||
comment?: string | null
|
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']
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ interface NetworkInterface {
|
|||||||
parent?: string | null
|
parent?: string | null
|
||||||
vlan_id?: number | null
|
vlan_id?: number | null
|
||||||
members: string[]
|
members: string[]
|
||||||
role: 'wan' | 'lan' | 'dmz' | 'mgmt' | 'cluster'
|
role: string
|
||||||
mtu?: number | null
|
mtu?: number | null
|
||||||
active: boolean
|
active: boolean
|
||||||
description?: string | null
|
description?: string | null
|
||||||
@@ -28,7 +28,7 @@ interface IfaceFormValues {
|
|||||||
parent?: string
|
parent?: string
|
||||||
vlan_id?: number
|
vlan_id?: number
|
||||||
members?: string[]
|
members?: string[]
|
||||||
role: NetworkInterface['role']
|
role: string
|
||||||
mtu?: number
|
mtu?: number
|
||||||
active: boolean
|
active: boolean
|
||||||
description?: string
|
description?: string
|
||||||
@@ -54,12 +54,20 @@ async function listSystemInterfaces(): Promise<SystemInterface[]> {
|
|||||||
return (r.data.data as { interfaces?: SystemInterface[] }).interfaces ?? []
|
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() {
|
export default function NetworksPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const { data: ifs, isLoading } = useQuery({ queryKey: ['network-interfaces'], queryFn: listInterfaces })
|
const { data: ifs, isLoading } = useQuery({ queryKey: ['network-interfaces'], queryFn: listInterfaces })
|
||||||
const { data: sys } = useQuery({ queryKey: ['system', 'interfaces'], queryFn: listSystemInterfaces, refetchInterval: 60_000 })
|
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 [editing, setEditing] = useState<NetworkInterface | null>(null)
|
||||||
const [creating, setCreating] = useState(false)
|
const [creating, setCreating] = useState(false)
|
||||||
@@ -86,8 +94,16 @@ export default function NetworksPage() {
|
|||||||
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) },
|
onSuccess: () => { void qc.invalidateQueries({ queryKey: ['network-interfaces'] }) },
|
||||||
})
|
})
|
||||||
|
|
||||||
const roleColor: Record<NetworkInterface['role'], string> = {
|
// Stable colour palette for role tags. Builtin zones get a fixed
|
||||||
wan: 'blue', lan: 'green', dmz: 'orange', mgmt: 'purple', cluster: 'magenta',
|
// 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> = [
|
const columns: ColumnsType<NetworkInterface> = [
|
||||||
@@ -105,7 +121,7 @@ export default function NetworksPage() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: t('networks.role'), dataIndex: 'role', key: 'role',
|
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.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') },
|
{ 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
|
return null
|
||||||
}}
|
}}
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item label={t('networks.role')} name="role" rules={[{ required: true }]}>
|
<Form.Item
|
||||||
<Select options={(['wan','lan','dmz','mgmt','cluster'] as const).map(r => ({ value: r, label: r.toUpperCase() }))} />
|
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>
|
||||||
<Form.Item label={t('networks.mtu')} name="mtu">
|
<Form.Item label={t('networks.mtu')} name="mtu">
|
||||||
<InputNumber min={68} max={9216} style={{ width: '100%' }} placeholder="1500" />
|
<InputNumber min={68} max={9216} style={{ width: '100%' }} placeholder="1500" />
|
||||||
|
|||||||
Reference in New Issue
Block a user