158 lines
4.7 KiB
Go
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
|
|
}
|