package handlers import ( "context" "errors" "fmt" "log/slog" "net" "strconv" "strings" "github.com/gin-gonic/gin" firewallrender "git.netcell-it.de/projekte/edgeguard-native/internal/firewall" "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" "github.com/jackc/pgx/v5/pgxpool" ) // FirewallHandler exposes everything under /api/v1/firewall/*: // // address-objects — primitive Adress-Definitionen (host/network/range/fqdn) // address-groups — Gruppen von address-objects (mit /members ops) // services — proto+port (Builtins lassen sich nicht editieren) // service-groups — Gruppen von services // rules — v2-Policy: zone × addr × service × action // nat-rules — separate Tabelle: dnat / snat / masquerade // // 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 ServiceGroups *firewall.ServiceGroupsRepo Rules *firewall.RulesRepo NATRules *firewall.NATRulesRepo Audit *audit.Repo NodeID string // Reloader regenerates and applies the nft ruleset. Called after // each mutation so the kernel ruleset is always in sync with the // DB. Errors are logged but don't fail the API call (the row is // already committed). Wire to internal/firewall.Generator.Render // in main.go; pass nil during tests. Reloader func(ctx context.Context) error // Pool is needed to instantiate the renderer for the // auto-rules endpoint (computes inbound rules derived from // running service config — DNS/Squid/WG listen-sockets). Pool *pgxpool.Pool } func NewFirewallHandler( zn *firewall.ZonesRepo, ao *firewall.AddressObjectsRepo, ag *firewall.AddressGroupsRepo, sv *firewall.ServicesRepo, sg *firewall.ServiceGroupsRepo, rl *firewall.RulesRepo, nat *firewall.NATRulesRepo, a *audit.Repo, nodeID string, reloader func(ctx context.Context) error, pool *pgxpool.Pool, ) *FirewallHandler { return &FirewallHandler{ Zones: zn, AddrObjects: ao, AddrGroups: ag, Services: sv, ServiceGroups: sg, Rules: rl, NATRules: nat, Audit: a, NodeID: nodeID, Reloader: reloader, Pool: pool, } } // reload runs the configured Reloader (if any). Errors don't fail // the surrounding API call — the DB row is already committed and // the operator can re-trigger via `edgeguard-ctl render-config`. func (h *FirewallHandler) reload(ctx context.Context, op string) { if h.Reloader == nil { return } if err := h.Reloader(ctx); err != nil { slog.Warn("firewall: nft reload failed", "op", op, "error", err) } } func (h *FirewallHandler) Register(rg *gin.RouterGroup) { g := rg.Group("/firewall") g.GET("/auto-rules", h.AutoRules) 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) ao.GET("/:id", h.GetAddrObj) ao.PUT("/:id", h.UpdateAddrObj) ao.DELETE("/:id", h.DeleteAddrObj) ag := g.Group("/address-groups") ag.GET("", h.ListAddrGrp) ag.POST("", h.CreateAddrGrp) ag.GET("/:id", h.GetAddrGrp) ag.PUT("/:id", h.UpdateAddrGrp) ag.DELETE("/:id", h.DeleteAddrGrp) sv := g.Group("/services") sv.GET("", h.ListService) sv.POST("", h.CreateService) sv.GET("/:id", h.GetService) sv.PUT("/:id", h.UpdateService) sv.DELETE("/:id", h.DeleteService) sg := g.Group("/service-groups") sg.GET("", h.ListSvcGrp) sg.POST("", h.CreateSvcGrp) sg.GET("/:id", h.GetSvcGrp) sg.PUT("/:id", h.UpdateSvcGrp) sg.DELETE("/:id", h.DeleteSvcGrp) rl := g.Group("/rules") rl.GET("", h.ListRule) rl.POST("", h.CreateRule) rl.GET("/:id", h.GetRule) rl.PUT("/:id", h.UpdateRule) rl.DELETE("/:id", h.DeleteRule) nat := g.Group("/nat-rules") nat.GET("", h.ListNAT) nat.POST("", h.CreateNAT) nat.GET("/:id", h.GetNAT) nat.PUT("/:id", h.UpdateNAT) nat.DELETE("/:id", h.DeleteNAT) } // AutoRules returns the inbound rules the firewall renderer derives // from the running service config (DNS/Squid/WG listen-sockets). // UI shows them in the SystemRulesCard so the operator understands // why e.g. UDP/53 is open without any operator-rule. func (h *FirewallHandler) AutoRules(c *gin.Context) { rules := firewallrender.New(h.Pool).LoadAutoRules(c.Request.Context()) response.OK(c, gin.H{"rules": rules}) } // ── 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) { out, err := h.AddrObjects.List(c.Request.Context()) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"address_objects": out}) } func (h *FirewallHandler) GetAddrObj(c *gin.Context) { id, ok := parseID(c) if !ok { return } x, err := h.AddrObjects.Get(c.Request.Context(), id) if err != nil { if errors.Is(err, firewall.ErrAddressObjectNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } response.OK(c, x) } func (h *FirewallHandler) CreateAddrObj(c *gin.Context) { var req models.FirewallAddressObject if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := validateAddrObjValue(req.Kind, req.Value); err != nil { response.BadRequest(c, err) return } out, err := h.AddrObjects.Create(c.Request.Context(), req) if err != nil { response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.create", req.Name, out, h.NodeID) response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateAddrObj(c *gin.Context) { id, ok := parseID(c) if !ok { return } var req models.FirewallAddressObject if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := validateAddrObjValue(req.Kind, req.Value); err != nil { response.BadRequest(c, err) return } out, err := h.AddrObjects.Update(c.Request.Context(), id, req) if err != nil { if errors.Is(err, firewall.ErrAddressObjectNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.update", req.Name, out, h.NodeID) response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteAddrObj(c *gin.Context) { id, ok := parseID(c) if !ok { return } if err := h.AddrObjects.Delete(c.Request.Context(), id); err != nil { if errors.Is(err, firewall.ErrAddressObjectNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_obj.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── Address Groups ───────────────────────────────────────────────────── func (h *FirewallHandler) ListAddrGrp(c *gin.Context) { out, err := h.AddrGroups.List(c.Request.Context()) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"address_groups": out}) } func (h *FirewallHandler) GetAddrGrp(c *gin.Context) { id, ok := parseID(c) if !ok { return } x, err := h.AddrGroups.Get(c.Request.Context(), id) if err != nil { if errors.Is(err, firewall.ErrAddressGroupNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } response.OK(c, x) } func (h *FirewallHandler) CreateAddrGrp(c *gin.Context) { var req models.FirewallAddressGroup if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } out, err := h.AddrGroups.Create(c.Request.Context(), req) if err != nil { response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.create", req.Name, out, h.NodeID) response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateAddrGrp(c *gin.Context) { id, ok := parseID(c) if !ok { return } var req models.FirewallAddressGroup if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } out, err := h.AddrGroups.Update(c.Request.Context(), id, req) if err != nil { if errors.Is(err, firewall.ErrAddressGroupNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.update", req.Name, out, h.NodeID) response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteAddrGrp(c *gin.Context) { id, ok := parseID(c) if !ok { return } if err := h.AddrGroups.Delete(c.Request.Context(), id); err != nil { if errors.Is(err, firewall.ErrAddressGroupNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.addr_grp.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── Services ─────────────────────────────────────────────────────────── func (h *FirewallHandler) ListService(c *gin.Context) { out, err := h.Services.List(c.Request.Context()) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"services": out}) } func (h *FirewallHandler) GetService(c *gin.Context) { id, ok := parseID(c) if !ok { return } x, err := h.Services.Get(c.Request.Context(), id) if err != nil { if errors.Is(err, firewall.ErrServiceNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } response.OK(c, x) } func (h *FirewallHandler) CreateService(c *gin.Context) { var req models.FirewallService if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } out, err := h.Services.Create(c.Request.Context(), req) if err != nil { response.BadRequest(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.create", req.Name, out, h.NodeID) response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateService(c *gin.Context) { id, ok := parseID(c) if !ok { return } var req models.FirewallService if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } out, err := h.Services.Update(c.Request.Context(), id, req) if err != nil { if errors.Is(err, firewall.ErrServiceNotFound) { response.NotFound(c, err) return } response.BadRequest(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.update", req.Name, out, h.NodeID) response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteService(c *gin.Context) { id, ok := parseID(c) if !ok { return } if err := h.Services.Delete(c.Request.Context(), id); err != nil { if errors.Is(err, firewall.ErrServiceNotFound) { response.NotFound(c, err) return } response.BadRequest(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.service.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── Service Groups ───────────────────────────────────────────────────── func (h *FirewallHandler) ListSvcGrp(c *gin.Context) { out, err := h.ServiceGroups.List(c.Request.Context()) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"service_groups": out}) } func (h *FirewallHandler) GetSvcGrp(c *gin.Context) { id, ok := parseID(c) if !ok { return } x, err := h.ServiceGroups.Get(c.Request.Context(), id) if err != nil { if errors.Is(err, firewall.ErrServiceGroupNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } response.OK(c, x) } func (h *FirewallHandler) CreateSvcGrp(c *gin.Context) { var req models.FirewallServiceGroup if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } out, err := h.ServiceGroups.Create(c.Request.Context(), req) if err != nil { response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.create", req.Name, out, h.NodeID) response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateSvcGrp(c *gin.Context) { id, ok := parseID(c) if !ok { return } var req models.FirewallServiceGroup if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } out, err := h.ServiceGroups.Update(c.Request.Context(), id, req) if err != nil { if errors.Is(err, firewall.ErrServiceGroupNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.update", req.Name, out, h.NodeID) response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteSvcGrp(c *gin.Context) { id, ok := parseID(c) if !ok { return } if err := h.ServiceGroups.Delete(c.Request.Context(), id); err != nil { if errors.Is(err, firewall.ErrServiceGroupNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.svc_grp.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── Rules ────────────────────────────────────────────────────────────── func (h *FirewallHandler) ListRule(c *gin.Context) { out, err := h.Rules.List(c.Request.Context()) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"rules": out}) } func (h *FirewallHandler) GetRule(c *gin.Context) { id, ok := parseID(c) if !ok { return } x, err := h.Rules.Get(c.Request.Context(), id) if err != nil { if errors.Is(err, firewall.ErrRuleNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } response.OK(c, x) } func (h *FirewallHandler) CreateRule(c *gin.Context) { var req models.FirewallRule if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := validateRule(req); err != nil { 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) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.create", strconv.FormatInt(out.ID, 10), out, h.NodeID) response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateRule(c *gin.Context) { id, ok := parseID(c) if !ok { return } var req models.FirewallRule if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := validateRule(req); err != nil { 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) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.update", strconv.FormatInt(id, 10), out, h.NodeID) response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteRule(c *gin.Context) { id, ok := parseID(c) if !ok { return } if err := h.Rules.Delete(c.Request.Context(), id); err != nil { if errors.Is(err, firewall.ErrRuleNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.rule.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── NAT Rules ────────────────────────────────────────────────────────── func (h *FirewallHandler) ListNAT(c *gin.Context) { out, err := h.NATRules.List(c.Request.Context()) if err != nil { response.Internal(c, err) return } response.OK(c, gin.H{"nat_rules": out}) } func (h *FirewallHandler) GetNAT(c *gin.Context) { id, ok := parseID(c) if !ok { return } x, err := h.NATRules.Get(c.Request.Context(), id) if err != nil { if errors.Is(err, firewall.ErrNATRuleNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } response.OK(c, x) } func (h *FirewallHandler) CreateNAT(c *gin.Context) { var req models.FirewallNATRule if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := validateNAT(req); err != nil { 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) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.create", strconv.FormatInt(out.ID, 10), out, h.NodeID) response.Created(c, out); h.reload(c.Request.Context(), "create") } func (h *FirewallHandler) UpdateNAT(c *gin.Context) { id, ok := parseID(c) if !ok { return } var req models.FirewallNATRule if err := c.ShouldBindJSON(&req); err != nil { response.BadRequest(c, err) return } if err := validateNAT(req); err != nil { 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) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.update", strconv.FormatInt(id, 10), out, h.NodeID) response.OK(c, out); h.reload(c.Request.Context(), "update") } func (h *FirewallHandler) DeleteNAT(c *gin.Context) { id, ok := parseID(c) if !ok { return } if err := h.NATRules.Delete(c.Request.Context(), id); err != nil { if errors.Is(err, firewall.ErrNATRuleNotFound) { response.NotFound(c, err) return } response.Internal(c, err) return } _ = h.Audit.Log(c.Request.Context(), actorOf(c), "fw.nat.delete", strconv.FormatInt(id, 10), gin.H{"id": id}, h.NodeID) response.NoContent(c); h.reload(c.Request.Context(), "delete") } // ── Validators ───────────────────────────────────────────────────────── func validateAddrObjValue(kind, value string) error { switch kind { case "host": if net.ParseIP(value) == nil { return fmt.Errorf("host: %q is not a valid IPv4/IPv6 address", value) } case "network": if _, _, err := net.ParseCIDR(value); err != nil { return fmt.Errorf("network: %q is not a valid CIDR (%w)", value, err) } case "range": parts := strings.SplitN(value, "-", 2) if len(parts) != 2 || net.ParseIP(strings.TrimSpace(parts[0])) == nil || net.ParseIP(strings.TrimSpace(parts[1])) == nil { return fmt.Errorf("range: %q must be 'IP-IP'", value) } case "fqdn": if !looksLikeFQDN(value) { return fmt.Errorf("fqdn: %q does not look like a hostname", value) } default: return fmt.Errorf("unknown address-object kind %q", kind) } return nil } func looksLikeFQDN(s string) bool { s = strings.TrimSuffix(strings.TrimSpace(s), ".") if s == "" || len(s) > 253 || !strings.Contains(s, ".") { return false } for _, label := range strings.Split(s, ".") { if len(label) == 0 || len(label) > 63 { return false } for _, r := range label { ok := r == '-' || (r >= '0' && r <= '9') || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') if !ok { return false } } if label[0] == '-' || label[len(label)-1] == '-' { return false } } return true } // validateRule enforces "exactly one or zero" of the three address // pointers per side, and "exactly one or zero" service references. func validateRule(r models.FirewallRule) error { if r.Action == "" { return errors.New("action is required") } if got := countNonNil(r.SrcAddressObjectID, r.SrcAddressGroupID, optStr(r.SrcCIDR)); got > 1 { return errors.New("source: at most one of src_address_object_id / src_address_group_id / src_cidr") } if got := countNonNil(r.DstAddressObjectID, r.DstAddressGroupID, optStr(r.DstCIDR)); got > 1 { return errors.New("destination: at most one of dst_address_object_id / dst_address_group_id / dst_cidr") } if r.ServiceObjectID != nil && r.ServiceGroupID != nil { return errors.New("service: at most one of service_object_id / service_group_id") } if r.SrcCIDR != nil { if _, _, err := net.ParseCIDR(*r.SrcCIDR); err != nil { return fmt.Errorf("src_cidr: %w", err) } } if r.DstCIDR != nil { if _, _, err := net.ParseCIDR(*r.DstCIDR); err != nil { return fmt.Errorf("dst_cidr: %w", err) } } return nil } func validateNAT(n models.FirewallNATRule) error { switch n.Kind { case "dnat": if n.TargetAddr == nil || *n.TargetAddr == "" { return errors.New("dnat: target_addr is required") } if n.MatchDPortStart == nil { return errors.New("dnat: match_dport_start is required (which incoming port to forward)") } case "snat": if n.TargetAddr == nil || *n.TargetAddr == "" { return errors.New("snat: target_addr is required (the address to rewrite to)") } case "masquerade": if n.OutZone == nil || *n.OutZone == "" { return errors.New("masquerade: out_zone is required (which interface group's IP to use)") } if n.TargetAddr != nil && *n.TargetAddr != "" { return errors.New("masquerade: target_addr must be empty (uses out-iface IP)") } default: return fmt.Errorf("unknown NAT kind %q", n.Kind) } 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 { n := 0 for _, a := range args { switch v := a.(type) { case *int64: if v != nil { n++ } case *string: if v != nil && *v != "" { n++ } case nil: // skip default: n++ } } return n } func optStr(p *string) *string { return p }