Scaffold tomswallapi control-plane service
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user