package agent import ( "fmt" "sort" "git.unkin.net/unkin/tomswall/internal/config" ) // Translate converts a control-plane RenderedConfig into a native tomswall // config.Config that the existing differential engine can apply. // // The rendered model is interface-agnostic and address-matched; tomswall // expresses that with the "all:" source/dest form (zone "all" imposes no // interface constraint, the CIDR is matched on saddr/daddr). Named sets are // inlined as their concrete members: a rule element matching N source addresses // against M dest addresses expands to N*M address-matched rules. This is a // correct v1; native nftables set references (so membership churns without a // rule rebuild) are a tracked follow-up. func Translate(rc *RenderedConfig) (*config.Config, error) { cfg := &config.Config{ Settings: config.Settings{ AddressFamily: config.AddressFamily(orDefault(rc.Settings.AddressFamily, "inet")), IPForwarding: rc.Settings.IPForwarding, LogLevel: orDefault(rc.Settings.LogLevel, "info"), TableName: orDefault(rc.Settings.TableName, "tomswall"), }, Zones: map[string]config.Zone{}, PortGroups: map[string]config.PortGroup{}, } // The firewall zone is required; bound zones map to their local interfaces. cfg.Zones["fw"] = config.Zone{Type: config.ZoneFirewall} for zone, ifaces := range rc.Bindings { cfg.Zones[zone] = config.Zone{Type: config.ZoneIP} for _, iface := range ifaces { cfg.Interfaces = append(cfg.Interfaces, config.Interface{Zone: zone, Interface: iface}) } } sort.Slice(cfg.Interfaces, func(i, j int) bool { return cfg.Interfaces[i].Interface < cfg.Interfaces[j].Interface }) setMembers := indexSets(rc.Sets) for i, rr := range rc.Rules { rules, err := translateRule(rr, setMembers) if err != nil { return nil, fmt.Errorf("rule %d: %w", i, err) } cfg.Rules = append(cfg.Rules, rules...) } for _, p := range rc.Policies { cfg.Policy = append(cfg.Policy, config.Policy{ Source: orDefault(p.Source, "all"), Dest: orDefault(p.Dest, "all"), Action: config.PolicyAction(p.Action), Log: p.Log, }) } for _, s := range rc.SNAT { cfg.SNAT = append(cfg.SNAT, translateSNAT(s)...) } for _, n := range rc.Netmap { cfg.Netmap = append(cfg.Netmap, config.Netmap{ Type: config.NetmapType(n.Type), Net1: n.FromNet, Net2: n.ToNet, Interface: n.Interface, Comment: n.Comment, }) } for _, n := range rc.NAT { cfg.StaticNAT = append(cfg.StaticNAT, config.StaticNAT{ External: n.External, Internal: n.Internal, Interface: n.Interface, Comment: n.Comment, }) } for _, h := range rc.Hosts { cfg.Hosts = append(cfg.Hosts, config.Host{ Zone: h.Zone, Interface: h.Interface, Addresses: h.Addresses, Exclusions: h.Exclusions, Dynamic: h.Dynamic, }) } for _, p := range rc.Providers { cfg.Providers = append(cfg.Providers, config.Provider{ Name: p.Name, Number: p.Number, Mark: p.Mark, Duplicate: p.Duplicate, Interface: p.Interface, Gateway: p.Gateway, Copy: p.Copy, }) } for _, r := range rc.Routes { cfg.Routes = append(cfg.Routes, config.StaticRoute{ Provider: r.Provider, Dest: r.Dest, Gateway: r.Gateway, Device: r.Oif, Persistent: r.Persistent, Comment: r.Comment, }) } for _, r := range rc.RoutingRules { cfg.RoutingRules = append(cfg.RoutingRules, config.RoutingRule{ Source: r.Source, Dest: r.Dest, Provider: r.Provider, Priority: r.Priority, Persistent: r.Persistent, Mark: r.Mark, Comment: r.Comment, }) } for _, t := range rc.Tunnels { cfg.Tunnels = append(cfg.Tunnels, config.Tunnel{ Type: t.Type, Zone: t.Zone, Gateways: t.Gateways, GatewayZones: t.GatewayZones, Port: t.Port, Comment: t.Comment, }) } for _, r := range rc.StoppedRules { cfg.StoppedRules = append(cfg.StoppedRules, config.StoppedRule{ Action: config.StoppedAction(r.Action), Source: r.Source, Dest: r.Dest, Proto: r.Proto, DPort: config.PortSpec(r.DPort), SPort: config.PortSpec(r.SPort), Comment: r.Comment, }) } for _, p := range rc.ProxyARP { cfg.ProxyARP = append(cfg.ProxyARP, config.ProxyARP{ Address: p.Address, Interface: p.Interface, External: p.External, HaveRoute: p.HaveRoute, Persistent: p.Persistent, Comment: p.Comment, }) } for _, p := range rc.ProxyNDP { cfg.ProxyNDP = append(cfg.ProxyNDP, config.ProxyNDP{ Address: p.Address, Interface: p.Interface, External: p.External, HaveRoute: p.HaveRoute, Persistent: p.Persistent, Comment: p.Comment, }) } for _, a := range rc.ArpRules { cfg.ArpRules = append(cfg.ArpRules, config.ArpRule{ Action: config.ArpAction(a.Action), ActionAddress: a.ActionAddress, ActionMAC: a.ActionMAC, Source: a.Source, Dest: a.Dest, Opcode: a.Opcode, Comment: a.Comment, }) } for _, m := range rc.Maclist { cfg.Maclist = append(cfg.Maclist, config.MaclistEntry{ Action: config.MaclistAction(m.Action), Interface: m.Interface, MAC: m.MAC, Addresses: m.Addresses, Log: m.Log, Comment: m.Comment, }) } for _, m := range rc.Mangle { mr := config.MangleRule{ Action: config.MangleAction(m.Action), Chain: config.MangleChain(m.Chain), MarkValue: m.MarkValue, Source: m.Source, Dest: m.Dest, Proto: m.Proto, DPort: config.PortSpec(m.DPort), SPort: config.PortSpec(m.SPort), User: m.User, Mark: m.Mark, Length: m.Length, TOS: m.TOS, Helper: m.Helper, Comment: m.Comment, } if m.Probability != nil { mr.Probability = *m.Probability } cfg.Mangle = append(cfg.Mangle, mr) } for _, a := range rc.Accounting { cfg.Accounting = append(cfg.Accounting, config.AccountingRule{ Action: config.AccountingAction(a.Action), Section: config.AccountingSection(a.Section), Chain: a.Chain, Source: a.Source, Dest: a.Dest, Proto: a.Proto, DPort: config.PortSpec(a.DPort), SPort: config.PortSpec(a.SPort), Mark: a.Mark, Comment: a.Comment, }) } for _, t := range rc.TCDevices { cfg.TCDevices = append(cfg.TCDevices, config.TCDevice{ Interface: t.Interface, InBandwidth: t.InBandwidth, OutBandwidth: t.OutBandwidth, Comment: t.Comment, }) } for _, t := range rc.TCClasses { cfg.TCClasses = append(cfg.TCClasses, config.TCClass{ Interface: t.Interface, Mark: t.Mark, Rate: t.Rate, Ceil: t.Ceil, Priority: t.Priority, Comment: t.Comment, }) } for _, t := range rc.TCFilters { cfg.TCFilters = append(cfg.TCFilters, config.TCFilter{ Class: t.Class, Source: t.Source, Dest: t.Dest, Proto: t.Proto, DPort: config.PortSpec(t.DPort), SPort: config.PortSpec(t.SPort), TOS: t.TOS, Length: t.Length, Priority: t.Priority, Comment: t.Comment, }) } for _, t := range rc.TCInterfaces { cfg.TCInterfaces = append(cfg.TCInterfaces, config.TCInterface{ Interface: t.Interface, Type: t.Type, InBandwidth: t.InBandwidth, OutBandwidth: t.OutBandwidth, Comment: t.Comment, }) } for _, t := range rc.TCPriorities { cfg.TCPriorities = append(cfg.TCPriorities, config.TCPriority{ Band: t.Band, Proto: t.Proto, DPort: config.PortSpec(t.DPort), SPort: config.PortSpec(t.SPort), Address: t.Address, Interface: t.Interface, Helper: t.Helper, Comment: t.Comment, }) } for _, b := range rc.Blrules { cfg.Blrules = append(cfg.Blrules, config.BlruleRule{ Action: config.BlruleAction(b.Action), Source: b.Source, Dest: b.Dest, Proto: b.Proto, DPort: config.PortSpec(b.DPort), SPort: config.PortSpec(b.SPort), Log: b.Log, Comment: b.Comment, }) } for _, c := range rc.Conntrack { cfg.Conntrack = append(cfg.Conntrack, config.ConntrackRule{ Action: config.ConntrackAction(c.Action), Source: c.Source, Dest: c.Dest, Proto: c.Proto, DPort: config.PortSpec(c.DPort), SPort: config.PortSpec(c.SPort), Chain: config.ConntrackChain(c.Chain), Helper: c.Helper, User: c.User, Comment: c.Comment, }) } for _, sm := range rc.Secmarks { cfg.Secmarks = append(cfg.Secmarks, config.SecmarkRule{ Secmark: sm.Secmark, Chain: sm.Chain, Source: sm.Source, Dest: sm.Dest, Proto: sm.Proto, DPort: config.PortSpec(sm.DPort), SPort: config.PortSpec(sm.SPort), Comment: sm.Comment, }) } if len(rc.Vars) > 0 { cfg.Vars = make(map[string]string, len(rc.Vars)) for k, v := range rc.Vars { cfg.Vars[k] = v } } return cfg, nil } // translateSNAT expands a rendered SNAT (which carries a list of egress // interfaces and source CIDRs) into native tomswall SNAT rules — one per // (egress interface, source) pair, since a native rule takes a single Dest. func translateSNAT(s RenderedSNAT) []config.SNATRule { sources := s.Source if len(sources) == 0 { sources = []string{""} } var out []config.SNATRule for _, egress := range s.Egress { for _, src := range sources { r := config.SNATRule{ Action: config.SNATAction(s.Action), Source: src, Dest: egress, Address: s.Address, Comment: s.Comment, } if s.Probability != nil { r.Probability = *s.Probability } out = append(out, r) } } return out } // indexSets maps set name -> concrete member CIDRs (invalid members skipped). func indexSets(sets []RenderedSet) map[string][]string { m := make(map[string][]string, len(sets)) for _, s := range sets { var members []string for _, cidr := range s.staticMembers() { if validateCIDR(cidr) == nil { members = append(members, cidr) } } m[s.Name] = members } return m } // addressesFor returns the union of concrete source/dest addresses for a // direction's OR'd match elements. A match's addresses are its set members when // a set is referenced, otherwise its zone subnets. func addressesFor(matches []RenderedMatch, setMembers map[string][]string) []string { seen := map[string]struct{}{} var out []string add := func(cidrs []string) { for _, c := range cidrs { if _, ok := seen[c]; ok { continue } if validateCIDR(c) != nil { continue } seen[c] = struct{}{} out = append(out, c) } } for _, m := range matches { if m.Set != "" { add(setMembers[m.Set]) continue } add(m.Subnets) } return out } // translateRule expands one rendered rule into address-matched tomswall rules. func translateRule(rr RenderedRule, setMembers map[string][]string) ([]config.Rule, error) { action, err := translateAction(rr.Action) if err != nil { return nil, err } srcAddrs := addressesFor(rr.Source, setMembers) dstAddrs := addressesFor(rr.Dest, setMembers) // A direction with no concrete addresses matches "any" for that side. if len(srcAddrs) == 0 { srcAddrs = []string{""} } if len(dstAddrs) == 0 { dstAddrs = []string{""} } var out []config.Rule for _, s := range srcAddrs { for _, d := range dstAddrs { out = append(out, config.Rule{ Action: action, Source: anySpec(s), Dest: anySpec(d), Proto: rr.Proto, DPort: config.PortSpec(rr.Ports), Log: rr.Log, Comment: rr.Comment, }) } } return out, nil } // anySpec renders an interface-agnostic source/dest spec: "all" with an optional // CIDR constraint. func anySpec(cidr string) string { if cidr == "" { return "all" } return "all:" + cidr } func translateAction(a string) (config.RuleAction, error) { switch config.RuleAction(a) { case config.RuleAccept, config.RuleDrop, config.RuleReject, config.RuleLog, config.RuleContinue, config.RuleCount: return config.RuleAction(a), nil default: return "", fmt.Errorf("unsupported action %q", a) } } func orDefault(v, def string) string { if v == "" { return def } return v }