Files
tomswall/internal/agent/translate.go
T
benvin e0f54ef320 Add tomswall agent (control-plane pull mode)
Add `tomswall agent`: it pulls this device's compiled config from tomswallapi,
differentially applies it, and reports the applied generation. It caches the
last known-good config and, when the control plane is unreachable, keeps
applying that cache — it never fails closed.

- internal/agent: rendered-config types, HTTP client (fetch + status report),
  on-disk cache, on-device DNS resolver for dns sets (honors the device's
  configured resolver, fail-safe on lookup failure), and the pull-apply-report
  loop behind a mockable Applier.
- Translate the interface-agnostic, address-matched rendered model into native
  tomswall config using the "all:<cidr>" any-interface source/dest form, reusing
  the existing differential engine. Named-set members are inlined as concrete
  addresses (native nft set references are a tracked follow-up).
- cmd/tomswall: wire the `agent` subcommand (flags + TOMSWALL_* env, --once).
- Unit tests: translation, cache, and the don't-fail-closed fallback loop.
- Add DESIGN.md documenting the control-plane architecture.
2026-07-20 20:05:49 +10:00

167 lines
4.6 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,
})
}
return cfg, nil
}
// 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
}