From 06928bc150a524ae8ad613c27c70f509ae6b5abd Mon Sep 17 00:00:00 2001 From: benvin Date: Tue, 21 Jul 2026 22:21:24 +1000 Subject: [PATCH] Agent: translate the NAT tier into native config The agent now maps the rendered NAT sections into native tomswall config: - snat/masquerade -> config.SNAT, expanding a rendered rule's egress interface list and source CIDRs into one native rule per (egress, source) pair (a native SNAT rule takes a single dest interface); carries address/probability. - netmap -> config.Netmap (from_net/to_net -> net1/net2 on the resolved interface). - 1:1 nat -> config.StaticNAT. Unit-tested end to end from RenderedConfig to config.Config. --- internal/agent/agent_test.go | 35 ++++++++++++++++++++++++++ internal/agent/rendered.go | 30 ++++++++++++++++++++++ internal/agent/translate.go | 48 ++++++++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+) diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index b9e8d76..3019815 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -84,6 +84,41 @@ func TestTranslateBareZone(t *testing.T) { } } +func TestTranslateNATTier(t *testing.T) { + prob := 0.5 + rc := &RenderedConfig{ + Enforcing: true, + SNAT: []RenderedSNAT{ + {Action: "masquerade", Source: []string{"10.1.0.0/24"}, Egress: []string{"eth0", "eth3"}}, + {Action: "snat", Source: []string{"10.2.0.0/24"}, Egress: []string{"eth0"}, Address: "203.0.113.1", Probability: &prob}, + }, + Netmap: []RenderedNetmap{{Type: "dnat", FromNet: "10.0.0.0/24", ToNet: "192.168.1.0/24", Interface: "eth0"}}, + NAT: []RenderedNAT{{External: "203.0.113.10", Internal: "10.1.0.10", Interface: "eth0"}}, + } + cfg, err := Translate(rc) + if err != nil { + t.Fatalf("Translate: %v", err) + } + + // masquerade with two egress interfaces expands to two rules; snat adds one. + if len(cfg.SNAT) != 3 { + t.Fatalf("expected 3 SNAT rules, got %d: %+v", len(cfg.SNAT), cfg.SNAT) + } + if cfg.SNAT[0].Action != config.SNATMasquerade || cfg.SNAT[0].Source != "10.1.0.0/24" || cfg.SNAT[0].Dest != "eth0" { + t.Errorf("unexpected masquerade rule: %+v", cfg.SNAT[0]) + } + if cfg.SNAT[2].Address != "203.0.113.1" || cfg.SNAT[2].Probability != 0.5 { + t.Errorf("snat address/probability not carried: %+v", cfg.SNAT[2]) + } + + if len(cfg.Netmap) != 1 || cfg.Netmap[0].Net1 != "10.0.0.0/24" || cfg.Netmap[0].Net2 != "192.168.1.0/24" || cfg.Netmap[0].Interface != "eth0" { + t.Errorf("netmap not translated: %+v", cfg.Netmap) + } + if len(cfg.StaticNAT) != 1 || cfg.StaticNAT[0].External != "203.0.113.10" || cfg.StaticNAT[0].Internal != "10.1.0.10" { + t.Errorf("static nat not translated: %+v", cfg.StaticNAT) + } +} + func TestTranslateRejectsUnknownAction(t *testing.T) { rc := &RenderedConfig{Enforcing: true, Rules: []RenderedRule{{Action: "bogus"}}} if _, err := Translate(rc); err == nil { diff --git a/internal/agent/rendered.go b/internal/agent/rendered.go index 124b249..ab0ef3c 100644 --- a/internal/agent/rendered.go +++ b/internal/agent/rendered.go @@ -18,6 +18,36 @@ type RenderedConfig struct { Sets []RenderedSet `yaml:"sets,omitempty" json:"sets,omitempty"` Rules []RenderedRule `yaml:"rules,omitempty" json:"rules,omitempty"` Policies []RenderedPolicy `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"` +} + +// RenderedSNAT is a resolved SNAT/masquerade rule (egress carries interface names). +type RenderedSNAT struct { + Action string `yaml:"action" json:"action"` + Source []string `yaml:"source,omitempty" json:"source,omitempty"` + Egress []string `yaml:"egress" json:"egress"` + 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"` } type RenderedSettings struct { diff --git a/internal/agent/translate.go b/internal/agent/translate.go index 8c5adf8..620bb2c 100644 --- a/internal/agent/translate.go +++ b/internal/agent/translate.go @@ -60,9 +60,57 @@ func Translate(rc *RenderedConfig) (*config.Config, error) { }) } + 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, + }) + } + 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)) -- 2.47.3