Files
tomswall/internal/agent/resolver.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

115 lines
3.0 KiB
Go

package agent
import (
"context"
"fmt"
"log/slog"
"net"
"time"
)
// Resolver resolves dns-set FQDNs to host CIDRs on-device, honoring the
// device's configured resolver (falling back to the system resolver).
type Resolver struct {
// Servers are resolver addresses (host or host:port); empty uses the system
// resolver. The literal "system" is treated the same as empty.
Servers []string
}
// NewResolver builds a Resolver for the given server list.
func NewResolver(servers []string) *Resolver {
if len(servers) == 1 && servers[0] == "system" {
servers = nil
}
return &Resolver{Servers: servers}
}
func (r *Resolver) netResolver() *net.Resolver {
if len(r.Servers) == 0 {
return net.DefaultResolver
}
servers := r.Servers
dialer := &net.Dialer{Timeout: 5 * time.Second}
var idx int
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
// Round-robin across configured servers for resilience.
addr := servers[idx%len(servers)]
idx++
if _, _, err := net.SplitHostPort(addr); err != nil {
addr = net.JoinHostPort(addr, "53")
}
return dialer.DialContext(ctx, network, addr)
},
}
}
// Resolve returns host CIDRs (/32 or /128) for a FQDN's A and AAAA records.
func (r *Resolver) Resolve(ctx context.Context, fqdn string) ([]string, error) {
ips, err := r.netResolver().LookupIP(ctx, "ip", fqdn)
if err != nil {
return nil, err
}
out := make([]string, 0, len(ips))
for _, ip := range ips {
if ip4 := ip.To4(); ip4 != nil {
out = append(out, ip4.String()+"/32")
} else {
out = append(out, ip.String()+"/128")
}
}
return out, nil
}
// ExpandDNSSets resolves every dns set's FQDNs and populates its Members in
// place. Resolution failures are logged and leave the prior Members untouched
// (fail-safe): a resolver outage must never empty a set.
func (r *Resolver) ExpandDNSSets(ctx context.Context, cfg *RenderedConfig) {
for i := range cfg.Sets {
set := &cfg.Sets[i]
if set.Kind != "dns" {
continue
}
var members []string
var anyErr bool
for _, fqdn := range set.FQDNs {
cidrs, err := r.Resolve(ctx, fqdn)
if err != nil {
slog.Warn("agent: dns resolution failed, keeping last-good", "set", set.Name, "fqdn", fqdn, "err", err)
anyErr = true
continue
}
members = append(members, cidrs...)
}
// Only replace membership when we resolved something; never empty a set
// on total failure.
if len(members) > 0 {
set.Members = dedup(members)
} else if anyErr {
slog.Warn("agent: dns set kept last-good members", "set", set.Name)
}
}
}
func dedup(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := in[:0]
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// validateCIDR is a small guard used by translation to skip malformed members.
func validateCIDR(s string) error {
if _, _, err := net.ParseCIDR(s); err != nil {
return fmt.Errorf("invalid CIDR %q: %w", s, err)
}
return nil
}