// Package model holds the fleet control-plane domain types and the // shorewall-style source/dest element grammar shared by the API and compiler. package model import ( "fmt" "strings" "time" ) // DeviceClass is either a routed-core member or a zone-boundary firewall. type DeviceClass string const ( ClassRouter DeviceClass = "router" ClassFirewall DeviceClass = "firewall" ) // AddressGroupType selects how an address group's nftables set is populated. type AddressGroupType string const ( GroupStatic AddressGroupType = "static" // explicit CIDRs, populated by the API GroupDNS AddressGroupType = "dns" // FQDNs, resolved on-device GroupASN AddressGroupType = "asn" // ASNs, expanded centrally via iplocate ) // Settings holds fleet-wide defaults. Individual devices may override a subset // via their per-device settings. type Settings struct { AddressFamily string `json:"address_family"` LogLevel string `json:"log_level"` IPForwarding bool `json:"ip_forwarding"` TableName string `json:"table_name"` DefaultResolver []string `json:"default_resolver"` } // PortGroup is a reusable proto+ports combo referenced by rules. type PortGroup struct { Name string `json:"name"` Proto string `json:"proto"` Ports []string `json:"ports"` } // Fabric is a routing domain. EnforceOnRouters toggles defense-in-depth (every // router carries the intent) vs transparent transit (only boundary firewalls do). type Fabric struct { Name string `json:"name"` EnforceOnRouters bool `json:"enforce_on_routers"` Description string `json:"description,omitempty"` } // Policy is a fleet-global default zone-to-zone posture. Lower priority evaluates // first (first match wins). type Policy struct { ID int64 `json:"id"` Priority int `json:"priority"` Source string `json:"source"` Dest string `json:"dest"` Action string `json:"action"` Log string `json:"log,omitempty"` } // Zone is a fleet-global network segment. type Zone struct { Name string `json:"name" yaml:"-"` Type string `json:"type" yaml:"type"` Subnets []string `json:"subnets" yaml:"-"` Parent string `json:"parent,omitempty" yaml:"parents,omitempty"` } // AddressGroup materializes an nftables named set. type AddressGroup struct { Name string `json:"name"` Type AddressGroupType `json:"type"` Members []string `json:"members"` Refresh string `json:"refresh,omitempty"` Description string `json:"description,omitempty"` // Resolved holds concrete CIDRs the ASN expander last produced (asn groups // only); ResolvedAt timestamps that expansion. Both are server-managed. Resolved []string `json:"resolved,omitempty"` ResolvedAt *time.Time `json:"resolved_at,omitempty"` } // SetName returns the nftables set name for this group. ASN groups get the // reserved asn_ prefix; others use their bare name. func (g AddressGroup) SetName() string { if g.Type == GroupASN && !strings.HasPrefix(g.Name, "asn_") { return "asn_" + g.Name } return g.Name } // Device is a fleet member. type Device struct { Name string `json:"name"` Class DeviceClass `json:"class"` Fabric string `json:"fabric,omitempty"` Resolver []string `json:"resolver,omitempty"` Settings map[string]string `json:"settings,omitempty"` // ReachablePrefixes is the device's FIB as last reported by its agent // (server-managed). The compiler uses it to scope router enforcement. ReachablePrefixes []string `json:"reachable_prefixes,omitempty"` } // Binding maps a global zone to one device's local interface(s). type Binding struct { Device string `json:"device"` Zone string `json:"zone"` Interfaces []string `json:"interfaces"` } // Rule is a fleet-global intent. Source and Dest are element lists (OR'd). type Rule struct { ID int64 `json:"id"` Priority int `json:"priority"` Action string `json:"action"` Source []string `json:"source"` Dest []string `json:"dest"` Proto string `json:"proto,omitempty"` PortGroup string `json:"portgroup,omitempty"` Ports []string `json:"ports,omitempty"` Log string `json:"log,omitempty"` Comment string `json:"comment,omitempty"` } // Selector kinds within a source/dest element. type SelectorKind string const ( SelIPSet SelectorKind = "ipset" // +name SelFQDN SelectorKind = "fqdn" // &name SelNone SelectorKind = "" // bare zone ) // Element is one comma-separated token of a source/dest list. A zone is always // present; the selector, when set, narrows within that zone (an AND). type Element struct { Zone string Selector SelectorKind Ref string // the ipset/fqdn-group name when Selector != SelNone } // ParseElement parses a single shorewall-style element: // // loc -> bare zone // net:+asn_cloudflare -> zone gated by an ipset // dmz:&api.partner -> zone gated by an fqdn group // // A bare selector (no zone) is rejected: every selector must be paired with a zone. func ParseElement(s string) (Element, error) { s = strings.TrimSpace(s) if s == "" { return Element{}, fmt.Errorf("empty element") } // Reject a leading selector sigil: bare selectors are not allowed. if s[0] == '+' || s[0] == '&' { return Element{}, fmt.Errorf("selector %q must be paired with a zone (write zone:%s)", s, s) } zone, sel, hasSel := strings.Cut(s, ":") zone = strings.TrimSpace(zone) if zone == "" { return Element{}, fmt.Errorf("element %q has an empty zone", s) } e := Element{Zone: zone, Selector: SelNone} if !hasSel { return e, nil } sel = strings.TrimSpace(sel) if sel == "" { return Element{}, fmt.Errorf("element %q has a trailing colon with no selector", s) } switch sel[0] { case '+': e.Selector, e.Ref = SelIPSet, sel[1:] case '&': e.Selector, e.Ref = SelFQDN, sel[1:] default: return Element{}, fmt.Errorf("selector %q must start with + (ipset) or & (fqdn)", sel) } if e.Ref == "" { return Element{}, fmt.Errorf("element %q has an empty selector reference", s) } return e, nil } // ParseElements parses and validates a full source/dest element list. func ParseElements(list []string) ([]Element, error) { out := make([]Element, 0, len(list)) for _, s := range list { e, err := ParseElement(s) if err != nil { return nil, err } out = append(out, e) } return out, nil }