Agent: translate the NAT tier into native config #4

Merged
benvin merged 1 commits from benvin/agent-nat into main 2026-07-21 22:30:10 +10:00
3 changed files with 113 additions and 0 deletions
+35
View File
@@ -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 {
+30
View File
@@ -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 {
+48
View File
@@ -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))