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.
115 lines
3.0 KiB
Go
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
|
|
}
|