// 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" "strings" "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 SNAT []model.SNATRule Netmap []model.NetmapRule NAT []model.NATRule Hosts []model.Host Providers []model.Provider Routes []model.Route RoutingRules []model.RoutingRule Tunnels []model.Tunnel StoppedRules []model.StoppedRule ProxyARP []model.ProxyEntry ProxyNDP []model.ProxyEntry ArpRules []model.ArpRule Maclist []model.MaclistEntry Mangle []model.MangleRule Accounting []model.AccountingRule TCDevices []model.TCDevice TCClasses []model.TCClass TCFilters []model.TCFilter TCInterfaces []model.TCInterface TCPriorities []model.TCPriority Blrules []model.BlruleRule Conntrack []model.ConntrackRule Secmarks []model.SecmarkRule Vars []model.Var } // 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"` SNAT []RenderedSNAT `yaml:"snat,omitempty" json:"snat,omitempty"` Netmap []RenderedNetmap `yaml:"netmap,omitempty" json:"netmap,omitempty"` NAT []RenderedNAT `yaml:"nat,omitempty" json:"nat,omitempty"` Hosts []RenderedHost `yaml:"hosts,omitempty" json:"hosts,omitempty"` Providers []RenderedProvider `yaml:"providers,omitempty" json:"providers,omitempty"` Routes []RenderedRoute `yaml:"routes,omitempty" json:"routes,omitempty"` RoutingRules []RenderedRoutingRule `yaml:"routing_rules,omitempty" json:"routing_rules,omitempty"` Tunnels []RenderedTunnel `yaml:"tunnels,omitempty" json:"tunnels,omitempty"` StoppedRules []RenderedStoppedRule `yaml:"stopped_rules,omitempty" json:"stopped_rules,omitempty"` ProxyARP []RenderedProxy `yaml:"proxy_arp,omitempty" json:"proxy_arp,omitempty"` ProxyNDP []RenderedProxy `yaml:"proxy_ndp,omitempty" json:"proxy_ndp,omitempty"` ArpRules []RenderedArpRule `yaml:"arp_rules,omitempty" json:"arp_rules,omitempty"` Maclist []RenderedMaclist `yaml:"maclist,omitempty" json:"maclist,omitempty"` Mangle []RenderedMangle `yaml:"mangle,omitempty" json:"mangle,omitempty"` Accounting []RenderedAccounting `yaml:"accounting,omitempty" json:"accounting,omitempty"` TCDevices []RenderedTCDevice `yaml:"tc_devices,omitempty" json:"tc_devices,omitempty"` TCClasses []RenderedTCClass `yaml:"tc_classes,omitempty" json:"tc_classes,omitempty"` TCFilters []RenderedTCFilter `yaml:"tc_filters,omitempty" json:"tc_filters,omitempty"` TCInterfaces []RenderedTCInterface `yaml:"tc_interfaces,omitempty" json:"tc_interfaces,omitempty"` TCPriorities []RenderedTCPriority `yaml:"tc_priorities,omitempty" json:"tc_priorities,omitempty"` Blrules []RenderedBlrule `yaml:"blrules,omitempty" json:"blrules,omitempty"` Conntrack []RenderedConntrack `yaml:"conntrack,omitempty" json:"conntrack,omitempty"` Secmarks []RenderedSecmark `yaml:"secmarks,omitempty" json:"secmarks,omitempty"` Vars map[string]string `yaml:"vars,omitempty" json:"vars,omitempty"` } // RenderedSNAT is a resolved SNAT/masquerade rule: source addresses masqueraded // (or SNATed to Address) as they leave via the resolved egress interfaces. type RenderedSNAT struct { Action string `yaml:"action" json:"action"` Source []string `yaml:"source,omitempty" json:"source,omitempty"` // source CIDRs Egress []string `yaml:"egress" json:"egress"` // egress interface names Address string `yaml:"address,omitempty" json:"address,omitempty"` Probability *float64 `yaml:"probability,omitempty" json:"probability,omitempty"` Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` } // RenderedNetmap is a resolved network-to-network mapping on one interface. type RenderedNetmap struct { Type string `yaml:"type" json:"type"` FromNet string `yaml:"from_net" json:"from_net"` ToNet string `yaml:"to_net" json:"to_net"` Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` Comment string `yaml:"comment,omitempty" json:"comment,omitempty"` } // RenderedNAT is a resolved one-to-one static NAT on one interface. type RenderedNAT struct { External string `yaml:"external" json:"external"` Internal string `yaml:"internal" json:"internal"` Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` Comment string `yaml:"comment,omitempty" json:"comment,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{} // Every enforcing device carries every applicable rule: the interface-agnostic // address-matched form is correct under ECMP precisely because it does not // depend on which device is on the path (over-approximation is safe). Reported // FIBs are stored for observability/validation, not to limit rules. 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])) } // NAT tier: resolve the global NAT intents against this device's bindings. // These are binding-scoped, independent of the forward-rule enforce flag. out.SNAT = renderSNATRules(in, out.Bindings) out.Netmap = renderNetmapRules(in, out.Bindings) out.NAT = renderNATRules(in) // Per-device long-tail sections owned by this device. renderPerDevice(in, out) renderPerDeviceL2(in, out) renderTraffic(in, out) renderGlobal2(in, out) return out, nil } // renderSNATRules resolves masquerade/SNAT intents that egress via this device. // A SNAT lands here only if the device binds the egress zone (and, when the // source is a zone, that zone too) — which auto-scopes masquerade to edges. func renderSNATRules(in Input, bindings map[string][]string) []RenderedSNAT { var out []RenderedSNAT for _, s := range in.SNAT { egress := bindings[s.Egress] if len(egress) == 0 { continue // device is not an egress for this SNAT } var source []string if z, ok := in.Zones[s.Source]; ok { if _, bound := bindings[s.Source]; !bound { continue // device does not attach the source zone } source = z.Subnets } else { source = []string{s.Source} // literal CIDR } out = append(out, RenderedSNAT{ Action: s.Action, Source: source, Egress: egress, Address: s.Address, Probability: s.Probability, Comment: s.Comment, }) } return out } // renderNetmapRules resolves netmaps anchored (device:zone or device:interface) // at this device. func renderNetmapRules(in Input, bindings map[string][]string) []RenderedNetmap { var out []RenderedNetmap for _, n := range in.Netmap { dev, sel, ok := strings.Cut(n.Anchor, ":") if !ok || dev != in.Device.Name { continue } iface := sel if ifaces, bound := bindings[sel]; bound && len(ifaces) > 0 { iface = ifaces[0] // anchor named a zone: use its bound interface } out = append(out, RenderedNetmap{ Type: n.Type, FromNet: n.FromNet, ToNet: n.ToNet, Interface: iface, Comment: n.Comment, }) } return out } // renderNATRules resolves 1:1 static NATs bound to this device. func renderNATRules(in Input) []RenderedNAT { var out []RenderedNAT for _, n := range in.NAT { if n.Device != in.Device.Name { continue } out = append(out, RenderedNAT{ External: n.External, Internal: n.Internal, Interface: n.Interface, Comment: n.Comment, }) } return out } 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 } if in.SNAT, err = s.ListSNAT(ctx); err != nil { return nil, err } if in.Netmap, err = s.ListNetmap(ctx); err != nil { return nil, err } if in.NAT, err = s.ListNAT(ctx); err != nil { return nil, err } if in.Hosts, err = s.ListHosts(ctx); err != nil { return nil, err } if in.Providers, err = s.ListProviders(ctx); err != nil { return nil, err } if in.Routes, err = s.ListRoutes(ctx); err != nil { return nil, err } if in.RoutingRules, err = s.ListRoutingRules(ctx); err != nil { return nil, err } if in.Tunnels, err = s.ListTunnels(ctx); err != nil { return nil, err } if in.StoppedRules, err = s.ListStoppedRules(ctx); err != nil { return nil, err } if in.ProxyARP, err = s.ListProxyARP(ctx); err != nil { return nil, err } if in.ProxyNDP, err = s.ListProxyNDP(ctx); err != nil { return nil, err } if in.ArpRules, err = s.ListArpRules(ctx); err != nil { return nil, err } if in.Maclist, err = s.ListMaclist(ctx); err != nil { return nil, err } if in.Mangle, err = s.ListMangle(ctx); err != nil { return nil, err } if in.Accounting, err = s.ListAccounting(ctx); err != nil { return nil, err } if in.TCDevices, err = s.ListTCDevices(ctx); err != nil { return nil, err } if in.TCClasses, err = s.ListTCClasses(ctx); err != nil { return nil, err } if in.TCFilters, err = s.ListTCFilters(ctx); err != nil { return nil, err } if in.TCInterfaces, err = s.ListTCInterfaces(ctx); err != nil { return nil, err } if in.TCPriorities, err = s.ListTCPriorities(ctx); err != nil { return nil, err } if in.Blrules, err = s.ListBlrules(ctx); err != nil { return nil, err } if in.Conntrack, err = s.ListConntrack(ctx); err != nil { return nil, err } if in.Secmarks, err = s.ListSecmarks(ctx); err != nil { return nil, err } if in.Vars, err = s.ListVars(ctx); err != nil { return nil, err } return Render(in) }