Files
tomswall/internal/agent/translate.go
T
benvin b94cb96510
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline was successful
Agent: translate per-device L2/misc long-tail
Map the rendered tunnels/stopped_rules/proxy_arp/proxy_ndp/arp_rules/maclist
sections into native tomswall config.
2026-07-26 15:19:09 +10:00

277 lines
8.1 KiB
Go

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:<cidr>" 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,
})
}
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
}