e0f54ef320
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.
167 lines
4.6 KiB
Go
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
|
|
}
|