Files
tomswallapi/internal/compiler/compiler.go
T
benvin d52c3ee76e
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
Add central ASN address-group expander
Expand asn address groups to concrete prefixes centrally (one iplocate key,
consistent fleet-wide) and refresh them on a per-group TTL (default 24h). A
background Refresher scans for due groups, unions each group's ASNs to a deduped
prefix set, and writes them to a new resolved/resolved_at column (migration
0002). Fail-safe: a lookup error or empty expansion keeps the last-good set,
never emptying it. The compiler folds resolved prefixes into the rendered set
members; membership churn bumps the generation but never rewrites rules. The
iplocate client is endpoint-configurable and response-tolerant, documented as
needing endpoint/key confirmation. Unit tests cover TTL parsing, due-checks, and
union/dedup/error propagation with a fake expander.
2026-07-19 21:44:30 +10:00

315 lines
9.8 KiB
Go

// Package compiler projects the fleet-global model through a device's binding
// table into a rendered, interface-agnostic config the tomswall agent applies.
//
// Rules are compiled to address-matched (saddr/daddr) forward rules with no
// iif/oif, which is what makes them correct under FRR/ECMP: any device on any
// path permits the 5-tuple and each device's own conntrack handles the return.
// Firewalls always enforce; routers enforce only when their fabric opts in.
package compiler
import (
"context"
"fmt"
"sort"
"gopkg.in/yaml.v3"
"git.unkin.net/unkin/tomswallapi/internal/model"
"git.unkin.net/unkin/tomswallapi/internal/store"
)
// Input is the fully-resolved model needed to render one device. Keeping Render
// pure (no store access) makes it unit-testable without a database.
type Input struct {
Generation int64
Settings model.Settings
Device model.Device
Fabric *model.Fabric
Zones map[string]model.Zone
Groups map[string]model.AddressGroup
PortGroups map[string]model.PortGroup
Rules []model.Rule
Policies []model.Policy
Bindings []model.Binding
}
// RenderedConfig is the per-device output served to the agent.
type RenderedConfig struct {
Generation int64 `yaml:"generation" json:"generation"`
Device string `yaml:"device" json:"device"`
Class model.DeviceClass `yaml:"class" json:"class"`
Enforcing bool `yaml:"enforcing" json:"enforcing"`
Settings RenderedSettings `yaml:"settings" json:"settings"`
Resolver []string `yaml:"resolver,omitempty" json:"resolver,omitempty"`
Bindings map[string][]string `yaml:"bindings,omitempty" json:"bindings,omitempty"` // zone -> interfaces
Sets []RenderedSet `yaml:"sets,omitempty" json:"sets,omitempty"`
Rules []RenderedRule `yaml:"rules,omitempty" json:"rules,omitempty"`
Policies []model.Policy `yaml:"policies,omitempty" json:"policies,omitempty"`
}
// RenderedSettings is the effective settings after per-device overrides.
type RenderedSettings struct {
AddressFamily string `yaml:"address_family" json:"address_family"`
LogLevel string `yaml:"log_level" json:"log_level"`
IPForwarding bool `yaml:"ip_forwarding" json:"ip_forwarding"`
TableName string `yaml:"table_name" json:"table_name"`
}
// RenderedSet is an nftables named set the agent must materialize. Members carry
// the concrete elements when the API knows them (static, or asn once expanded);
// dns and unexpanded asn sets carry their source so the agent/expander can
// populate them out-of-band without a rule reload.
type RenderedSet struct {
Name string `yaml:"name" json:"name"`
Kind model.AddressGroupType `yaml:"kind" json:"kind"`
Members []string `yaml:"members,omitempty" json:"members,omitempty"` // static CIDRs / expanded prefixes
FQDNs []string `yaml:"fqdns,omitempty" json:"fqdns,omitempty"` // dns: names to resolve on-device
ASNs []string `yaml:"asns,omitempty" json:"asns,omitempty"` // asn: source ASNs
Refresh string `yaml:"refresh,omitempty" json:"refresh,omitempty"`
}
// RenderedMatch is one OR'd element of a rule direction: the zone's subnets
// AND, optionally, a named set to intersect with.
type RenderedMatch struct {
Zone string `yaml:"zone" json:"zone"`
Subnets []string `yaml:"subnets,omitempty" json:"subnets,omitempty"`
Set string `yaml:"set,omitempty" json:"set,omitempty"`
}
// RenderedRule is an interface-agnostic forward rule.
type RenderedRule struct {
Action string `yaml:"action" json:"action"`
Source []RenderedMatch `yaml:"source" json:"source"`
Dest []RenderedMatch `yaml:"dest" json:"dest"`
Proto string `yaml:"proto,omitempty" json:"proto,omitempty"`
Ports []string `yaml:"ports,omitempty" json:"ports,omitempty"`
Log string `yaml:"log,omitempty" json:"log,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
// Marshal serializes the rendered config to YAML.
func (c *RenderedConfig) Marshal() ([]byte, error) { return yaml.Marshal(c) }
// enforces reports whether the device applies rules: firewalls always do; routers
// only when their fabric opts into defense-in-depth.
func enforces(dev model.Device, fabric *model.Fabric) bool {
if dev.Class == model.ClassFirewall {
return true
}
return dev.Class == model.ClassRouter && fabric != nil && fabric.EnforceOnRouters
}
// setNameFor resolves a rule's selector reference (as written after + or &) to a
// concrete nft set name. A reference may be a group's bare name or its computed
// set name (e.g. an asn group "cloudflare" whose set is "asn_cloudflare").
func setNameFor(groups map[string]model.AddressGroup, ref string) (model.AddressGroup, bool) {
if g, ok := groups[ref]; ok {
return g, true
}
for _, g := range groups {
if g.SetName() == ref {
return g, true
}
}
return model.AddressGroup{}, false
}
// Render projects the model into a device config. It is pure and deterministic.
func Render(in Input) (*RenderedConfig, error) {
out := &RenderedConfig{
Generation: in.Generation,
Device: in.Device.Name,
Class: in.Device.Class,
Enforcing: enforces(in.Device, in.Fabric),
Settings: renderSettings(in),
Resolver: effectiveResolver(in),
Bindings: map[string][]string{},
}
for _, b := range in.Bindings {
out.Bindings[b.Zone] = b.Interfaces
}
usedSets := map[string]model.AddressGroup{}
if out.Enforcing {
for _, rule := range in.Rules {
rr, err := renderRule(in, rule, usedSets)
if err != nil {
return nil, fmt.Errorf("rule %d: %w", rule.ID, err)
}
out.Rules = append(out.Rules, rr)
}
out.Policies = in.Policies
}
// Emit a set definition for every address group any rule referenced.
names := make([]string, 0, len(usedSets))
for n := range usedSets {
names = append(names, n)
}
sort.Strings(names)
for _, n := range names {
out.Sets = append(out.Sets, renderSet(usedSets[n]))
}
return out, nil
}
func renderSettings(in Input) RenderedSettings {
s := RenderedSettings{
AddressFamily: in.Settings.AddressFamily,
LogLevel: in.Settings.LogLevel,
IPForwarding: in.Settings.IPForwarding,
TableName: in.Settings.TableName,
}
// Per-device string overrides.
if v, ok := in.Device.Settings["address_family"]; ok {
s.AddressFamily = v
}
if v, ok := in.Device.Settings["log_level"]; ok {
s.LogLevel = v
}
if v, ok := in.Device.Settings["table_name"]; ok {
s.TableName = v
}
return s
}
func effectiveResolver(in Input) []string {
if len(in.Device.Resolver) > 0 {
return in.Device.Resolver
}
return in.Settings.DefaultResolver
}
func renderRule(in Input, rule model.Rule, usedSets map[string]model.AddressGroup) (RenderedRule, error) {
src, err := renderMatches(in, rule.Source, usedSets)
if err != nil {
return RenderedRule{}, fmt.Errorf("source: %w", err)
}
dst, err := renderMatches(in, rule.Dest, usedSets)
if err != nil {
return RenderedRule{}, fmt.Errorf("dest: %w", err)
}
proto, ports := resolvePorts(in, rule)
return RenderedRule{
Action: rule.Action,
Source: src,
Dest: dst,
Proto: proto,
Ports: ports,
Log: rule.Log,
Comment: rule.Comment,
}, nil
}
func renderMatches(in Input, list []string, usedSets map[string]model.AddressGroup) ([]RenderedMatch, error) {
elems, err := model.ParseElements(list)
if err != nil {
return nil, err
}
out := make([]RenderedMatch, 0, len(elems))
for _, e := range elems {
m := RenderedMatch{Zone: e.Zone}
if z, ok := in.Zones[e.Zone]; ok {
m.Subnets = z.Subnets
}
if e.Selector != model.SelNone {
g, ok := setNameFor(in.Groups, e.Ref)
if !ok {
return nil, fmt.Errorf("unknown address group %q", e.Ref)
}
m.Set = g.SetName()
usedSets[g.SetName()] = g
}
out = append(out, m)
}
return out, nil
}
func resolvePorts(in Input, rule model.Rule) (proto string, ports []string) {
if rule.PortGroup != "" {
if pg, ok := in.PortGroups[rule.PortGroup]; ok {
return pg.Proto, pg.Ports
}
}
return rule.Proto, rule.Ports
}
func renderSet(g model.AddressGroup) RenderedSet {
rs := RenderedSet{Name: g.SetName(), Kind: g.Type, Refresh: g.Refresh}
switch g.Type {
case model.GroupStatic:
rs.Members = g.Members
case model.GroupDNS:
rs.FQDNs = g.Members
case model.GroupASN:
rs.ASNs = g.Members // source ASNs
rs.Members = g.Resolved // concrete prefixes from the central expander (may be empty until first expansion)
}
return rs
}
// Compile fetches the model for a device from the store and renders its config.
func Compile(ctx context.Context, s *store.Store, device string) (*RenderedConfig, error) {
dev, err := s.GetDevice(ctx, device)
if err != nil {
return nil, err
}
gen, err := s.Generation(ctx)
if err != nil {
return nil, err
}
settings, err := s.GetSettings(ctx)
if err != nil {
return nil, err
}
in := Input{Generation: gen, Settings: settings, Device: dev}
if dev.Fabric != "" {
f, err := s.GetFabric(ctx, dev.Fabric)
if err == nil {
in.Fabric = &f
} else if err != store.ErrNotFound {
return nil, err
}
}
zones, err := s.ListZones(ctx)
if err != nil {
return nil, err
}
in.Zones = make(map[string]model.Zone, len(zones))
for _, z := range zones {
in.Zones[z.Name] = z
}
groups, err := s.ListAddressGroups(ctx)
if err != nil {
return nil, err
}
in.Groups = make(map[string]model.AddressGroup, len(groups))
for _, g := range groups {
in.Groups[g.Name] = g
}
pgs, err := s.ListPortGroups(ctx)
if err != nil {
return nil, err
}
in.PortGroups = make(map[string]model.PortGroup, len(pgs))
for _, p := range pgs {
in.PortGroups[p.Name] = p
}
if in.Rules, err = s.ListRules(ctx); err != nil {
return nil, err
}
if in.Policies, err = s.ListPolicies(ctx); err != nil {
return nil, err
}
if in.Bindings, err = s.ListBindings(ctx, device); err != nil {
return nil, err
}
return Render(in)
}