Files
tomswallapi/internal/model/model.go
T
2026-07-19 13:31:14 +10:00

158 lines
4.7 KiB
Go

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