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:
@@ -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 ; <core> → bond0 (fabric "core")
|
||||
fw-b bindings: zone-b → ens5 ; <core> → 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=<net-iface> 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_<isp>` (friendly) or `asn_<number>`. 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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -39,6 +39,7 @@ Use 'tomswall migrate' to convert a shorewall config to YAML.`,
|
||||
purgeCmd(),
|
||||
flushCmd(),
|
||||
migrateCmd(),
|
||||
agentCmd(),
|
||||
completionCmd(),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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"`
|
||||
|
||||
@@ -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) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user