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.
This commit is contained in:
benvin
2026-07-20 20:05:49 +10:00
parent 8d9a76c751
commit e0f54ef320
20 changed files with 1414 additions and 82 deletions
+114
View File
@@ -0,0 +1,114 @@
package agent
import (
"context"
"fmt"
"log/slog"
"time"
"git.unkin.net/unkin/tomswall/internal/config"
"git.unkin.net/unkin/tomswall/internal/nftables"
)
// Applier applies a translated config to the firewall. Abstracted so the run
// loop is testable without touching the kernel.
type Applier interface {
Apply(ctx context.Context, cfg *config.Config) error
}
// Agent runs the pull-apply-report loop for one device.
type Agent struct {
Client *Client
Cache Cache
Interval time.Duration
Applier Applier
// Resolver overrides the DNS resolver (tests); nil derives it per-config.
Resolver *Resolver
}
// Run loops until ctx is cancelled, applying one cycle per Interval (and once
// immediately). A failed cycle is logged and retried on the next tick — the loop
// never exits on transient errors.
func (a *Agent) Run(ctx context.Context) error {
if a.Interval <= 0 {
a.Interval = time.Minute
}
t := time.NewTicker(a.Interval)
defer t.Stop()
for {
if err := a.RunOnce(ctx); err != nil {
slog.Error("agent: apply cycle failed", "err", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
}
// RunOnce performs a single pull-apply-report cycle. On a fetch failure it falls
// back to the on-disk cache and re-applies it — it never fails closed.
func (a *Agent) RunOnce(ctx context.Context) error {
rc, raw, err := a.Client.FetchConfig(ctx)
if err != nil {
slog.Warn("agent: control plane unreachable, using cached config", "err", err)
cached, cerr := a.Cache.Read()
if cerr != nil {
return fmt.Errorf("read cache: %w", cerr)
}
if cached == nil {
return fmt.Errorf("control plane unreachable and no cached config: %w", err)
}
// Re-apply last known-good; do not report a generation we didn't fetch.
return a.applyConfig(ctx, cached, false)
}
if err := a.Cache.Write(raw); err != nil {
slog.Warn("agent: caching config failed", "err", err)
}
return a.applyConfig(ctx, rc, true)
}
func (a *Agent) applyConfig(ctx context.Context, rc *RenderedConfig, report bool) error {
resolver := a.Resolver
if resolver == nil {
resolver = NewResolver(rc.Resolver)
}
resolver.ExpandDNSSets(ctx, rc)
cfg, err := Translate(rc)
if err != nil {
return fmt.Errorf("translate: %w", err)
}
if err := a.Applier.Apply(ctx, cfg); err != nil {
return fmt.Errorf("apply: %w", err)
}
slog.Info("agent: applied config", "generation", rc.Generation, "rules", len(cfg.Rules))
if report {
if err := a.Client.ReportStatus(ctx, rc.Generation); err != nil {
slog.Warn("agent: reporting status failed", "err", err)
}
}
return nil
}
// EngineApplier applies via the real nftables differential engine.
type EngineApplier struct{}
// Apply computes and applies the differential change set for cfg.
func (EngineApplier) Apply(_ context.Context, cfg *config.Config) error {
engine, err := nftables.NewEngine(cfg)
if err != nil {
return fmt.Errorf("initializing nftables: %w", err)
}
changes, err := engine.Plan()
if err != nil {
return fmt.Errorf("computing changes: %w", err)
}
if changes.Empty() {
return nil
}
return engine.Apply(changes)
}
+205
View File
@@ -0,0 +1,205 @@
package agent
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"git.unkin.net/unkin/tomswall/internal/config"
)
func TestTranslateInterfaceAgnosticRule(t *testing.T) {
rc := &RenderedConfig{
Generation: 5,
Device: "fw-a",
Enforcing: true,
Settings: RenderedSettings{AddressFamily: "inet", LogLevel: "info", TableName: "tomswall"},
Bindings: map[string][]string{"zone-a": {"eth1"}},
Sets: []RenderedSet{
{Name: "asn_cloudflare", Kind: "asn", Members: []string{"104.16.0.0/13", "1.1.1.0/24"}},
},
Rules: []RenderedRule{
{
Action: "accept",
Source: []RenderedMatch{{Zone: "zone-a", Subnets: []string{"10.1.0.0/24"}}},
Dest: []RenderedMatch{{Zone: "net", Set: "asn_cloudflare"}},
Proto: "tcp",
Ports: []string{"443"},
},
},
}
cfg, err := Translate(rc)
if err != nil {
t.Fatalf("Translate: %v", err)
}
// fw zone + zone-a.
if _, ok := cfg.Zones["fw"]; !ok {
t.Error("missing firewall zone")
}
if _, ok := cfg.Zones["zone-a"]; !ok {
t.Error("missing zone-a")
}
// binding -> interface.
if len(cfg.Interfaces) != 1 || cfg.Interfaces[0].Interface != "eth1" {
t.Errorf("expected one eth1 interface, got %+v", cfg.Interfaces)
}
// 1 source addr x 2 dest addrs (asn set members) = 2 rules.
if len(cfg.Rules) != 2 {
t.Fatalf("expected 2 expanded rules, got %d: %+v", len(cfg.Rules), cfg.Rules)
}
for _, r := range cfg.Rules {
if r.Source != "all:10.1.0.0/24" {
t.Errorf("source not interface-agnostic saddr match: %q", r.Source)
}
if r.Action != config.RuleAccept || r.Proto != "tcp" || len(r.DPort) != 1 || r.DPort[0] != "443" {
t.Errorf("unexpected rule: %+v", r)
}
}
dests := map[string]bool{cfg.Rules[0].Dest: true, cfg.Rules[1].Dest: true}
if !dests["all:104.16.0.0/13"] || !dests["all:1.1.1.0/24"] {
t.Errorf("dest set members not inlined: %v", dests)
}
}
func TestTranslateBareZone(t *testing.T) {
rc := &RenderedConfig{
Enforcing: true,
Rules: []RenderedRule{{
Action: "accept",
Source: []RenderedMatch{{Zone: "zone-a", Subnets: []string{"10.1.0.0/24"}}},
Dest: []RenderedMatch{{Zone: "zone-b", Subnets: []string{"10.4.0.0/24"}}},
Proto: "tcp", Ports: []string{"22"},
}},
}
cfg, err := Translate(rc)
if err != nil {
t.Fatalf("Translate: %v", err)
}
if len(cfg.Rules) != 1 || cfg.Rules[0].Source != "all:10.1.0.0/24" || cfg.Rules[0].Dest != "all:10.4.0.0/24" {
t.Errorf("unexpected zone-to-zone rule: %+v", cfg.Rules)
}
}
func TestTranslateRejectsUnknownAction(t *testing.T) {
rc := &RenderedConfig{Enforcing: true, Rules: []RenderedRule{{Action: "bogus"}}}
if _, err := Translate(rc); err == nil {
t.Fatal("expected error for unknown action")
}
}
// fakeApplier records applied configs.
type fakeApplier struct {
count int32
lastGen int
}
func (f *fakeApplier) Apply(_ context.Context, cfg *config.Config) error {
atomic.AddInt32(&f.count, 1)
f.lastGen = len(cfg.Rules)
return nil
}
const renderedYAML = `generation: 7
device: fw-a
enforcing: true
settings:
address_family: inet
log_level: info
table_name: tomswall
bindings:
zone-a: [eth1]
rules:
- action: accept
source:
- zone: zone-a
subnets: ["10.1.0.0/24"]
dest:
- zone: zone-b
subnets: ["10.4.0.0/24"]
proto: tcp
ports: ["22"]
`
func TestRunOnceAppliesAndReports(t *testing.T) {
var reported int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/devices/fw-a/config":
w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write([]byte(renderedYAML))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/devices/fw-a/status":
atomic.StoreInt64(&reported, 1)
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
applier := &fakeApplier{}
a := &Agent{
Client: NewClient(srv.URL, "fw-a", "tok"),
Cache: Cache{Path: filepath.Join(t.TempDir(), "cache.yaml")},
Applier: applier,
}
if err := a.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if atomic.LoadInt32(&applier.count) != 1 {
t.Errorf("expected 1 apply, got %d", applier.count)
}
if atomic.LoadInt64(&reported) != 1 {
t.Error("expected status to be reported")
}
// Cache should now be populated.
if cached, err := a.Cache.Read(); err != nil || cached == nil || cached.Generation != 7 {
t.Errorf("cache not written correctly: %+v (err %v)", cached, err)
}
}
func TestRunOnceFallsBackToCacheNeverFailsClosed(t *testing.T) {
// Server always errors — the control plane is "unreachable".
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
cachePath := filepath.Join(t.TempDir(), "cache.yaml")
if err := (Cache{Path: cachePath}).Write([]byte(renderedYAML)); err != nil {
t.Fatalf("seed cache: %v", err)
}
applier := &fakeApplier{}
a := &Agent{
Client: NewClient(srv.URL, "fw-a", "tok"),
Cache: Cache{Path: cachePath},
Applier: applier,
}
// Fetch fails, but the cached config must still be applied (fail-safe).
if err := a.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce should not error when a cache exists: %v", err)
}
if atomic.LoadInt32(&applier.count) != 1 {
t.Errorf("expected cached config to be applied, got %d applies", applier.count)
}
}
func TestRunOnceNoCacheReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
a := &Agent{
Client: NewClient(srv.URL, "fw-a", "tok"),
Cache: Cache{Path: filepath.Join(t.TempDir(), "absent.yaml")},
Applier: &fakeApplier{},
}
if err := a.RunOnce(context.Background()); err == nil {
t.Fatal("expected error when unreachable and no cache exists")
}
}
+36
View File
@@ -0,0 +1,36 @@
package agent
import (
"os"
"path/filepath"
)
// Cache persists the last known-good rendered config to disk so the agent can
// keep applying it when the control plane is unreachable (never fail closed).
type Cache struct {
Path string
}
// Write atomically stores the raw config bytes.
func (c Cache) Write(raw []byte) error {
if err := os.MkdirAll(filepath.Dir(c.Path), 0o755); err != nil {
return err
}
tmp := c.Path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, c.Path)
}
// Read returns the cached config, or (nil, nil) when no cache exists yet.
func (c Cache) Read() (*RenderedConfig, error) {
raw, err := os.ReadFile(c.Path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
return ParseRendered(raw)
}
+94
View File
@@ -0,0 +1,94 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"gopkg.in/yaml.v3"
)
// Client talks to the tomswallapi control plane for one device.
type Client struct {
BaseURL string
Device string
Token string
HTTP *http.Client
}
// NewClient builds a Client with a sane default timeout.
func NewClient(baseURL, device, token string) *Client {
return &Client{
BaseURL: baseURL,
Device: device,
Token: token,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// FetchConfig retrieves the device's rendered config. It returns both the parsed
// document and the raw bytes (so callers can cache exactly what was served).
func (c *Client) FetchConfig(ctx context.Context) (*RenderedConfig, []byte, error) {
url := fmt.Sprintf("%s/api/v1/devices/%s/config", c.BaseURL, c.Device)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "application/yaml")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("fetch config: status %d: %s", resp.StatusCode, bytes.TrimSpace(body))
}
cfg, err := ParseRendered(body)
if err != nil {
return nil, nil, err
}
return cfg, body, nil
}
// ParseRendered decodes a rendered config document (YAML, JSON is a subset).
func ParseRendered(body []byte) (*RenderedConfig, error) {
var cfg RenderedConfig
if err := yaml.Unmarshal(body, &cfg); err != nil {
return nil, fmt.Errorf("parsing rendered config: %w", err)
}
return &cfg, nil
}
// ReportStatus tells the control plane which generation this device has applied.
func (c *Client) ReportStatus(ctx context.Context, generation int64) error {
url := fmt.Sprintf("%s/api/v1/devices/%s/status", c.BaseURL, c.Device)
payload, _ := json.Marshal(map[string]int64{"generation": generation})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode >= 400 {
return fmt.Errorf("report status: %d", resp.StatusCode)
}
return nil
}
+70
View File
@@ -0,0 +1,70 @@
// Package agent implements `tomswall agent`: it pulls a device's compiled config
// from tomswallapi, differentially applies it, and reports the applied generation.
// It never fails closed — if the control plane is unreachable it keeps the last
// known-good config running.
package agent
// RenderedConfig is the per-device document served by tomswallapi at
// GET /api/v1/devices/{name}/config. It mirrors the control plane's compiler
// output: interface-agnostic, address-matched rules plus named sets.
type RenderedConfig struct {
Generation int64 `yaml:"generation" json:"generation"`
Device string `yaml:"device" json:"device"`
Class string `yaml:"class" json:"class"`
Enforcing bool `yaml:"enforcing" json:"enforcing"`
Settings RenderedSettings `yaml:"settings" json:"settings"`
Resolver []string `yaml:"resolver,omitempty" json:"resolver,omitempty"`
Bindings map[string][]string `yaml:"bindings,omitempty" json:"bindings,omitempty"` // zone -> interfaces
Sets []RenderedSet `yaml:"sets,omitempty" json:"sets,omitempty"`
Rules []RenderedRule `yaml:"rules,omitempty" json:"rules,omitempty"`
Policies []RenderedPolicy `yaml:"policies,omitempty" json:"policies,omitempty"`
}
type RenderedSettings struct {
AddressFamily string `yaml:"address_family" json:"address_family"`
LogLevel string `yaml:"log_level" json:"log_level"`
IPForwarding bool `yaml:"ip_forwarding" json:"ip_forwarding"`
TableName string `yaml:"table_name" json:"table_name"`
}
// RenderedSet is an address group's nftables set. Members carries the concrete
// elements the control plane knows (static CIDRs, expanded ASN prefixes); FQDNs
// are resolved on-device; ASNs are informational (already expanded into Members).
type RenderedSet struct {
Name string `yaml:"name" json:"name"`
Kind string `yaml:"kind" json:"kind"` // static | dns | asn
Members []string `yaml:"members,omitempty" json:"members,omitempty"`
FQDNs []string `yaml:"fqdns,omitempty" json:"fqdns,omitempty"`
ASNs []string `yaml:"asns,omitempty" json:"asns,omitempty"`
Refresh string `yaml:"refresh,omitempty" json:"refresh,omitempty"`
}
// RenderedMatch is one OR'd element of a rule direction: a zone's subnets AND,
// optionally, a named set to intersect with.
type RenderedMatch struct {
Zone string `yaml:"zone" json:"zone"`
Subnets []string `yaml:"subnets,omitempty" json:"subnets,omitempty"`
Set string `yaml:"set,omitempty" json:"set,omitempty"`
}
type RenderedRule struct {
Action string `yaml:"action" json:"action"`
Source []RenderedMatch `yaml:"source" json:"source"`
Dest []RenderedMatch `yaml:"dest" json:"dest"`
Proto string `yaml:"proto,omitempty" json:"proto,omitempty"`
Ports []string `yaml:"ports,omitempty" json:"ports,omitempty"`
Log string `yaml:"log,omitempty" json:"log,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedPolicy struct {
Priority int `yaml:"priority" json:"priority"`
Source string `yaml:"source" json:"source"`
Dest string `yaml:"dest" json:"dest"`
Action string `yaml:"action" json:"action"`
Log string `yaml:"log,omitempty" json:"log,omitempty"`
}
// setMembers returns the concrete address elements for a set: static/asn use
// Members; dns is resolved separately and merged in before translation.
func (s RenderedSet) staticMembers() []string { return s.Members }
+114
View File
@@ -0,0 +1,114 @@
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
}
+166
View File
@@ -0,0 +1,166 @@
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
}
+13 -13
View File
@@ -32,15 +32,15 @@ type Config struct {
ProxyNDP []ProxyNDP `yaml:"proxyndp,omitempty"`
Routes []StaticRoute `yaml:"routes,omitempty"`
ArpRules []ArpRule `yaml:"arprules,omitempty"`
Accounting []AccountingRule `yaml:"accounting,omitempty"`
Mangle []MangleRule `yaml:"mangle,omitempty"`
Maclist []MaclistEntry `yaml:"maclist,omitempty"`
TCDevices []TCDevice `yaml:"tcdevices,omitempty"`
TCClasses []TCClass `yaml:"tcclasses,omitempty"`
TCFilters []TCFilter `yaml:"tcfilters,omitempty"`
TCInterfaces []TCInterface `yaml:"tcinterfaces,omitempty"`
TCPriorities []TCPriority `yaml:"tcpriority,omitempty"`
Secmarks []SecmarkRule `yaml:"secmarks,omitempty"`
Accounting []AccountingRule `yaml:"accounting,omitempty"`
Mangle []MangleRule `yaml:"mangle,omitempty"`
Maclist []MaclistEntry `yaml:"maclist,omitempty"`
TCDevices []TCDevice `yaml:"tcdevices,omitempty"`
TCClasses []TCClass `yaml:"tcclasses,omitempty"`
TCFilters []TCFilter `yaml:"tcfilters,omitempty"`
TCInterfaces []TCInterface `yaml:"tcinterfaces,omitempty"`
TCPriorities []TCPriority `yaml:"tcpriority,omitempty"`
Secmarks []SecmarkRule `yaml:"secmarks,omitempty"`
}
type AddressFamily string
@@ -52,10 +52,10 @@ const (
)
type Settings struct {
AddressFamily AddressFamily `yaml:"address_family,omitempty"`
IPForwarding bool `yaml:"ip_forwarding"`
LogLevel string `yaml:"log_level"`
TableName string `yaml:"table_name"`
AddressFamily AddressFamily `yaml:"address_family,omitempty"`
IPForwarding bool `yaml:"ip_forwarding"`
LogLevel string `yaml:"log_level"`
TableName string `yaml:"table_name"`
// When true, auto-generate CONTINUE policies for sub-zones to their parent zones.
ImplicitContinue bool `yaml:"implicit_continue,omitempty"`
+2 -3
View File
@@ -992,8 +992,8 @@ func TestValidateSNAT(t *testing.T) {
wantErr: "persistent requires an address",
},
{
name: "empty snat list is valid",
snat: nil,
name: "empty snat list is valid",
snat: nil,
},
}
@@ -1006,4 +1006,3 @@ func TestValidateSNAT(t *testing.T) {
})
}
}
+11 -12
View File
@@ -215,7 +215,7 @@ func TestValidateRoutingRules(t *testing.T) {
},
},
{
name: "missing providers",
name: "missing providers",
setup: baseConfig,
rules: []RoutingRule{
{Source: "10.0.0.0/8", Provider: "isp1", Priority: 1000},
@@ -1174,9 +1174,9 @@ func TestResolveNesting(t *testing.T) {
t.Run("simple parent-child hierarchy", func(t *testing.T) {
cfg := Config{
Zones: map[string]Zone{
"fw": {Type: ZoneFirewall},
"net": {Type: ZoneIP},
"dmz": {Type: ZoneIP, Parents: []string{"net"}},
"fw": {Type: ZoneFirewall},
"net": {Type: ZoneIP},
"dmz": {Type: ZoneIP, Parents: []string{"net"}},
},
}
order, err := cfg.ResolveNesting()
@@ -1259,9 +1259,9 @@ func TestIsSubZone(t *testing.T) {
}{
{"dmz", "net", true},
{"web", "dmz", true},
{"web", "net", true}, // transitive
{"net", "dmz", false}, // reverse
{"net", "net", false}, // self
{"web", "net", true}, // transitive
{"net", "dmz", false}, // reverse
{"net", "net", false}, // self
{"nosuch", "net", false}, // non-existent
}
@@ -1305,10 +1305,10 @@ func TestValidateName(t *testing.T) {
func TestSubstituteVars(t *testing.T) {
tests := []struct {
name string
input string
vars map[string]string
want string
name string
input string
vars map[string]string
want string
}{
{
name: "braced substitution",
@@ -1441,4 +1441,3 @@ func TestValidateSettings(t *testing.T) {
})
}
}
+6 -6
View File
@@ -13,14 +13,14 @@ type Interface struct {
type InterfaceOptions struct {
// Rule generation options
DHCP bool `yaml:"dhcp,omitempty"`
DHCP bool `yaml:"dhcp,omitempty"`
TCPFlags *bool `yaml:"tcpflags,omitempty"`
NoSmurfs bool `yaml:"nosmurfs,omitempty"`
NoSmurfs bool `yaml:"nosmurfs,omitempty"`
RouteBack *bool `yaml:"routeback,omitempty"`
Bridge bool `yaml:"bridge,omitempty"`
DestOnly bool `yaml:"destonly,omitempty"`
Unmanaged bool `yaml:"unmanaged,omitempty"`
Upnp bool `yaml:"upnp,omitempty"`
Bridge bool `yaml:"bridge,omitempty"`
DestOnly bool `yaml:"destonly,omitempty"`
Unmanaged bool `yaml:"unmanaged,omitempty"`
Upnp bool `yaml:"upnp,omitempty"`
// Startup behavior
Optional bool `yaml:"optional,omitempty"`
+7 -7
View File
@@ -91,13 +91,13 @@ type Rule struct {
}
type TimeSpec struct {
Start string `yaml:"start,omitempty"`
Stop string `yaml:"stop,omitempty"`
Weekdays []string `yaml:"weekdays,omitempty"`
Monthdays []int `yaml:"monthdays,omitempty"`
DateStart string `yaml:"date_start,omitempty"`
DateStop string `yaml:"date_stop,omitempty"`
UTC bool `yaml:"utc,omitempty"`
Start string `yaml:"start,omitempty"`
Stop string `yaml:"stop,omitempty"`
Weekdays []string `yaml:"weekdays,omitempty"`
Monthdays []int `yaml:"monthdays,omitempty"`
DateStart string `yaml:"date_start,omitempty"`
DateStop string `yaml:"date_stop,omitempty"`
UTC bool `yaml:"utc,omitempty"`
}
// PortSpec supports single ports, ranges, and lists.
+6 -6
View File
@@ -5,8 +5,8 @@ import "fmt"
// TCDevice defines a traffic-shaped interface with bandwidth limits.
type TCDevice struct {
Interface string `yaml:"interface"`
InBandwidth string `yaml:"in_bandwidth,omitempty"` // ingress rate limit
OutBandwidth string `yaml:"out_bandwidth"` // egress max
InBandwidth string `yaml:"in_bandwidth,omitempty"` // ingress rate limit
OutBandwidth string `yaml:"out_bandwidth"` // egress max
Options TCDeviceOptions `yaml:"options,omitempty"`
Comment string `yaml:"comment,omitempty"`
}
@@ -20,10 +20,10 @@ type TCDeviceOptions struct {
// TCClass defines an HTB/HFSC traffic class with rate guarantees.
type TCClass struct {
Interface string `yaml:"interface"` // format: iface:class or iface:parent:class
Mark int `yaml:"mark,omitempty"` // 1-255 fw mark
Rate string `yaml:"rate"` // minimum guaranteed bandwidth
Ceil string `yaml:"ceil,omitempty"` // max bandwidth
Interface string `yaml:"interface"` // format: iface:class or iface:parent:class
Mark int `yaml:"mark,omitempty"` // 1-255 fw mark
Rate string `yaml:"rate"` // minimum guaranteed bandwidth
Ceil string `yaml:"ceil,omitempty"` // max bandwidth
Priority int `yaml:"priority,omitempty"` // scheduling order
Options TCClassOptions `yaml:"options,omitempty"`
Comment string `yaml:"comment,omitempty"`
+11 -11
View File
@@ -5,19 +5,19 @@ import "fmt"
type TunnelType string
const (
TunnelIPSec TunnelType = "ipsec"
TunnelIPSecNAT TunnelType = "ipsecnat"
TunnelIPIP TunnelType = "ipip"
TunnelGRE TunnelType = "gre"
TunnelL2TP TunnelType = "l2tp"
TunnelPPTPClient TunnelType = "pptpclient"
TunnelPPTPServer TunnelType = "pptpserver"
TunnelOpenVPN TunnelType = "openvpn"
TunnelIPSec TunnelType = "ipsec"
TunnelIPSecNAT TunnelType = "ipsecnat"
TunnelIPIP TunnelType = "ipip"
TunnelGRE TunnelType = "gre"
TunnelL2TP TunnelType = "l2tp"
TunnelPPTPClient TunnelType = "pptpclient"
TunnelPPTPServer TunnelType = "pptpserver"
TunnelOpenVPN TunnelType = "openvpn"
TunnelOpenVPNClient TunnelType = "openvpnclient"
TunnelOpenVPNServer TunnelType = "openvpnserver"
TunnelTinc TunnelType = "tinc"
Tunnel6to4 TunnelType = "6to4"
TunnelGeneric TunnelType = "generic"
TunnelTinc TunnelType = "tinc"
Tunnel6to4 TunnelType = "6to4"
TunnelGeneric TunnelType = "generic"
)
// Tunnel defines VPN tunnel rules that allow encapsulated traffic to pass
+14 -14
View File
@@ -1126,19 +1126,19 @@ func matchTCPFlagsDrop(iface string) []expr.Any {
}
var icmpTypeNames = map[string]byte{
"echo-reply": 0,
"destination-unreachable": 3,
"source-quench": 4,
"redirect": 5,
"echo-request": 8,
"router-advertisement": 9,
"router-solicitation": 10,
"time-exceeded": 11,
"parameter-problem": 12,
"timestamp-request": 13,
"timestamp-reply": 14,
"address-mask-request": 17,
"address-mask-reply": 18,
"echo-reply": 0,
"destination-unreachable": 3,
"source-quench": 4,
"redirect": 5,
"echo-request": 8,
"router-advertisement": 9,
"router-solicitation": 10,
"time-exceeded": 11,
"parameter-problem": 12,
"timestamp-request": 13,
"timestamp-reply": 14,
"address-mask-request": 17,
"address-mask-reply": 18,
}
func matchICMPType(spec string) []expr.Any {
@@ -1589,7 +1589,7 @@ func buildLog(level, prefix string) []expr.Any {
}
return []expr.Any{
&expr.Log{
Key: 1 << unix.NFTA_LOG_PREFIX | 1<<unix.NFTA_LOG_LEVEL,
Key: 1<<unix.NFTA_LOG_PREFIX | 1<<unix.NFTA_LOG_LEVEL,
Level: nfLevel,
Data: []byte(logPrefix),
},
+6 -6
View File
@@ -915,9 +915,9 @@ func TestCompile_RateLimit(t *testing.T) {
{
Action: config.RuleAccept,
Source: "net",
Dest: "fw",
Proto: "tcp",
DPort: config.PortSpec{"22"},
Dest: "fw",
Proto: "tcp",
DPort: config.PortSpec{"22"},
RateLimit: "10/sec:5",
},
},
@@ -1353,9 +1353,9 @@ func TestCompile_ConnLimit(t *testing.T) {
{
Action: config.RuleAccept,
Source: "net",
Dest: "fw",
Proto: "tcp",
DPort: config.PortSpec{"22"},
Dest: "fw",
Proto: "tcp",
DPort: config.PortSpec{"22"},
ConnLimit: "20",
},
},
+4 -4
View File
@@ -187,11 +187,11 @@ SINGLE_QUOTED='another'
}
tests := map[string]string{
"IP_FORWARDING": "Yes",
"LOG_LEVEL": "info",
"IP_FORWARDING": "Yes",
"LOG_LEVEL": "info",
"STARTUP_ENABLED": "Yes",
"QUOTED_VALUE": "some value",
"SINGLE_QUOTED": "another",
"QUOTED_VALUE": "some value",
"SINGLE_QUOTED": "another",
}
for k, want := range tests {
got, ok := conf[k]