diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..3253925 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,457 @@ +# tomswall control plane — design + +Status: draft / theory-craft. No implementation yet. + +This document specifies a **fleet control plane** for tomswall: a central, +Terraform-managed API that lets you declare zones, address groups, and firewall +policy **once** and have every device in a routed estate enforce a provably +consistent posture. tomswall itself remains the per-host nftables engine; this +adds an orchestration layer above the fleet. + +--- + +## 1. Motivation + +Today each tomswall host owns a complete, independent `tomswall.yaml` — its own +zones, interfaces, policy, and rules. In an estate of many firewalls and routers +that is unmanageable: a single logical intent ("hosts in zone A may reach zone B +on tcp/22") has to be hand-translated into per-hop rules on every device along +every possible path. + +The control plane inverts this. You declare the **intent once**; the API compiles +it into the concrete rules each device needs and serves each device its rendered +config. A connection that crosses several firewalls — +`src → rt1 → rt2 → rt3 → dest` — is expressed as a single rule. + +Key environment facts this design is built around: + +- Internal routing is **dynamic (FRR)** with **ECMP** — paths are not stable and + cannot be pinned. +- **NAT happens only at the edge**; the internal core is purely routed. +- Devices fall into two classes: **routers** (FRR core) and **firewalls** (zone + boundary enforcement). + +--- + +## 2. Terminology + +| term | meaning | +|---|---| +| **zone** | A fleet-global named network segment (a set of subnets). Replaces per-host zones. | +| **subzone** | A zone nested inside a parent zone; its subnets must be ⊂ the parent's. | +| **address group** | A fleet-global named set of addresses → materialized as an nftables named set (ipset). Population source is `static`, `dns`, or `asn`. | +| **fabric** | A routing domain: a group of routers (and the firewall edges attached to it) that share an FRR routing fabric. | +| **binding** | A per-device mapping of a global zone to that device's local interface(s). The only legitimately host-specific object. | +| **intent / rule** | A fleet-global `source → dest` policy statement, matched on zones and/or address groups. | +| **generation** | A monotonic version stamp on rendered config; devices report the generation they have applied. | + +--- + +## 3. Architecture + +``` + terraform ──▶ tomswallapi ◀── peers with FRR (BGP-LS / BMP) + │ · inventory + topology (devices, zones, fabrics, bindings) + │ · match/address-group model (static/dns/asn ipsets) + │ · compiler: intents → per-device tomswall.yaml + │ · reachability validation from routing state + │ · per-device config + set-delta feed (authenticated) + ▼ + fw-a rt1 rt2 rt3 fw-b ── each runs `tomswall agent`: + pull config → differential apply → report generation + + on-device resolver maintains dns ipsets +``` + +Three moving parts: + +1. **`tomswallapi`** — the control plane. Stores the model (Postgres, per the + house stack), peers with FRR for routing/reachability, compiles intents into + per-device configs, and serves them. +2. **`tomswall agent`** — a new pull mode on the existing binary. Periodically + fetches its rendered config, runs the existing differential `apply`, maintains + dns-backed ipsets via an on-device resolver, and reports its applied generation. +3. **FRR peering** — the API consumes routing state (BGP-LS for topology, BMP / + route monitoring for prefix reachability) so it can validate that a zone's + subnet really originates where its firewall claims, and scope which routers an + intent must touch. + +--- + +## 4. Data model: two layers + +The founding constraint: + +> Every host may have a different zone→interface mapping, but all share the same +> zones, address groups, policies, and rules. + +That splits the model into two layers with a hard wall between them. + +### 4.1 Global layer (defined once, byte-identical everywhere) + +`zones` · `subzones` · `address_groups` · `portgroups` · `policies` · `rules` · +`blrules` · `conntrack` · `secmarks` · `vars` · `fabrics`. + +**None of these ever names an interface.** The rule `zone-a → zone-b tcp/22` is +the same object on every device. + +### 4.2 Device layer (the only per-host freedom) + +`class` (router/firewall) · `fabric` membership · per-device `settings` overrides · +`resolver` config · and the **binding table** (`zone → interface(s)`). + +The rendered per-device `tomswall.yaml` = **global rules projected through that +device's binding table**. Same intent, different local interface resolution: + +``` +GLOBAL (shared): rule zone-a → zone-b tcp/22 accept + zones zone-a=10.1.0.0/24 zone-b=10.4.0.0/24 + +fw-a bindings: zone-a → eth1 ; → bond0 (fabric "core") +fw-b bindings: zone-b → ens5 ; → ens4 (fabric "core") +rt3: no zone bindings ; fabric "core" +``` + +`zone-a` is *attached* on fw-a (resolved via its eth1 binding) but *remote* on +fw-b and rt3 (resolved purely by subnet match on the core side). That asymmetry is +fine because the compiled rule is interface-agnostic (§7). + +--- + +## 5. Device classes and fabrics + +- **firewall** — a zone boundary. Zones "live" on firewall interfaces; a firewall + enforces with default-drop between zones and is where the real policy edge sits. +- **router** — an FRR core member belonging to a **fabric**. Enforces + interface-agnostically (any interface, address-matched) because ECMP means the + path is not pinnable. + +A **fabric** is a routing domain with an `enforce_on_routers` flag: + +- `enforce_on_routers = false` (transparent transit) — routers route freely for + internal ranges and rely on conntrack; only boundary firewalls enforce the + intent. Fewest rules; no in-core containment. +- `enforce_on_routers = true` (defense-in-depth) — every router in the fabric also + carries the intent as an interface-agnostic permit with default-drop transit. + Every hop is a checkpoint; contains a compromised core node. + +Per-fabric choice lets a small trusted core run transparent while a larger or +multi-tenant core runs defense-in-depth. + +--- + +## 6. The match model + +Source and dest use a **shorewall-style** grammar. Each direction is a comma-list +of elements; **within an element a zone gates the selector (AND); across elements +the list is a union (OR)**. + +``` +element := zone # bare zone — the zone-to-zone base case + | zone ":" selector # zone AND selector (selector must be paired) +selector := "+" ipset # named address group (static / dns / asn) + | "&" fqdn-group # (fqdn groups are just dns-typed ipsets; "+" also accepted) + +source = "loc, net:+asn_cloudflare, dmz:+partner_api" + # loc OR (net AND asn_cloudflare) OR (dmz AND partner_api) +``` + +Rules: + +- **Bare zone → legal.** `loc → net`, the zone-to-zone base case. +- **`zone:+selector` → legal.** The selector must always be paired with a zone. +- **Bare selector → rejected at plan time.** No floating `+ipset` / `asn:` without + a zone. + +Why the pairing is structural, not cosmetic: an internet-facing zone like `net` +has no finite subnet — it is "everything else" — so it can never stand as a clean +address match on its own. Pairing supplies the missing halves: **the zone gives +direction/interface, the selector gives concrete addresses.** + +``` +rule: loc → net:+asn_cloudflare tcp/443 +edge fw render: oif= daddr @asn_cloudflare tcp dport 443 accept + # zone `net` → the internet-facing binding; + # asn_cloudflare → the actual prefixes to match +``` + +Internal zones (which have subnets) may stand bare; internet/edge zones +effectively require a selector. + +--- + +## 7. Compilation + +### 7.1 Interface-agnostic, address-matched rules (the ECMP unlock) + +Because FRR picks paths dynamically and load-balances across ECMP, rules must +**not** be compiled to per-hop `iif/oif`. Every device carries the same rule +matched on `saddr ∈ source, daddr ∈ dest, proto, port` in the forward chain, on +**any interface**: + +``` +rule: zone-a (10.1.0.0/24 @ fw-a) → zone-b (10.4.0.0/24 @ fw-b) tcp/22 + +fw-a (firewall): forward saddr 10.1.0.0/24 daddr 10.4.0.0/24 tcp dport 22 accept +rt1…N (routers): forward saddr 10.1.0.0/24 daddr 10.4.0.0/24 tcp dport 22 accept +fw-b (firewall): forward saddr 10.1.0.0/24 daddr 10.4.0.0/24 tcp dport 22 accept +``` + +- **Return traffic needs no rules.** Each device is independently stateful, so + every hop's own `ct state established,related accept` handles the reply. Only the + forward direction is emitted. +- **ECMP and asymmetric routing just work** — flow #1 may go rt1→rt3, flow #2 + rt1→rt2→rt3, and the return may take a third path; every device it touches + already permits the 5-tuple and holds its own conntrack state. + +### 7.2 Over-approximation is safe → no exact path computation + +Because rules are interface-agnostic and stateful, programming a permit on a +router the traffic never crosses just leaves an unused rule. So the compiler never +needs FRR's *actual* chosen path — only **which fabric(s) could carry A↔B**, which +is coarse and static-friendly. FRR peering (§3) is used to *narrow* the router set +(and to validate zone origins), not to pin a path. + +### 7.3 nftables set / map form for scale + +Every zone and address group is a **named nft set** (`flags interval` for CIDRs). +Rules reference sets by name. Where many intents land on the same core routers, +compile into **sets / verdict maps** keyed on `(saddr, daddr, proto, dport)` rather +than long linear chains, so match cost stays sub-linear. `zone:+ipset` compiles to +a conjunction of two set lookups (`ip saddr @z_zone` **and** `ip saddr @g_ipset`); +a multi-element list becomes multiple rule lines sharing a verdict. + +The critical property: **membership is the only thing that churns; rules are +stable.** Adding/removing an address is a set-element op — no rule reload, no chain +rebuild, existing connections preserved. + +--- + +## 8. Address groups (ipsets) + +An address group is always an nftables named set. What differs is only how its +members are populated: + +| type | member source | resolved where | refresh | +|---|---|---|---| +| `static` | explicit CIDRs/IPs | API (constant) | n/a | +| `dns` | FQDNs → A/AAAA | **on-device resolver** | per record TTL | +| `asn` | ASN(s) → prefixes via iplocate | **central API** | configurable, default 24h | + +### 8.1 ASN groups + +An ASN group is defined **once, globally**, and materializes a set named by +convention `asn_` (friendly) or `asn_`. An ISP may own several ASNs; +one group merges them into one set. Rules reference it like any ipset — +`net:+asn_cloudflare` — there is **no** special `asn:` selector token. + +```hcl +resource "tomswall_address_group" "asn_cloudflare" { + type = "asn" + asns = ["13335", "209242"] + refresh = "24h" # default; configurable per group / globally +} # → materializes nft set asn_cloudflare +``` + +Expansion is **centralized**: the API holds a single iplocate key (in Vault), +calls the ASN data-type endpoint, caches prefixes per ASN, refreshes on the TTL, +and ships prefix deltas as set-element ops. Devices never call iplocate. ASN +membership is therefore **fleet-uniform**. + +### 8.2 DNS groups and the per-host resolver + +DNS groups resolve **on-device**, so each site honors its own split-horizon / +geo-DNS view. The resolver used follows a precedence chain: + +``` +per-device resolver override → fleet default_resolver → system /etc/resolv.conf +``` + +```hcl +resource "tomswall_device" "fw_a" { resolver = ["10.1.0.53", "10.1.0.54"] } +# global: default_resolver = ["10.0.0.53"] (or "system") +``` + +A device in site A resolves `vpn.corp` against site A's resolver and populates +*its own* set from that view; site B may legitimately differ. DNS membership is +**not** guaranteed fleet-uniform — only the rules and set names are. Record TTL is +used as the nft **element timeout**, so stale entries self-evict and the resolver +refreshes before expiry. + +### 8.3 Set lifecycle notes + +- v4 and v6 members are held in parallel family sets (or inet-family sets). +- Element deltas (`nft add/delete element`) are a separate, lighter feed than rule + rollout; they never bump the rule generation. + +--- + +## 9. NAT / masquerade / netmap / policy — full parity + +The control plane is a **superset** of the tomswall config model, never a lossy +subset: the rendered per-device output *is* a full `tomswall.yaml`, so anything +tomswall can express is reachable. **Every section is a typed, first-class +resource — there is no raw-YAML escape hatch.** Sections are handled in one of +three tiers: + +- **Global-compiled** — defined once, projected identically to every relevant + device: `policy` (default posture), `rules`, `portgroups`, `zones`/subzones, + `blrules`, `conntrack`, `secmarks`, `vars`. +- **Global-intent, device-resolved** — defined once against zones; each device + resolves to its own interfaces via its binding table: `snat`/**masquerade**, + `netmap`, `nat` (1:1). Example: `masquerade source=loc egress=net` lands only on + devices that bind **both** `loc` and `net` (i.e. edge firewalls); interior + routers have no `net` binding and skip it automatically. +- **Per-device** — declared against a device (or a selector): `host`, `provider`, + `rtrules`, `route`, `tunnel`, `stopped_rule`, `proxy_arp`/`ndp`, `arp_rule`, + `maclist`, `accounting`, `mangle`, `tc_*`. + +--- + +## 10. Terraform resource catalog + +### Fleet / topology +- `tomswall_device` — name, `class` (router/firewall), `fabric`, per-device + `settings` overrides, `resolver` +- `tomswall_fabric` — routing domain, `enforce_on_routers` +- `tomswall_binding` — zone→interface map (per device+zone) +- `tomswall_settings` — global defaults (address_family, log_level, ip_forwarding, + table_name) + +### Global-compiled +- `tomswall_zone` — subnets, `parent` (subzones) +- `tomswall_address_group` — ipset; `type` = static/dns/asn +- `tomswall_portgroup` +- `tomswall_policy` — default zone→zone posture, `priority` +- `tomswall_rule` — the `zone:+ipset` / `&fqdn` intents +- `tomswall_blrule` +- `tomswall_conntrack` +- `tomswall_secmark` +- `tomswall_var` + +### Global-intent, device-resolved +- `tomswall_snat` — masquerade/SNAT (auto-scopes to devices binding both zones) +- `tomswall_netmap` — anchored subnet↔subnet +- `tomswall_nat` — 1:1 static, bound to the device holding the public IP + +### Per-device (`device` reference or a `class`/`fabric`/`all` selector) +- `tomswall_host` +- `tomswall_provider` +- `tomswall_routing_rule` (rtrules) +- `tomswall_route` +- `tomswall_tunnel` +- `tomswall_stopped_rule` +- `tomswall_proxy_arp` / `tomswall_proxy_ndp` +- `tomswall_arp_rule` +- `tomswall_maclist` +- `tomswall_accounting` +- `tomswall_mangle` +- `tomswall_tc_device` / `tomswall_tc_class` / `tomswall_tc_filter` / + `tomswall_tc_interface` / `tomswall_tc_priority` + +### Data sources +- `tomswall_device_config` — rendered `tomswall.yaml` preview for a device +- rule fanout preview — which devices an intent will touch (surfaced in `plan`) + +Per-device resources accept **either a single `device` or a selector** so common +objects (a shared static route, a provider) are declared once, preserving the +define-once ethos even in the local tier. + +--- + +## 11. Agent protocol + +`tomswall agent` (or a systemd timer invoking a pull) does: + +1. **Pull** its rendered config by `device_id` from the API (authenticated). +2. Write it to a **local cache file**. +3. Run the existing **differential `apply`** (compute diff vs live nftables, apply + only the delta atomically; never tears the firewall down). +4. Maintain **dns ipsets** via the on-device resolver (add/delete elements on TTL). +5. **Report** the applied `generation` back to the API. + +### 11.1 Do not fail closed + +On API-unreachable: **keep the cache, re-apply it (idempotent no-op), never flush +to deny.** Existing rules ride through control-plane outages untouched; only +*changes* require the API. This is a deliberate availability choice. + +### 11.2 Rollout & convergence + +A rule spanning several devices rolls out as each device pulls independently. +Mid-rollout the connection is blocked at whichever hop has not yet pulled — i.e. +**fail-closed for adds** (safe). Config is **generation-stamped** and devices +report the generation applied, giving a fleet-wide "converged / N behind" view. + +### 11.3 tomswall engine changes required + +- A **pull/agent mode** (`tomswall agent`, or `source: http` in the config loader). +- The **interface-agnostic rule form** (match `saddr`/`daddr` with no `iif/oif`). +- **Resolver config** and on-device dns-set maintenance. +- **Ownership tags** (resource-id + generation as nft comments) so `purge` / + foreign-rule detection never fights control-plane-managed content. + +--- + +## 12. Invariants (validated at plan time) + +1. **Zones/policies/rules/groups are global-only.** A device may *bind* a zone to + an interface; it may never *define* one. No local zone namespace. +2. **Subnet→zone is globally unique.** Every subnet belongs to exactly one zone + fleet-wide; no overlaps. (Ambiguous saddr/daddr matching otherwise.) +3. **Every zone in a rule must be resolvable on every enforcing device** — either + locally bound (attached) or remote-reachable via its fabric. Unresolvable → + rejection, not a silent gap. FRR reachability proves this. +4. **Each zone is anchored at ≥1 firewall interface** — the API knows where every + zone physically enters the fabric, cross-checked against FRR-advertised origins. +5. **A firewall must bind every directly-connected zone.** A connected subnet with + no zone identity is rejected/flagged (checked against FRR-reported prefixes). + Zones the device does not attach are implicitly remote — no verbose "not here" + declarations needed. +6. **Subzones nest by containment** — a subzone's subnet ⊂ its parent's; hierarchy + is global. +7. **Interface names appear only in the binding table.** An interface name leaking + into any global object → rejection. +8. **A selector never appears without a zone.** `zone:+ipset` / `zone:&fqdn` only; + bare `+ipset` / `asn:` → rejection. Bare zones remain legal. +9. **The zone in a pair supplies direction/interface; the selector supplies + addresses** — this is what lets no-subnet zones (`net`, edge) participate. +10. **Every tomswall section is a typed resource.** No raw-YAML passthrough; + nothing bypasses validation. +11. **Config is generation-stamped**; devices report the generation applied. + +--- + +## 13. Fail-safe semantics + +- **Resolution failure keeps last-good membership.** An iplocate outage or DNS + SERVFAIL must never empty a set. (Matches the agent's don't-fail-closed stance.) +- **A genuinely-empty group (NXDOMAIN, ASN with no prefixes) makes its rule inert + and is logged/flagged** — never "match everything." An unresolvable + source/dest disables its rule loudly, never opens it. +- **Adds fail closed, the control plane fails open.** Partial rollout blocks new + flows until every hop converges; a dead API leaves the last-good posture running. + +--- + +## 14. Security / auth + +- Agents authenticate to the API (mTLS or Vault-issued per-device tokens, per the + house pattern). +- The iplocate API key and any resolver credentials live in Vault. +- The API is the single source of truth; state in Postgres. +- Compiled objects carry ownership tags so the on-device engine can distinguish + control-plane content from local/foreign rules. + +--- + +## 15. Open questions / future work + +- **FRR integration depth** — BGP-LS (topology) vs BMP / route monitoring (prefix + reachability) vs a lighter agent-reported FIB. Start with what proves zone + origin and fabric membership; deepen as needed. +- **Set-union match ergonomics** — whether multi-element source/dest compiles to + multiple rule lines or a merged interval set; membership churn is handled at the + member-set level regardless. +- **NAT along non-edge paths** — out of scope by assumption (routed core, edge-only + NAT). Revisit only if internal translation is ever introduced. +- **Multi-tenancy** — whether fabrics/zones need tenant scoping for RBAC. diff --git a/cmd/tomswall/agent.go b/cmd/tomswall/agent.go new file mode 100644 index 0000000..6f91379 --- /dev/null +++ b/cmd/tomswall/agent.go @@ -0,0 +1,77 @@ +package main + +import ( + "context" + "fmt" + "os" + "os/signal" + "syscall" + "time" + + "github.com/spf13/cobra" + + "git.unkin.net/unkin/tomswall/internal/agent" +) + +func agentCmd() *cobra.Command { + var ( + apiURL string + device string + token string + cachePath string + interval time.Duration + once bool + ) + + cmd := &cobra.Command{ + Use: "agent", + Short: "Pull compiled config from tomswallapi and apply it continuously", + Long: `Agent runs the control-plane pull loop: it fetches this device's compiled +config from tomswallapi, differentially applies it, and reports the applied +generation back. It caches the last known-good config and, if the control plane +is unreachable, keeps applying that cache — it never fails closed. + +The agent token defaults to the TOMSWALL_AGENT_TOKEN environment variable, and +the device name defaults to the system hostname.`, + RunE: func(cmd *cobra.Command, args []string) error { + if token == "" { + token = os.Getenv("TOMSWALL_AGENT_TOKEN") + } + if device == "" { + device, _ = os.Hostname() + } + if apiURL == "" { + return fmt.Errorf("--api-url is required (or set it in the environment)") + } + if device == "" { + return fmt.Errorf("--device is required (could not determine hostname)") + } + if token == "" { + return fmt.Errorf("agent token required: set --token or TOMSWALL_AGENT_TOKEN") + } + + a := &agent.Agent{ + Client: agent.NewClient(apiURL, device, token), + Cache: agent.Cache{Path: cachePath}, + Interval: interval, + Applier: agent.EngineApplier{}, + } + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + if once { + return a.RunOnce(ctx) + } + return a.Run(ctx) + }, + } + + cmd.Flags().StringVar(&apiURL, "api-url", os.Getenv("TOMSWALL_API_URL"), "tomswallapi base URL (or TOMSWALL_API_URL)") + cmd.Flags().StringVar(&device, "device", "", "device name (defaults to hostname)") + cmd.Flags().StringVar(&token, "token", "", "agent bearer token (or TOMSWALL_AGENT_TOKEN)") + cmd.Flags().StringVar(&cachePath, "cache", "/var/lib/tomswall/rendered.yaml", "path to the last-known-good config cache") + cmd.Flags().DurationVar(&interval, "interval", time.Minute, "poll interval") + cmd.Flags().BoolVar(&once, "once", false, "run a single apply cycle and exit") + return cmd +} diff --git a/cmd/tomswall/main.go b/cmd/tomswall/main.go index 2cd6ca5..c617769 100644 --- a/cmd/tomswall/main.go +++ b/cmd/tomswall/main.go @@ -39,6 +39,7 @@ Use 'tomswall migrate' to convert a shorewall config to YAML.`, purgeCmd(), flushCmd(), migrateCmd(), + agentCmd(), completionCmd(), ) diff --git a/internal/agent/agent.go b/internal/agent/agent.go new file mode 100644 index 0000000..d72ef0a --- /dev/null +++ b/internal/agent/agent.go @@ -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) +} diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go new file mode 100644 index 0000000..b9e8d76 --- /dev/null +++ b/internal/agent/agent_test.go @@ -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") + } +} diff --git a/internal/agent/cache.go b/internal/agent/cache.go new file mode 100644 index 0000000..1fb439b --- /dev/null +++ b/internal/agent/cache.go @@ -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) +} diff --git a/internal/agent/client.go b/internal/agent/client.go new file mode 100644 index 0000000..5320e6b --- /dev/null +++ b/internal/agent/client.go @@ -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 +} diff --git a/internal/agent/rendered.go b/internal/agent/rendered.go new file mode 100644 index 0000000..124b249 --- /dev/null +++ b/internal/agent/rendered.go @@ -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 } diff --git a/internal/agent/resolver.go b/internal/agent/resolver.go new file mode 100644 index 0000000..2f2b94b --- /dev/null +++ b/internal/agent/resolver.go @@ -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 +} diff --git a/internal/agent/translate.go b/internal/agent/translate.go new file mode 100644 index 0000000..8c5adf8 --- /dev/null +++ b/internal/agent/translate.go @@ -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:" 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 +} diff --git a/internal/config/config.go b/internal/config/config.go index 40fe1ab..26f1b7b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -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"` diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 1f08bc3..4041c1b 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -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) { }) } } - diff --git a/internal/config/extras_test.go b/internal/config/extras_test.go index 9121d60..f83941d 100644 --- a/internal/config/extras_test.go +++ b/internal/config/extras_test.go @@ -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) { }) } } - diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go index c4838df..1a65e21 100644 --- a/internal/config/interfaces.go +++ b/internal/config/interfaces.go @@ -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"` diff --git a/internal/config/rules.go b/internal/config/rules.go index f3196a5..0a52040 100644 --- a/internal/config/rules.go +++ b/internal/config/rules.go @@ -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. diff --git a/internal/config/tc.go b/internal/config/tc.go index 739119e..c9066a3 100644 --- a/internal/config/tc.go +++ b/internal/config/tc.go @@ -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"` diff --git a/internal/config/tunnels.go b/internal/config/tunnels.go index 60fc2af..6f73d04 100644 --- a/internal/config/tunnels.go +++ b/internal/config/tunnels.go @@ -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 diff --git a/internal/nftables/compiler.go b/internal/nftables/compiler.go index ae6c053..45e6549 100644 --- a/internal/nftables/compiler.go +++ b/internal/nftables/compiler.go @@ -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<