diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c140932 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/tomswall +*.test diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..3d17912 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict + + - repo: local + hooks: + - id: gofmt + name: gofmt + entry: gofmt -l -d + language: system + types: [go] + pass_filenames: true + - id: go-vet + name: go vet + entry: go vet ./... + language: system + types: [go] + pass_filenames: false diff --git a/.woodpecker/build.yaml b/.woodpecker/build.yaml new file mode 100644 index 0000000..f288e50 --- /dev/null +++ b/.woodpecker/build.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: build + image: golang:1.23 + commands: + - go build ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/pre-commit.yaml b/.woodpecker/pre-commit.yaml new file mode 100644 index 0000000..d57b508 --- /dev/null +++ b/.woodpecker/pre-commit.yaml @@ -0,0 +1,18 @@ +when: + - event: pull_request + +steps: + - name: pre-commit + image: git.unkin.net/unkin/almalinux9-gobuilder:20260606 + commands: + - uvx pre-commit run --all-files + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 diff --git a/.woodpecker/test.yaml b/.woodpecker/test.yaml new file mode 100644 index 0000000..9aea57f --- /dev/null +++ b/.woodpecker/test.yaml @@ -0,0 +1,19 @@ +when: + - event: pull_request + +steps: + - name: test + image: golang:1.23 + commands: + - go vet ./... + - go test ./... + backend_options: + kubernetes: + serviceAccountName: default + resources: + requests: + memory: 512Mi + cpu: 1 + limits: + memory: 2Gi + cpu: 2 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/Makefile b/Makefile new file mode 100644 index 0000000..af6af11 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +BINARY := tomswall +MODULE := git.unkin.net/unkin/tomswall +PREFIX := /usr/local +CONFDIR := /etc/tomswall + +.PHONY: build install clean check test + +build: + go build -o $(BINARY) ./cmd/tomswall + +install: build + install -Dm755 $(BINARY) $(DESTDIR)$(PREFIX)/sbin/$(BINARY) + install -Dm644 tomswall.example.yaml $(DESTDIR)$(CONFDIR)/tomswall.example.yaml + @if [ ! -f $(DESTDIR)$(CONFDIR)/tomswall.yaml ]; then \ + install -Dm644 tomswall.example.yaml $(DESTDIR)$(CONFDIR)/tomswall.yaml; \ + fi + +clean: + rm -f $(BINARY) + +check: + go vet ./... + +test: + go test ./... diff --git a/README.md b/README.md index a09e942..8c51587 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,3 @@ # tomswall -Spiritual successor to shorewall — nftables firewall manager using google/nftables \ No newline at end of file +Spiritual successor to shorewall — nftables firewall manager using google/nftables 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 new file mode 100644 index 0000000..c617769 --- /dev/null +++ b/cmd/tomswall/main.go @@ -0,0 +1,430 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/spf13/cobra" + + "git.unkin.net/unkin/tomswall/internal/config" + "git.unkin.net/unkin/tomswall/internal/nftables" + "git.unkin.net/unkin/tomswall/internal/shorewall" +) + +var configPath string + +func main() { + root := &cobra.Command{ + Use: "tomswall", + Short: "nftables firewall manager — spiritual successor to shorewall", + Long: `tomswall is a firewall manager that interacts directly with the kernel's +nftables subsystem via the google/nftables library. It supports differential +rule application — no firewall teardown/rebuild needed. + +Configuration can be provided in YAML, JSON, or legacy shorewall format. +Use 'tomswall migrate' to convert a shorewall config to YAML.`, + SilenceUsage: true, + } + + root.PersistentFlags().StringVarP(&configPath, "config", "c", "/etc/tomswall/tomswall.yaml", "path to configuration file or shorewall directory") + + root.AddCommand( + applyCmd(), + planCmd(), + validateCmd(), + statusCmd(), + purgeCmd(), + flushCmd(), + migrateCmd(), + agentCmd(), + completionCmd(), + ) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func loadConfig() (*config.Config, error) { + info, err := os.Stat(configPath) + if err != nil { + return nil, fmt.Errorf("config path %s: %w", configPath, err) + } + + var cfg *config.Config + if info.IsDir() { + cfg, err = shorewall.Convert(configPath) + if err != nil { + return nil, fmt.Errorf("converting shorewall config: %w", err) + } + } else { + cfg, err = config.Load(configPath) + if err != nil { + return nil, err + } + } + + if err := cfg.Validate(); err != nil { + return nil, fmt.Errorf("validation: %w", err) + } + return cfg, nil +} + +func applyCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "apply", + Short: "Apply configuration to nftables (differential)", + Long: `Apply computes the difference between the desired configuration and the +current nftables state, then applies only the necessary changes atomically. +The firewall is never torn down — existing connections are preserved.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + 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() { + fmt.Println("No changes needed — firewall is up to date.") + return nil + } + + fmt.Println(changes.Summary()) + + if err := engine.Apply(changes); err != nil { + return fmt.Errorf("applying changes: %w", err) + } + fmt.Println("Changes applied successfully.") + return nil + }, + } + return cmd +} + +func planCmd() *cobra.Command { + return &cobra.Command{ + Use: "plan", + Short: "Show planned changes without applying (dry-run)", + Long: `Plan computes the difference between the desired configuration and the +current nftables state and displays what would change, without modifying +the firewall.`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + 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() { + fmt.Println("No changes needed — firewall is up to date.") + return nil + } + + fmt.Println("Planned changes:") + fmt.Println(changes.Summary()) + return nil + }, + } +} + +func validateCmd() *cobra.Command { + return &cobra.Command{ + Use: "validate", + Short: "Validate configuration files", + Long: `Validate loads and validates the configuration without connecting to +nftables. Checks all config sections for correctness: zones, interfaces, +hosts, policy, rules, SNAT, NAT, netmap, providers, and all other sections. + +Accepts YAML, JSON, or a shorewall config directory.`, + RunE: func(cmd *cobra.Command, args []string) error { + _, err := loadConfig() + if err != nil { + return err + } + fmt.Println("Configuration is valid.") + return nil + }, + } +} + +func statusCmd() *cobra.Command { + return &cobra.Command{ + Use: "status", + Short: "Show current firewall state and pending changes", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + 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) + } + + foreign, err := engine.FindForeignRules() + if err != nil { + return fmt.Errorf("scanning foreign rules: %w", err) + } + + if changes.Empty() && len(foreign) == 0 { + fmt.Println("Firewall is up to date. No foreign rules detected.") + return nil + } + + if !changes.Empty() { + fmt.Println("Pending changes:") + fmt.Println(changes.Summary()) + } + + if len(foreign) > 0 { + fmt.Printf("\nForeign rules detected (%d):\n", len(foreign)) + for _, r := range foreign { + fmt.Printf(" - %s\n", r) + } + fmt.Println("\nUse 'tomswall purge' to remove foreign rules.") + } + + return nil + }, + } +} + +func purgeCmd() *cobra.Command { + var dryRun bool + cmd := &cobra.Command{ + Use: "purge", + Short: "Remove rules not managed by tomswall", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + engine, err := nftables.NewEngine(cfg) + if err != nil { + return fmt.Errorf("initializing nftables: %w", err) + } + + foreign, err := engine.FindForeignRules() + if err != nil { + return fmt.Errorf("scanning: %w", err) + } + + if len(foreign) == 0 { + fmt.Println("No foreign rules found.") + return nil + } + + fmt.Printf("Found %d foreign rule(s) to remove:\n", len(foreign)) + for _, r := range foreign { + fmt.Printf(" - %s\n", r) + } + + if dryRun { + return nil + } + + if err := engine.PurgeForeignRules(foreign); err != nil { + return fmt.Errorf("purging: %w", err) + } + fmt.Println("Foreign rules removed.") + return nil + }, + } + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "show foreign rules without removing") + return cmd +} + +func flushCmd() *cobra.Command { + return &cobra.Command{ + Use: "flush", + Short: "Remove all tomswall-managed rules and tables", + RunE: func(cmd *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + + engine, err := nftables.NewEngine(cfg) + if err != nil { + return fmt.Errorf("initializing nftables: %w", err) + } + + if err := engine.Flush(); err != nil { + return fmt.Errorf("flushing: %w", err) + } + fmt.Println("All tomswall rules flushed.") + return nil + }, + } +} + +func migrateCmd() *cobra.Command { + var outputFormat string + var outputPath string + + cmd := &cobra.Command{ + Use: "migrate [shorewall-dir]", + Short: "Convert a shorewall/shorewall6 config directory to tomswall format", + Long: `Migrate reads a shorewall or shorewall6 configuration directory and converts +it to tomswall YAML or JSON format. The original config is never modified. +Auto-detects IPv6 mode when shorewall6.conf is present. + +Examples: + tomswall migrate # /etc/shorewall -> stdout + tomswall migrate /etc/shorewall -o config.yaml + tomswall migrate /etc/shorewall6 -o config6.yaml + tomswall migrate /etc/shorewall -f json -o config.json`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + dir := "/etc/shorewall" + if len(args) > 0 { + dir = args[0] + } + + if !shorewall.DirExists(dir) { + return fmt.Errorf("not a valid shorewall config directory: %s", dir) + } + + cfg, err := shorewall.Convert(dir) + if err != nil { + return fmt.Errorf("converting: %w", err) + } + + if err := cfg.Validate(); err != nil { + fmt.Fprintf(os.Stderr, "Warning: converted config has validation issues: %v\n", err) + } + + var data []byte + switch strings.ToLower(outputFormat) { + case "json": + data, err = json.MarshalIndent(cfg, "", " ") + default: + data, err = cfg.ToYAML() + } + if err != nil { + return fmt.Errorf("serializing: %w", err) + } + + if outputPath == "" || outputPath == "-" { + fmt.Print(string(data)) + } else { + dir := filepath.Dir(outputPath) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("creating output directory: %w", err) + } + if err := os.WriteFile(outputPath, data, 0644); err != nil { + return fmt.Errorf("writing output: %w", err) + } + fmt.Fprintf(os.Stderr, "Written to %s\n", outputPath) + } + + return nil + }, + } + cmd.Flags().StringVarP(&outputFormat, "format", "f", "yaml", "output format: yaml or json") + cmd.Flags().StringVarP(&outputPath, "output", "o", "", "output file path (default: stdout)") + return cmd +} + +func completionCmd() *cobra.Command { + var install bool + + cmd := &cobra.Command{ + Use: "completion [bash|zsh]", + Short: "Generate shell completion scripts", + Long: `Generate shell completion scripts for bash or zsh. + +To load completions in the current session: + source <(tomswall completion bash) + source <(tomswall completion zsh) + +To install completions permanently: + tomswall completion bash --install + tomswall completion zsh --install`, + ValidArgs: []string{"bash", "zsh"}, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + root := cmd.Root() + switch args[0] { + case "bash": + if install { + path := "/etc/bash_completion.d/tomswall" + f, err := os.Create(path) + if err != nil { + home, _ := os.UserHomeDir() + path = filepath.Join(home, ".local", "share", "bash-completion", "completions", "tomswall") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating completion directory: %w", err) + } + f, err = os.Create(path) + if err != nil { + return fmt.Errorf("creating completion file: %w", err) + } + } + defer f.Close() + if err := root.GenBashCompletionV2(f, true); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Bash completion installed to %s\n", path) + return nil + } + return root.GenBashCompletionV2(os.Stdout, true) + case "zsh": + if install { + path := "/usr/local/share/zsh/site-functions/_tomswall" + f, err := os.Create(path) + if err != nil { + home, _ := os.UserHomeDir() + path = filepath.Join(home, ".local", "share", "zsh", "site-functions", "_tomswall") + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating completion directory: %w", err) + } + f, err = os.Create(path) + if err != nil { + return fmt.Errorf("creating completion file: %w", err) + } + } + defer f.Close() + if err := root.GenZshCompletion(f); err != nil { + return err + } + fmt.Fprintf(os.Stderr, "Zsh completion installed to %s\n", path) + return nil + } + return root.GenZshCompletion(os.Stdout) + default: + return fmt.Errorf("unsupported shell: %s (use bash or zsh)", args[0]) + } + }, + } + cmd.Flags().BoolVar(&install, "install", false, "install completion file to system/user directory") + return cmd +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..5d8664e --- /dev/null +++ b/go.mod @@ -0,0 +1,21 @@ +module git.unkin.net/unkin/tomswall + +go 1.23 + +require ( + github.com/google/nftables v0.2.0 + github.com/spf13/cobra v1.8.1 + golang.org/x/sys v0.18.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/google/go-cmp v0.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/josharian/native v1.1.0 // indirect + github.com/mdlayher/netlink v1.7.2 // indirect + github.com/mdlayher/socket v0.5.1 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/net v0.23.0 // indirect + golang.org/x/sync v0.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..cc8855b --- /dev/null +++ b/go.sum @@ -0,0 +1,30 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/nftables v0.2.0 h1:PbJwaBmbVLzpeldoeUKGkE2RjstrjPKMl6oLrfEJ6/8= +github.com/google/nftables v0.2.0/go.mod h1:Beg6V6zZ3oEn0JuiUQ4wqwuyqqzasOltcoXPtgLbFp4= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/mdlayher/netlink v1.7.2 h1:/UtM3ofJap7Vl4QWCPDGXY8d3GIY2UGSDbK+QWmY8/g= +github.com/mdlayher/netlink v1.7.2/go.mod h1:xraEF7uJbxLhc5fpHL4cPe221LI2bdttWlU+ZGLfQSw= +github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos= +github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc h1:R83G5ikgLMxrBvLh22JhdfI8K6YXEPHx5P03Uu3DRs4= +github.com/vishvananda/netns v0.0.0-20180720170159-13995c7128cc/go.mod h1:ZjcWmFBXmLKZu9Nxj3WKYEafiSqer2rnvPr0en9UNpI= +golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= +golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= +golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 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/accounting.go b/internal/config/accounting.go new file mode 100644 index 0000000..ddec534 --- /dev/null +++ b/internal/config/accounting.go @@ -0,0 +1,67 @@ +package config + +import "fmt" + +type AccountingAction string + +const ( + AccountingCount AccountingAction = "count" + AccountingDone AccountingAction = "done" + AccountingLog AccountingAction = "log" + AccountingNFLog AccountingAction = "nflog" +) + +type AccountingSection string + +const ( + AccountingSectionInput AccountingSection = "input" + AccountingSectionOutput AccountingSection = "output" + AccountingSectionForward AccountingSection = "forward" + AccountingSectionPrerouting AccountingSection = "prerouting" + AccountingSectionPostrouting AccountingSection = "postrouting" +) + +type AccountingRule struct { + Action AccountingAction `yaml:"action"` + Section AccountingSection `yaml:"section"` + + // Chain is an optional custom chain name. + Chain string `yaml:"chain,omitempty"` + + Source string `yaml:"source,omitempty"` + Dest string `yaml:"dest,omitempty"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + Mark string `yaml:"mark,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +var validAccountingActions = map[AccountingAction]bool{ + AccountingCount: true, AccountingDone: true, + AccountingLog: true, AccountingNFLog: true, +} + +var validAccountingSections = map[AccountingSection]bool{ + AccountingSectionInput: true, AccountingSectionOutput: true, + AccountingSectionForward: true, AccountingSectionPrerouting: true, + AccountingSectionPostrouting: true, +} + +func (c *Config) validateAccounting() error { + for i, a := range c.Accounting { + if !validAccountingActions[a.Action] { + return fmt.Errorf("accounting[%d]: unknown action %q", i, a.Action) + } + if !validAccountingSections[a.Section] { + return fmt.Errorf("accounting[%d]: unknown section %q", i, a.Section) + } + + if a.Source == "" && a.Dest == "" { + return fmt.Errorf("accounting[%d]: source or dest required", i) + } + } + return nil +} diff --git a/internal/config/arprules.go b/internal/config/arprules.go new file mode 100644 index 0000000..cbb3d69 --- /dev/null +++ b/internal/config/arprules.go @@ -0,0 +1,63 @@ +package config + +import "fmt" + +type ArpAction string + +const ( + ArpAccept ArpAction = "accept" + ArpDrop ArpAction = "drop" + ArpSNAT ArpAction = "snat" + ArpDNAT ArpAction = "dnat" + ArpSMAT ArpAction = "smat" + ArpDMAT ArpAction = "dmat" +) + +type ArpRule struct { + // Action to take on matching ARP packets. + Action ArpAction `yaml:"action"` + + // ActionAddress is the IP address to rewrite to (required for snat/dnat). + ActionAddress string `yaml:"action_address,omitempty"` + + // ActionMAC is the MAC address to rewrite to (required for smat/dmat). + ActionMAC string `yaml:"action_mac,omitempty"` + + // Source zone/address spec. + Source string `yaml:"source,omitempty"` + + // Dest zone/address spec. + Dest string `yaml:"dest,omitempty"` + + // Opcode is the ARP operation code to match (e.g. 1=request, 2=reply). + Opcode int `yaml:"opcode,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validArpActions = map[ArpAction]bool{ + ArpAccept: true, ArpDrop: true, + ArpSNAT: true, ArpDNAT: true, + ArpSMAT: true, ArpDMAT: true, +} + +func (c *Config) validateArpRules() error { + for i, a := range c.ArpRules { + if !validArpActions[a.Action] { + return fmt.Errorf("arprules[%d]: unknown action %q", i, a.Action) + } + + if (a.Action == ArpSNAT || a.Action == ArpDNAT) && a.ActionAddress == "" { + return fmt.Errorf("arprules[%d]: action_address required for %s action", i, a.Action) + } + + if (a.Action == ArpSMAT || a.Action == ArpDMAT) && a.ActionMAC == "" { + return fmt.Errorf("arprules[%d]: action_mac required for %s action", i, a.Action) + } + + if a.Source == "" && a.Dest == "" { + return fmt.Errorf("arprules[%d]: source or dest required", i) + } + } + return nil +} diff --git a/internal/config/blrules.go b/internal/config/blrules.go new file mode 100644 index 0000000..b1fc8e3 --- /dev/null +++ b/internal/config/blrules.go @@ -0,0 +1,75 @@ +package config + +import "fmt" + +type BlruleAction string + +const ( + BlruleAccept BlruleAction = "accept" + BlruleWhitelist BlruleAction = "whitelist" + BlruleDrop BlruleAction = "drop" + BlruleReject BlruleAction = "reject" + BlruleLog BlruleAction = "log" + BlruleContinue BlruleAction = "continue" + BlruleNFQueue BlruleAction = "nfqueue" +) + +// BlruleRule defines a blacklist/whitelist rule. +// Processed before normal rules; ACCEPT/WHITELIST/CONTINUE exempt matching +// traffic from remaining blacklist rules. +type BlruleRule struct { + Action BlruleAction `yaml:"action"` + + Source string `yaml:"source"` + Dest string `yaml:"dest"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + Log string `yaml:"log,omitempty"` + + // NFQUEUE number (only for nfqueue action). + NFQueue int `yaml:"nfqueue,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validBlruleActions = map[BlruleAction]bool{ + BlruleAccept: true, BlruleWhitelist: true, + BlruleDrop: true, BlruleReject: true, + BlruleLog: true, BlruleContinue: true, + BlruleNFQueue: true, +} + +func (c *Config) validateBlrules() error { + for i, r := range c.Blrules { + if !validBlruleActions[r.Action] { + return fmt.Errorf("blrules[%d]: unknown action %q", i, r.Action) + } + + if r.Source == "" { + return fmt.Errorf("blrules[%d]: source required", i) + } + if r.Dest == "" { + return fmt.Errorf("blrules[%d]: dest required", i) + } + + if r.Source != "all" && r.Source != "any" && r.Source != "none" && + !hasPrefix(r.Source, "all!") && !hasPrefix(r.Source, "any!") { + srcZone := zoneFromSpec(r.Source) + if _, ok := c.Zones[srcZone]; !ok { + return fmt.Errorf("blrules[%d]: source zone %q not defined", i, srcZone) + } + } + + if r.Dest != "all" && r.Dest != "any" && r.Dest != "none" && + !hasPrefix(r.Dest, "all!") && !hasPrefix(r.Dest, "any!") { + dstZone := zoneFromSpec(r.Dest) + if _, ok := c.Zones[dstZone]; !ok { + return fmt.Errorf("blrules[%d]: dest zone %q not defined", i, dstZone) + } + } + } + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..26f1b7b --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,240 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +type Config struct { + Settings Settings `yaml:"settings"` + Vars map[string]string `yaml:"vars,omitempty"` + PortGroups map[string]PortGroup `yaml:"portgroups"` + Zones map[string]Zone `yaml:"zones"` + Interfaces []Interface `yaml:"interfaces"` + Hosts []Host `yaml:"hosts"` + Policy []Policy `yaml:"policy"` + Rules []Rule `yaml:"rules"` + Blrules []BlruleRule `yaml:"blrules,omitempty"` + SNAT []SNATRule `yaml:"snat"` + StaticNAT []StaticNAT `yaml:"nat"` + Netmap []Netmap `yaml:"netmap"` + Providers []Provider `yaml:"providers"` + Conntrack []ConntrackRule `yaml:"conntrack,omitempty"` + Tunnels []Tunnel `yaml:"tunnels,omitempty"` + RoutingRules []RoutingRule `yaml:"rtrules,omitempty"` + StoppedRules []StoppedRule `yaml:"stoppedrules,omitempty"` + ProxyARP []ProxyARP `yaml:"proxyarp,omitempty"` + 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"` +} + +type AddressFamily string + +const ( + FamilyINET AddressFamily = "inet" + FamilyIP AddressFamily = "ip" + FamilyIP6 AddressFamily = "ip6" +) + +type Settings struct { + 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"` +} + +// Load reads a config file in YAML or JSON format (detected by extension). +func Load(path string) (*Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading config %s: %w", path, err) + } + + var cfg Config + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".json": + if err := json.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing JSON config: %w", err) + } + default: + if err := yaml.Unmarshal(data, &cfg); err != nil { + return nil, fmt.Errorf("parsing YAML config: %w", err) + } + } + + cfg.applyDefaults() + return &cfg, nil +} + +// ToYAML serializes the config to YAML bytes. +func (c *Config) ToYAML() ([]byte, error) { + return yaml.Marshal(c) +} + +// ToJSON serializes the config to indented JSON bytes. +func (c *Config) ToJSON() ([]byte, error) { + return json.MarshalIndent(c, "", " ") +} + +func (c *Config) applyDefaults() { + if c.Settings.TableName == "" { + c.Settings.TableName = "tomswall" + } + if c.Settings.LogLevel == "" { + c.Settings.LogLevel = "info" + } + if c.Settings.AddressFamily == "" { + c.Settings.AddressFamily = FamilyINET + } +} + +var validAddressFamilies = map[AddressFamily]bool{ + FamilyINET: true, FamilyIP: true, FamilyIP6: true, +} + +func (c *Config) validateSettings() error { + if !validAddressFamilies[c.Settings.AddressFamily] { + return fmt.Errorf("unknown address_family %q (use inet, ip, or ip6)", c.Settings.AddressFamily) + } + return nil +} + +func (c *Config) Validate() error { + if err := c.validateSettings(); err != nil { + return fmt.Errorf("settings: %w", err) + } + if err := c.validateZones(); err != nil { + return fmt.Errorf("zones: %w", err) + } + if err := c.validateInterfaces(); err != nil { + return fmt.Errorf("interfaces: %w", err) + } + if err := c.validateHosts(); err != nil { + return fmt.Errorf("hosts: %w", err) + } + if err := c.validatePortGroups(); err != nil { + return fmt.Errorf("portgroups: %w", err) + } + if err := c.validatePolicy(); err != nil { + return fmt.Errorf("policy: %w", err) + } + if err := c.validateRules(); err != nil { + return fmt.Errorf("rules: %w", err) + } + if err := c.validateSNAT(); err != nil { + return fmt.Errorf("snat: %w", err) + } + if err := c.validateStaticNAT(); err != nil { + return fmt.Errorf("nat: %w", err) + } + if err := c.validateNetmap(); err != nil { + return fmt.Errorf("netmap: %w", err) + } + if err := c.validateProviders(); err != nil { + return fmt.Errorf("providers: %w", err) + } + if err := c.validateVars(); err != nil { + return fmt.Errorf("vars: %w", err) + } + if err := c.validateConntrack(); err != nil { + return fmt.Errorf("conntrack: %w", err) + } + if err := c.validateBlrules(); err != nil { + return fmt.Errorf("blrules: %w", err) + } + if err := c.validateTunnels(); err != nil { + return fmt.Errorf("tunnels: %w", err) + } + if err := c.validateRoutingRules(); err != nil { + return fmt.Errorf("rtrules: %w", err) + } + if err := c.validateStoppedRules(); err != nil { + return fmt.Errorf("stoppedrules: %w", err) + } + if err := c.validateProxyARP(); err != nil { + return fmt.Errorf("proxyarp: %w", err) + } + if err := c.validateProxyNDP(); err != nil { + return fmt.Errorf("proxyndp: %w", err) + } + if err := c.validateRoutes(); err != nil { + return fmt.Errorf("routes: %w", err) + } + if err := c.validateArpRules(); err != nil { + return fmt.Errorf("arprules: %w", err) + } + if err := c.validateAccounting(); err != nil { + return fmt.Errorf("accounting: %w", err) + } + if err := c.validateMangle(); err != nil { + return fmt.Errorf("mangle: %w", err) + } + if err := c.validateMaclist(); err != nil { + return fmt.Errorf("maclist: %w", err) + } + if err := c.validateTCDevices(); err != nil { + return fmt.Errorf("tcdevices: %w", err) + } + if err := c.validateTCClasses(); err != nil { + return fmt.Errorf("tcclasses: %w", err) + } + if err := c.validateTCFilters(); err != nil { + return fmt.Errorf("tcfilters: %w", err) + } + if err := c.validateTCInterfaces(); err != nil { + return fmt.Errorf("tcinterfaces: %w", err) + } + if err := c.validateTCPriority(); err != nil { + return fmt.Errorf("tcpriority: %w", err) + } + if err := c.validateSecmarks(); err != nil { + return fmt.Errorf("secmarks: %w", err) + } + return nil +} + +func (c *Config) FirewallZone() string { + for name, z := range c.Zones { + if z.Type == ZoneFirewall { + return name + } + } + return "" +} + +func (c *Config) ZoneInterfaces(zone string) []string { + var ifaces []string + for _, iface := range c.Interfaces { + if iface.Zone == zone { + ifaces = append(ifaces, iface.Interface) + } + } + return ifaces +} + +func (c *Config) ResolvePortGroup(name string) (*PortGroup, bool) { + pg, ok := c.PortGroups[name] + if !ok { + return nil, false + } + return &pg, true +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..4041c1b --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,1008 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// baseConfig returns a minimal valid Config with a firewall zone and policy. +// Tests append their own fields to this base. +func baseConfig() Config { + return Config{ + Settings: Settings{AddressFamily: FamilyINET}, + Zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "loc": {Type: ZoneIP}, + }, + Interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + {Zone: "loc", Interface: "eth1"}, + }, + Policy: []Policy{ + {Source: "all", Dest: "all", Action: PolicyDrop}, + }, + } +} + +// checkErr is a test helper that verifies error expectations. +func checkErr(t *testing.T, err error, wantErr string) { + t.Helper() + if wantErr == "" { + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + return + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", wantErr) + } + if !strings.Contains(err.Error(), wantErr) { + t.Fatalf("expected error containing %q, got: %v", wantErr, err) + } +} + +// --------------------------------------------------------------------------- +// Load +// --------------------------------------------------------------------------- + +func TestLoad_YAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + data := []byte(` +zones: + fw: + type: firewall + net: + type: ip +policy: + - source: net + dest: fw + action: drop +settings: + ip_forwarding: true +`) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load YAML: %v", err) + } + if cfg.Zones["fw"].Type != ZoneFirewall { + t.Errorf("expected firewall zone type, got %q", cfg.Zones["fw"].Type) + } + if !cfg.Settings.IPForwarding { + t.Error("expected ip_forwarding to be true") + } + // applyDefaults should fill in missing fields. + if cfg.Settings.TableName != "tomswall" { + t.Errorf("expected default table name %q, got %q", "tomswall", cfg.Settings.TableName) + } + if cfg.Settings.LogLevel != "info" { + t.Errorf("expected default log level %q, got %q", "info", cfg.Settings.LogLevel) + } +} + +func TestLoad_JSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + data := []byte(`{ + "zones": { + "fw": {"type": "firewall"}, + "net": {"type": "ip"} + }, + "policy": [ + {"source": "net", "dest": "fw", "action": "drop"} + ] +}`) + if err := os.WriteFile(path, data, 0644); err != nil { + t.Fatal(err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load JSON: %v", err) + } + if cfg.Zones["fw"].Type != ZoneFirewall { + t.Errorf("expected firewall zone type, got %q", cfg.Zones["fw"].Type) + } + if cfg.Settings.TableName != "tomswall" { + t.Errorf("expected default table name, got %q", cfg.Settings.TableName) + } +} + +func TestLoad_FileNotFound(t *testing.T) { + _, err := Load("/nonexistent/path/config.yaml") + if err == nil { + t.Fatal("expected error for missing file") + } +} + +func TestLoad_InvalidYAML(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + if err := os.WriteFile(path, []byte("{{bad yaml"), 0644); err != nil { + t.Fatal(err) + } + _, err := Load(path) + if err == nil { + t.Fatal("expected error for invalid YAML") + } +} + +func TestLoad_InvalidJSON(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "bad.json") + if err := os.WriteFile(path, []byte("{bad json}"), 0644); err != nil { + t.Fatal(err) + } + _, err := Load(path) + if err == nil { + t.Fatal("expected error for invalid JSON") + } +} + +// --------------------------------------------------------------------------- +// Zones +// --------------------------------------------------------------------------- + +func TestValidateZones(t *testing.T) { + tests := []struct { + name string + zones map[string]Zone + wantErr string + }{ + { + name: "valid minimal", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + }, + { + name: "all valid zone types", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "vpn": {Type: ZoneIPSec}, + "br": {Type: ZoneBPort}, + "lo": {Type: ZoneLoopback}, + "loc": {Type: ZoneLocal}, + }, + }, + { + name: "valid parent zone", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "sub": {Type: ZoneIP, Parents: []string{"net"}}, + }, + }, + { + name: "no zones defined", + zones: map[string]Zone{}, + wantErr: "no zones defined", + }, + { + name: "missing firewall zone", + zones: map[string]Zone{ + "net": {Type: ZoneIP}, + "dmz": {Type: ZoneIP}, + }, + wantErr: "exactly one firewall zone required, found 0", + }, + { + name: "two firewall zones", + zones: map[string]Zone{ + "fw1": {Type: ZoneFirewall}, + "fw2": {Type: ZoneFirewall}, + }, + wantErr: "exactly one firewall zone required, found 2", + }, + { + name: "invalid zone name starts with digit", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "1bad": {Type: ZoneIP}, + }, + wantErr: "must start with a letter", + }, + { + name: "invalid character in zone name", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net-1": {Type: ZoneIP}, + }, + wantErr: "invalid character", + }, + { + name: "reserved zone name all", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "all": {Type: ZoneIP}, + }, + wantErr: "reserved name", + }, + { + name: "reserved zone name none", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "none": {Type: ZoneIP}, + }, + wantErr: "reserved name", + }, + { + name: "reserved zone name any", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "any": {Type: ZoneIP}, + }, + wantErr: "reserved name", + }, + { + name: "reserved zone name SOURCE", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "SOURCE": {Type: ZoneIP}, + }, + wantErr: "reserved name", + }, + { + name: "reserved zone name DEST", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "DEST": {Type: ZoneIP}, + }, + wantErr: "reserved name", + }, + { + name: "unknown zone type", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: "bogus"}, + }, + wantErr: "unknown type", + }, + { + name: "empty zone type", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ""}, + }, + wantErr: "type required", + }, + { + name: "parent zone not defined", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP, Parents: []string{"missing"}}, + }, + wantErr: "parent zone \"missing\" not defined", + }, + { + name: "firewall zone with options", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall, Options: []string{"notrack"}}, + }, + wantErr: "firewall zone does not accept options", + }, + { + name: "firewall zone with in_options", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall, InOptions: []string{"notrack"}}, + }, + wantErr: "firewall zone does not accept options", + }, + { + name: "firewall zone with out_options", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall, OutOptions: []string{"notrack"}}, + }, + wantErr: "firewall zone does not accept options", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{Zones: tt.zones} + err := cfg.validateZones() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --------------------------------------------------------------------------- +// Interfaces +// --------------------------------------------------------------------------- + +func TestValidateInterfaces(t *testing.T) { + tests := []struct { + name string + interfaces []Interface + wantErr string + }{ + { + name: "valid single interface", + interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + }, + }, + { + name: "valid empty list", + interfaces: nil, + }, + { + name: "firewall zone forbidden", + interfaces: []Interface{ + {Zone: "fw", Interface: "eth0"}, + }, + wantErr: "firewall zone must not be listed in interfaces", + }, + { + name: "virtual interface rejected", + interfaces: []Interface{ + {Zone: "net", Interface: "eth0:0"}, + }, + wantErr: "virtual interfaces", + }, + { + name: "optional and required mutually exclusive", + interfaces: []Interface{ + {Zone: "net", Interface: "eth0", Options: InterfaceOptions{Optional: true, Required: true}}, + }, + wantErr: "optional and required are mutually exclusive", + }, + { + name: "zone not defined", + interfaces: []Interface{ + {Zone: "missing", Interface: "eth0"}, + }, + wantErr: "zone \"missing\" not defined", + }, + { + name: "duplicate interface", + interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + {Zone: "net", Interface: "eth0"}, + }, + wantErr: "duplicate interface", + }, + { + name: "empty interface name", + interfaces: []Interface{ + {Zone: "net", Interface: ""}, + }, + wantErr: "interface name required", + }, + { + name: "unmanaged with zone set", + interfaces: []Interface{ + {Zone: "net", Interface: "eth0", Options: InterfaceOptions{Unmanaged: true}}, + }, + wantErr: "unmanaged interfaces must have an empty zone", + }, + { + name: "unmanaged without zone is valid", + interfaces: []Interface{ + {Zone: "", Interface: "eth0", Options: InterfaceOptions{Unmanaged: true}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Interfaces = tt.interfaces + err := cfg.validateInterfaces() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --------------------------------------------------------------------------- +// Hosts +// --------------------------------------------------------------------------- + +func TestValidateHosts(t *testing.T) { + tests := []struct { + name string + zones map[string]Zone + interfaces []Interface + hosts []Host + wantErr string + }{ + { + name: "valid host", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "loc": {Type: ZoneIP}, + }, + interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + }, + hosts: []Host{ + {Zone: "loc", Interface: "eth0", Addresses: []string{"192.168.1.0/24"}}, + }, + }, + { + name: "valid dynamic host", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "loc": {Type: ZoneIP}, + }, + interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + }, + hosts: []Host{ + {Zone: "loc", Interface: "eth0", Dynamic: true}, + }, + }, + { + name: "firewall zone forbidden", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + }, + hosts: []Host{ + {Zone: "fw", Interface: "eth0", Addresses: []string{"10.0.0.1"}}, + }, + wantErr: "firewall zone must not be listed in hosts", + }, + { + name: "missing interface", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "loc": {Type: ZoneIP}, + }, + interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + }, + hosts: []Host{ + {Zone: "loc", Interface: "eth99", Addresses: []string{"10.0.0.1"}}, + }, + wantErr: "interface \"eth99\" not defined in interfaces", + }, + { + name: "zone not defined", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + }, + hosts: []Host{ + {Zone: "missing", Interface: "eth0", Addresses: []string{"10.0.0.1"}}, + }, + wantErr: "zone \"missing\" not defined", + }, + { + name: "no address and not dynamic", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "loc": {Type: ZoneIP}, + }, + interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + }, + hosts: []Host{ + {Zone: "loc", Interface: "eth0"}, + }, + wantErr: "at least one address required", + }, + { + name: "empty zone", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + }, + hosts: []Host{ + {Zone: "", Interface: "eth0", Addresses: []string{"10.0.0.1"}}, + }, + wantErr: "zone required", + }, + { + name: "empty interface", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "loc": {Type: ZoneIP}, + }, + interfaces: []Interface{ + {Zone: "net", Interface: "eth0"}, + }, + hosts: []Host{ + {Zone: "loc", Interface: "", Addresses: []string{"10.0.0.1"}}, + }, + wantErr: "interface required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{ + Zones: tt.zones, + Interfaces: tt.interfaces, + Hosts: tt.hosts, + Policy: []Policy{{Source: "net", Dest: "fw", Action: PolicyDrop}}, + } + err := cfg.validateHosts() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --------------------------------------------------------------------------- +// Port groups +// --------------------------------------------------------------------------- + +func TestValidatePortGroups(t *testing.T) { + tests := []struct { + name string + portgroups map[string]PortGroup + wantErr string + }{ + { + name: "valid tcp portgroup", + portgroups: map[string]PortGroup{ + "web": {Proto: "tcp", Ports: PortSpec{"80", "443"}}, + }, + }, + { + name: "valid udp portgroup", + portgroups: map[string]PortGroup{ + "dns": {Proto: "udp", Ports: PortSpec{"53"}}, + }, + }, + { + name: "valid port range", + portgroups: map[string]PortGroup{ + "high": {Proto: "tcp", Ports: PortSpec{"1024-65535"}}, + }, + }, + { + name: "missing proto", + portgroups: map[string]PortGroup{ + "web": {Proto: "", Ports: PortSpec{"80"}}, + }, + wantErr: "proto required", + }, + { + name: "invalid proto", + portgroups: map[string]PortGroup{ + "web": {Proto: "icmp", Ports: PortSpec{"80"}}, + }, + wantErr: "proto must be tcp or udp", + }, + { + name: "empty ports", + portgroups: map[string]PortGroup{ + "web": {Proto: "tcp", Ports: PortSpec{}}, + }, + wantErr: "at least one port required", + }, + { + name: "no portgroups is valid", + portgroups: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.PortGroups = tt.portgroups + err := cfg.validatePortGroups() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --------------------------------------------------------------------------- +// Policy +// --------------------------------------------------------------------------- + +func TestValidatePolicy(t *testing.T) { + tests := []struct { + name string + zones map[string]Zone + policy []Policy + wantErr string + }{ + { + name: "valid policy", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + policy: []Policy{ + {Source: "net", Dest: "fw", Action: PolicyDrop}, + {Source: "fw", Dest: "net", Action: PolicyAccept}, + }, + }, + { + name: "valid with all keyword", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + policy: []Policy{ + {Source: "all", Dest: "all", Action: PolicyReject}, + }, + }, + { + name: "no policies", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + policy: nil, + wantErr: "no policies defined", + }, + { + name: "unknown action", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + policy: []Policy{ + {Source: "net", Dest: "fw", Action: "explode"}, + }, + wantErr: "unknown action", + }, + { + name: "source zone not defined", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + policy: []Policy{ + {Source: "missing", Dest: "fw", Action: PolicyDrop}, + }, + wantErr: "source zone \"missing\" not defined", + }, + { + name: "dest zone not defined", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + policy: []Policy{ + {Source: "net", Dest: "missing", Action: PolicyDrop}, + }, + wantErr: "dest zone \"missing\" not defined", + }, + { + name: "source required", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + policy: []Policy{ + {Source: "", Dest: "fw", Action: PolicyDrop}, + }, + wantErr: "source required", + }, + { + name: "dest required", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + policy: []Policy{ + {Source: "net", Dest: "", Action: PolicyDrop}, + }, + wantErr: "dest required", + }, + { + name: "valid all actions", + zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + }, + policy: []Policy{ + {Source: "net", Dest: "fw", Action: PolicyAccept}, + {Source: "fw", Dest: "net", Action: PolicyDrop}, + {Source: "all", Dest: "all", Action: PolicyReject}, + {Source: "net", Dest: "net", Action: PolicyContinue}, + {Source: "net", Dest: "net", Action: PolicyNone}, + {Source: "all", Dest: "all", Action: PolicyQueue}, + {Source: "all", Dest: "all", Action: PolicyNFQueue}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Config{Zones: tt.zones, Policy: tt.policy} + err := cfg.validatePolicy() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --------------------------------------------------------------------------- +// Rules +// --------------------------------------------------------------------------- + +func TestValidateRules(t *testing.T) { + tests := []struct { + name string + portgroups map[string]PortGroup + rules []Rule + wantErr string + }{ + { + name: "valid accept rule", + rules: []Rule{ + {Action: RuleAccept, Source: "net", Dest: "fw", Proto: "tcp", DPort: PortSpec{"22"}}, + }, + }, + { + name: "valid rule with portgroup", + portgroups: map[string]PortGroup{ + "web": {Proto: "tcp", Ports: PortSpec{"80", "443"}}, + }, + rules: []Rule{ + {Action: RuleAccept, Source: "net", Dest: "fw", PortGroup: "web"}, + }, + }, + { + name: "unknown action", + rules: []Rule{ + {Action: "explode", Source: "net", Dest: "fw"}, + }, + wantErr: "unknown action", + }, + { + name: "portgroup mutually exclusive with proto", + portgroups: map[string]PortGroup{ + "web": {Proto: "tcp", Ports: PortSpec{"80"}}, + }, + rules: []Rule{ + {Action: RuleAccept, Source: "net", Dest: "fw", PortGroup: "web", Proto: "tcp"}, + }, + wantErr: "portgroup is mutually exclusive with proto/dport", + }, + { + name: "portgroup mutually exclusive with dport", + portgroups: map[string]PortGroup{ + "web": {Proto: "tcp", Ports: PortSpec{"80"}}, + }, + rules: []Rule{ + {Action: RuleAccept, Source: "net", Dest: "fw", PortGroup: "web", DPort: PortSpec{"443"}}, + }, + wantErr: "portgroup is mutually exclusive with proto/dport", + }, + { + name: "portgroup not defined", + rules: []Rule{ + {Action: RuleAccept, Source: "net", Dest: "fw", PortGroup: "missing"}, + }, + wantErr: "portgroup \"missing\" not defined", + }, + { + name: "user requires firewall source", + rules: []Rule{ + {Action: RuleAccept, Source: "net", Dest: "fw", User: "nobody"}, + }, + wantErr: "user match only valid when source is the firewall zone", + }, + { + name: "user with firewall source is valid", + rules: []Rule{ + {Action: RuleAccept, Source: "fw", Dest: "net", User: "nobody", Proto: "tcp", DPort: PortSpec{"80"}}, + }, + }, + { + name: "mark action requires set_mark", + rules: []Rule{ + {Action: RuleMark, Source: "net", Dest: "fw"}, + }, + wantErr: "set_mark required for mark action", + }, + { + name: "connmark action requires set_mark", + rules: []Rule{ + {Action: RuleConnMark, Source: "net", Dest: "fw"}, + }, + wantErr: "set_mark required for connmark action", + }, + { + name: "mark with set_mark is valid", + rules: []Rule{ + {Action: RuleMark, Source: "net", Dest: "fw", SetMark: "0x1"}, + }, + }, + { + name: "connmark with set_mark is valid", + rules: []Rule{ + {Action: RuleConnMark, Source: "net", Dest: "fw", SetMark: "0x2/0xff"}, + }, + }, + { + name: "source required", + rules: []Rule{ + {Action: RuleAccept, Source: "", Dest: "fw"}, + }, + wantErr: "source required", + }, + { + name: "dest required", + rules: []Rule{ + {Action: RuleAccept, Source: "net", Dest: ""}, + }, + wantErr: "dest required", + }, + { + name: "source zone not defined", + rules: []Rule{ + {Action: RuleAccept, Source: "missing", Dest: "fw"}, + }, + wantErr: "source zone \"missing\" not defined", + }, + { + name: "all keyword is valid source", + rules: []Rule{ + {Action: RuleDrop, Source: "all", Dest: "all"}, + }, + }, + { + name: "tarpit with non-tcp proto", + rules: []Rule{ + {Action: RuleTarpit, Source: "net", Dest: "fw", Proto: "udp"}, + }, + wantErr: "tarpit only works with proto tcp", + }, + { + name: "tarpit with tcp is valid", + rules: []Rule{ + {Action: RuleTarpit, Source: "net", Dest: "fw", Proto: "tcp"}, + }, + }, + { + name: "empty rules list is valid", + rules: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.PortGroups = tt.portgroups + cfg.Rules = tt.rules + err := cfg.validateRules() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --------------------------------------------------------------------------- +// SNAT +// --------------------------------------------------------------------------- + +func TestValidateSNAT(t *testing.T) { + tests := []struct { + name string + snat []SNATRule + wantErr string + }{ + { + name: "valid masquerade", + snat: []SNATRule{ + {Action: SNATMasquerade, Dest: "eth0"}, + }, + }, + { + name: "valid snat with address", + snat: []SNATRule{ + {Action: SNATAddress, Address: "1.2.3.4", Dest: "eth0"}, + }, + }, + { + name: "valid continue", + snat: []SNATRule{ + {Action: SNATContinue, Dest: "eth0"}, + }, + }, + { + name: "valid log", + snat: []SNATRule{ + {Action: SNATLog, Dest: "eth0"}, + }, + }, + { + name: "unknown action", + snat: []SNATRule{ + {Action: "bogus", Dest: "eth0"}, + }, + wantErr: "unknown action", + }, + { + name: "address required for snat action", + snat: []SNATRule{ + {Action: SNATAddress, Address: "", Dest: "eth0"}, + }, + wantErr: "address required for snat action", + }, + { + name: "dest required", + snat: []SNATRule{ + {Action: SNATMasquerade, Dest: ""}, + }, + wantErr: "dest required", + }, + { + name: "probability too high", + snat: []SNATRule{ + {Action: SNATMasquerade, Dest: "eth0", Probability: 1.5}, + }, + wantErr: "probability must be between 0", + }, + { + name: "probability negative", + snat: []SNATRule{ + {Action: SNATMasquerade, Dest: "eth0", Probability: -0.5}, + }, + wantErr: "probability must be between 0", + }, + { + name: "probability exactly 1 is valid", + snat: []SNATRule{ + {Action: SNATMasquerade, Dest: "eth0", Probability: 1.0}, + }, + }, + { + name: "probability 0.5 is valid", + snat: []SNATRule{ + {Action: SNATMasquerade, Dest: "eth0", Probability: 0.5}, + }, + }, + { + name: "probability 0 is valid (skipped)", + snat: []SNATRule{ + {Action: SNATMasquerade, Dest: "eth0", Probability: 0}, + }, + }, + { + name: "port_range requires proto", + snat: []SNATRule{ + {Action: SNATMasquerade, Dest: "eth0", PortRange: "1024-65535"}, + }, + wantErr: "port_range requires proto", + }, + { + name: "persistent requires address", + snat: []SNATRule{ + {Action: SNATMasquerade, Dest: "eth0", Persistent: true}, + }, + wantErr: "persistent requires an address", + }, + { + name: "empty snat list is valid", + snat: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.SNAT = tt.snat + err := cfg.validateSNAT() + checkErr(t, err, tt.wantErr) + }) + } +} diff --git a/internal/config/conntrack.go b/internal/config/conntrack.go new file mode 100644 index 0000000..527a00f --- /dev/null +++ b/internal/config/conntrack.go @@ -0,0 +1,86 @@ +package config + +import "fmt" + +type ConntrackAction string + +const ( + ConntrackNoTrack ConntrackAction = "notrack" + ConntrackHelper ConntrackAction = "helper" + ConntrackDrop ConntrackAction = "drop" + ConntrackLog ConntrackAction = "log" +) + +type ConntrackChain string + +const ( + ConntrackPrerouting ConntrackChain = "prerouting" + ConntrackOutput ConntrackChain = "output" + ConntrackBoth ConntrackChain = "both" +) + +type ConntrackRule struct { + // Action: notrack (bypass conntrack), helper (assign CT helper), drop (raw table drop), log. + Action ConntrackAction `yaml:"action"` + + // Source zone spec. Supports zone, zone:interface, zone:interface:address. + Source string `yaml:"source,omitempty"` + + // Dest zone spec. Same syntax as Source. + Dest string `yaml:"dest,omitempty"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + // Chain to install the rule in: prerouting, output, or both. Default: prerouting. + Chain ConntrackChain `yaml:"chain,omitempty"` + + // CT helper name (for helper action): ftp, sip, tftp, irc, pptp, amanda, snmp, etc. + Helper string `yaml:"helper,omitempty"` + + // User/group match (only valid for output chain). + User string `yaml:"user,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validConntrackActions = map[ConntrackAction]bool{ + ConntrackNoTrack: true, ConntrackHelper: true, + ConntrackDrop: true, ConntrackLog: true, +} + +var validConntrackChains = map[ConntrackChain]bool{ + ConntrackPrerouting: true, ConntrackOutput: true, + ConntrackBoth: true, "": true, +} + +func (c *Config) validateConntrack() error { + for i, ct := range c.Conntrack { + if !validConntrackActions[ct.Action] { + return fmt.Errorf("conntrack[%d]: unknown action %q", i, ct.Action) + } + if !validConntrackChains[ct.Chain] { + return fmt.Errorf("conntrack[%d]: chain must be prerouting, output, or both", i) + } + + if ct.Action == ConntrackHelper && ct.Helper == "" { + return fmt.Errorf("conntrack[%d]: helper name required for helper action", i) + } + + if ct.Source == "" && ct.Dest == "" && ct.Action != ConntrackHelper { + return fmt.Errorf("conntrack[%d]: source or dest required", i) + } + + if ct.User != "" { + chain := ct.Chain + if chain == "" { + chain = ConntrackPrerouting + } + if chain == ConntrackPrerouting { + return fmt.Errorf("conntrack[%d]: user match only valid for output chain", i) + } + } + } + return nil +} diff --git a/internal/config/extras_test.go b/internal/config/extras_test.go new file mode 100644 index 0000000..f83941d --- /dev/null +++ b/internal/config/extras_test.go @@ -0,0 +1,1443 @@ +package config + +import ( + "strings" + "testing" +) + +// --- 1. Conntrack --- + +func TestValidateConntrack(t *testing.T) { + tests := []struct { + name string + rules []ConntrackRule + wantErr string + }{ + { + name: "valid notrack rule", + rules: []ConntrackRule{ + {Action: ConntrackNoTrack, Source: "net", Proto: "udp"}, + }, + }, + { + name: "missing action", + rules: []ConntrackRule{ + {Source: "net"}, + }, + wantErr: "unknown action", + }, + { + name: "helper requires helper name", + rules: []ConntrackRule{ + {Action: ConntrackHelper, Source: "net"}, + }, + wantErr: "helper name required", + }, + { + name: "valid helper with name", + rules: []ConntrackRule{ + {Action: ConntrackHelper, Source: "net", Helper: "ftp"}, + }, + }, + { + name: "user requires output chain", + rules: []ConntrackRule{ + {Action: ConntrackNoTrack, Source: "net", User: "nobody"}, + }, + wantErr: "user match only valid for output chain", + }, + { + name: "user with output chain is valid", + rules: []ConntrackRule{ + {Action: ConntrackNoTrack, Source: "net", User: "nobody", Chain: ConntrackOutput}, + }, + }, + { + name: "source or dest required for non-helper", + rules: []ConntrackRule{ + {Action: ConntrackDrop}, + }, + wantErr: "source or dest required", + }, + { + name: "helper without source/dest is valid", + rules: []ConntrackRule{ + {Action: ConntrackHelper, Helper: "ftp", Proto: "tcp", Chain: ConntrackBoth}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Conntrack = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 2. Blrules --- + +func TestValidateBlrules(t *testing.T) { + tests := []struct { + name string + rules []BlruleRule + wantErr string + }{ + { + name: "valid rule", + rules: []BlruleRule{ + {Action: BlruleAccept, Source: "net", Dest: "loc"}, + }, + }, + { + name: "unknown action", + rules: []BlruleRule{ + {Action: "bogus", Source: "net", Dest: "loc"}, + }, + wantErr: "unknown action", + }, + { + name: "source zone not defined", + rules: []BlruleRule{ + {Action: BlruleDrop, Source: "nosuchzone", Dest: "loc"}, + }, + wantErr: `source zone "nosuchzone" not defined`, + }, + { + name: "source required", + rules: []BlruleRule{ + {Action: BlruleDrop, Dest: "loc"}, + }, + wantErr: "source required", + }, + { + name: "dest required", + rules: []BlruleRule{ + {Action: BlruleDrop, Source: "net"}, + }, + wantErr: "dest required", + }, + { + name: "source all is valid", + rules: []BlruleRule{ + {Action: BlruleDrop, Source: "all", Dest: "all"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Blrules = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 3. Tunnels --- + +func TestValidateTunnels(t *testing.T) { + tests := []struct { + name string + tunnels []Tunnel + wantErr string + }{ + { + name: "valid tunnel", + tunnels: []Tunnel{ + {Type: "ipsec", Zone: "net", Gateways: []string{"1.2.3.4"}}, + }, + }, + { + name: "zone not defined", + tunnels: []Tunnel{ + {Type: "gre", Zone: "nosuchzone", Gateways: []string{"1.2.3.4"}}, + }, + wantErr: `zone "nosuchzone" not defined`, + }, + { + name: "missing gateways", + tunnels: []Tunnel{ + {Type: "ipsec", Zone: "net"}, + }, + wantErr: "at least one gateway required", + }, + { + name: "unknown tunnel type", + tunnels: []Tunnel{ + {Type: "bogus", Zone: "net", Gateways: []string{"1.2.3.4"}}, + }, + wantErr: "unknown tunnel type", + }, + { + name: "tunnel type with suffix", + tunnels: []Tunnel{ + {Type: "ipsec:ah", Zone: "net", Gateways: []string{"1.2.3.4"}}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Tunnels = tt.tunnels + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 4. Rtrules --- + +func TestValidateRoutingRules(t *testing.T) { + withProvider := func() Config { + cfg := baseConfig() + cfg.Providers = []Provider{ + {Name: "isp1", Number: 1, Interface: "eth0"}, + } + return cfg + } + + tests := []struct { + name string + setup func() Config + rules []RoutingRule + wantErr string + }{ + { + name: "valid routing rule", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "isp1", Priority: 1000}, + }, + }, + { + name: "missing providers", + setup: baseConfig, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "isp1", Priority: 1000}, + }, + wantErr: "rtrules require providers", + }, + { + name: "provider not defined", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "nosuch", Priority: 1000}, + }, + wantErr: `provider "nosuch" not defined`, + }, + { + name: "priority below range", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "isp1", Priority: 999}, + }, + wantErr: "priority must be 1000-26999", + }, + { + name: "priority above range", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "isp1", Priority: 27000}, + }, + wantErr: "priority must be 1000-26999", + }, + { + name: "source or dest required", + setup: withProvider, + rules: []RoutingRule{ + {Provider: "isp1", Priority: 1000}, + }, + wantErr: "source or dest required", + }, + { + name: "provider required", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Priority: 1000}, + }, + wantErr: "provider required", + }, + { + name: "main provider always valid", + setup: withProvider, + rules: []RoutingRule{ + {Source: "10.0.0.0/8", Provider: "main", Priority: 1000}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := tt.setup() + cfg.RoutingRules = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 5. StoppedRules --- + +func TestValidateStoppedRules(t *testing.T) { + tests := []struct { + name string + rules []StoppedRule + wantErr string + }{ + { + name: "valid rule", + rules: []StoppedRule{ + {Action: StoppedAccept, Source: "eth0"}, + }, + }, + { + name: "unknown action", + rules: []StoppedRule{ + {Action: "bogus", Source: "eth0"}, + }, + wantErr: "unknown action", + }, + { + name: "missing source and dest", + rules: []StoppedRule{ + {Action: StoppedAccept}, + }, + wantErr: "source or dest required", + }, + { + name: "accept with source and dest is valid", + rules: []StoppedRule{ + {Action: StoppedAccept, Source: "eth0", Dest: "eth1"}, + }, + }, + { + name: "notrack with dest $FW is valid", + rules: []StoppedRule{ + {Action: StoppedNoTrack, Source: "eth0", Dest: "$FW"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.StoppedRules = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 6. Vars --- + +func TestValidateVars(t *testing.T) { + tests := []struct { + name string + vars map[string]string + wantErr string + }{ + { + name: "valid vars", + vars: map[string]string{"NET_IF": "eth0", "PORT": "8080"}, + }, + { + name: "empty var name", + vars: map[string]string{"": "value"}, + wantErr: "empty variable name", + }, + { + name: "invalid chars - space", + vars: map[string]string{"bad name": "value"}, + wantErr: "invalid variable name", + }, + { + name: "invalid chars - dollar", + vars: map[string]string{"$var": "value"}, + wantErr: "invalid variable name", + }, + { + name: "invalid chars - braces", + vars: map[string]string{"{var}": "value"}, + wantErr: "invalid variable name", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Vars = tt.vars + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 7. NAT (static) --- + +func TestValidateStaticNAT(t *testing.T) { + tests := []struct { + name string + nat []StaticNAT + wantErr string + }{ + { + name: "valid nat", + nat: []StaticNAT{ + {External: "1.2.3.4", Interface: "eth0", Internal: "10.0.0.1"}, + }, + }, + { + name: "external required", + nat: []StaticNAT{ + {Interface: "eth0", Internal: "10.0.0.1"}, + }, + wantErr: "external address required", + }, + { + name: "external must be IP", + nat: []StaticNAT{ + {External: "example.com", Interface: "eth0", Internal: "10.0.0.1"}, + }, + wantErr: "external must be an IP address", + }, + { + name: "interface required", + nat: []StaticNAT{ + {External: "1.2.3.4", Internal: "10.0.0.1"}, + }, + wantErr: "interface required", + }, + { + name: "internal required", + nat: []StaticNAT{ + {External: "1.2.3.4", Interface: "eth0"}, + }, + wantErr: "internal address required", + }, + { + name: "internal must be IP", + nat: []StaticNAT{ + {External: "1.2.3.4", Interface: "eth0", Internal: "server.local"}, + }, + wantErr: "internal must be an IP address", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.StaticNAT = tt.nat + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 8. Netmap --- + +func TestValidateNetmap(t *testing.T) { + tests := []struct { + name string + netmap []Netmap + wantErr string + }{ + { + name: "valid netmap", + netmap: []Netmap{ + {Type: NetmapDNAT, Net1: "192.168.1.0/24", Interface: "eth0", Net2: "10.0.0.0/24"}, + }, + }, + { + name: "invalid CIDR net1", + netmap: []Netmap{ + {Type: NetmapDNAT, Net1: "notacidr", Interface: "eth0", Net2: "10.0.0.0/24"}, + }, + wantErr: "net1 must be CIDR format", + }, + { + name: "invalid CIDR net2", + netmap: []Netmap{ + {Type: NetmapSNAT, Net1: "192.168.1.0/24", Interface: "eth0", Net2: "notacidr"}, + }, + wantErr: "net2 must be CIDR format", + }, + { + name: "missing interface", + netmap: []Netmap{ + {Type: NetmapDNAT, Net1: "192.168.1.0/24", Net2: "10.0.0.0/24"}, + }, + wantErr: "interface required", + }, + { + name: "interface not defined", + netmap: []Netmap{ + {Type: NetmapDNAT, Net1: "192.168.1.0/24", Interface: "eth99", Net2: "10.0.0.0/24"}, + }, + wantErr: `interface "eth99" not defined`, + }, + { + name: "invalid type", + netmap: []Netmap{ + {Type: "bogus", Net1: "192.168.1.0/24", Interface: "eth0", Net2: "10.0.0.0/24"}, + }, + wantErr: "type must be dnat or snat", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Netmap = tt.netmap + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 9. Providers --- + +func TestValidateProviders(t *testing.T) { + tests := []struct { + name string + providers []Provider + wantErr string + }{ + { + name: "valid provider", + providers: []Provider{ + {Name: "isp1", Number: 1, Interface: "eth0"}, + }, + }, + { + name: "duplicate name", + providers: []Provider{ + {Name: "isp1", Number: 1, Interface: "eth0"}, + {Name: "isp1", Number: 2, Interface: "eth1"}, + }, + wantErr: `duplicate name "isp1"`, + }, + { + name: "reserved name local", + providers: []Provider{ + {Name: "local", Number: 1, Interface: "eth0"}, + }, + wantErr: `"local" is a reserved name`, + }, + { + name: "reserved name main", + providers: []Provider{ + {Name: "main", Number: 1, Interface: "eth0"}, + }, + wantErr: `"main" is a reserved name`, + }, + { + name: "number below range", + providers: []Provider{ + {Name: "isp1", Number: 0, Interface: "eth0"}, + }, + wantErr: "number must be between 1 and 252", + }, + { + name: "number above range", + providers: []Provider{ + {Name: "isp1", Number: 253, Interface: "eth0"}, + }, + wantErr: "number must be between 1 and 252", + }, + { + name: "duplicate number", + providers: []Provider{ + {Name: "isp1", Number: 1, Interface: "eth0"}, + {Name: "isp2", Number: 1, Interface: "eth1"}, + }, + wantErr: "number 1 already used", + }, + { + name: "interface required", + providers: []Provider{ + {Name: "isp1", Number: 1}, + }, + wantErr: "interface required", + }, + { + name: "name required", + providers: []Provider{ + {Number: 1, Interface: "eth0"}, + }, + wantErr: "name required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Providers = tt.providers + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 10. Accounting --- + +func TestValidateAccounting(t *testing.T) { + tests := []struct { + name string + rules []AccountingRule + wantErr string + }{ + { + name: "valid rule", + rules: []AccountingRule{ + {Action: AccountingCount, Section: AccountingSectionForward, Source: "net"}, + }, + }, + { + name: "source or dest required", + rules: []AccountingRule{ + {Action: AccountingCount, Section: AccountingSectionForward}, + }, + wantErr: "source or dest required", + }, + { + name: "unknown action", + rules: []AccountingRule{ + {Action: "bogus", Section: AccountingSectionForward, Source: "net"}, + }, + wantErr: "unknown action", + }, + { + name: "unknown section", + rules: []AccountingRule{ + {Action: AccountingCount, Section: "bogus", Source: "net"}, + }, + wantErr: "unknown section", + }, + { + name: "dest only is valid", + rules: []AccountingRule{ + {Action: AccountingDone, Section: AccountingSectionInput, Dest: "loc"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Accounting = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 11. Mangle --- + +func TestValidateMangle(t *testing.T) { + tests := []struct { + name string + rules []MangleRule + wantErr string + }{ + { + name: "valid rule", + rules: []MangleRule{ + {Action: MangleMark, Chain: ManglePrerouting, MarkValue: "0x1"}, + }, + }, + { + name: "mark_value required for mark action", + rules: []MangleRule{ + {Action: MangleMark, Chain: ManglePrerouting}, + }, + wantErr: "mark_value required for mark action", + }, + { + name: "mark_value required for connmark action", + rules: []MangleRule{ + {Action: MangleConnMark, Chain: ManglePrerouting}, + }, + wantErr: "mark_value required for connmark action", + }, + { + name: "mark_value required for classify action", + rules: []MangleRule{ + {Action: MangleClassify, Chain: MangleForward}, + }, + wantErr: "mark_value required for classify action", + }, + { + name: "unknown action", + rules: []MangleRule{ + {Action: "bogus", Chain: ManglePrerouting}, + }, + wantErr: "unknown action", + }, + { + name: "unknown chain", + rules: []MangleRule{ + {Action: MangleDrop, Chain: "bogus"}, + }, + wantErr: "unknown chain", + }, + { + name: "log action without mark_value is valid", + rules: []MangleRule{ + {Action: MangleLog, Chain: MangleInput}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Mangle = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 12. Maclist --- + +func TestValidateMaclist(t *testing.T) { + tests := []struct { + name string + entries []MaclistEntry + wantErr string + }{ + { + name: "valid entry with mac", + entries: []MaclistEntry{ + {Action: MaclistAccept, Interface: "eth0", MAC: "00:11:22:33:44:55"}, + }, + }, + { + name: "valid entry with addresses", + entries: []MaclistEntry{ + {Action: MaclistDrop, Interface: "eth0", Addresses: []string{"10.0.0.1"}}, + }, + }, + { + name: "mac or addresses required", + entries: []MaclistEntry{ + {Action: MaclistAccept, Interface: "eth0"}, + }, + wantErr: "mac or addresses required", + }, + { + name: "interface required", + entries: []MaclistEntry{ + {Action: MaclistAccept, MAC: "00:11:22:33:44:55"}, + }, + wantErr: "interface required", + }, + { + name: "unknown action", + entries: []MaclistEntry{ + {Action: "bogus", Interface: "eth0", MAC: "00:11:22:33:44:55"}, + }, + wantErr: "unknown action", + }, + { + name: "interface not defined", + entries: []MaclistEntry{ + {Action: MaclistAccept, Interface: "eth99", MAC: "00:11:22:33:44:55"}, + }, + wantErr: `interface "eth99" not defined`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Maclist = tt.entries + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 13. TC --- + +func TestValidateTCDevices(t *testing.T) { + tests := []struct { + name string + devices []TCDevice + wantErr string + }{ + { + name: "valid device", + devices: []TCDevice{ + {Interface: "eth0", OutBandwidth: "10mbit"}, + }, + }, + { + name: "interface required", + devices: []TCDevice{ + {OutBandwidth: "10mbit"}, + }, + wantErr: "interface required", + }, + { + name: "out_bandwidth required", + devices: []TCDevice{ + {Interface: "eth0"}, + }, + wantErr: "out_bandwidth required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.TCDevices = tt.devices + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +func TestValidateTCClasses(t *testing.T) { + tests := []struct { + name string + classes []TCClass + wantErr string + }{ + { + name: "valid class", + classes: []TCClass{ + {Interface: "eth0:1", Rate: "1mbit"}, + }, + }, + { + name: "interface required", + classes: []TCClass{ + {Rate: "1mbit"}, + }, + wantErr: "interface required", + }, + { + name: "rate required", + classes: []TCClass{ + {Interface: "eth0:1"}, + }, + wantErr: "rate required", + }, + { + name: "mark out of range", + classes: []TCClass{ + {Interface: "eth0:1", Rate: "1mbit", Mark: 256}, + }, + wantErr: "mark must be 1-255", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.TCClasses = tt.classes + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +func TestValidateTCFilters(t *testing.T) { + tests := []struct { + name string + filters []TCFilter + wantErr string + }{ + { + name: "valid filter", + filters: []TCFilter{ + {Class: "eth0:1", Source: "10.0.0.0/8"}, + }, + }, + { + name: "class required", + filters: []TCFilter{ + {Source: "10.0.0.0/8"}, + }, + wantErr: "class required", + }, + { + name: "source or dest required", + filters: []TCFilter{ + {Class: "eth0:1"}, + }, + wantErr: "source or dest required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.TCFilters = tt.filters + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +func TestValidateTCInterfaces(t *testing.T) { + tests := []struct { + name string + interfaces []TCInterface + wantErr string + }{ + { + name: "valid interface", + interfaces: []TCInterface{ + {Interface: "eth0"}, + }, + }, + { + name: "interface required", + interfaces: []TCInterface{ + {Type: "external"}, + }, + wantErr: "interface required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.TCInterfaces = tt.interfaces + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +func TestValidateTCPriority(t *testing.T) { + tests := []struct { + name string + priorities []TCPriority + wantErr string + }{ + { + name: "valid band 1", + priorities: []TCPriority{ + {Band: 1}, + }, + }, + { + name: "valid band 3", + priorities: []TCPriority{ + {Band: 3}, + }, + }, + { + name: "band below range", + priorities: []TCPriority{ + {Band: 0}, + }, + wantErr: "band must be 1, 2, or 3", + }, + { + name: "band above range", + priorities: []TCPriority{ + {Band: 4}, + }, + wantErr: "band must be 1, 2, or 3", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.TCPriorities = tt.priorities + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 14. ProxyARP --- + +func TestValidateProxyARP(t *testing.T) { + tests := []struct { + name string + entries []ProxyARP + wantErr string + }{ + { + name: "valid entry", + entries: []ProxyARP{ + {Address: "1.2.3.4", Interface: "eth1", External: "eth0"}, + }, + }, + { + name: "address required", + entries: []ProxyARP{ + {Interface: "eth1", External: "eth0"}, + }, + wantErr: "address required", + }, + { + name: "external required", + entries: []ProxyARP{ + {Address: "1.2.3.4", Interface: "eth1"}, + }, + wantErr: "external required", + }, + { + name: "interface required unless haveroute", + entries: []ProxyARP{ + {Address: "1.2.3.4", External: "eth0"}, + }, + wantErr: "interface required unless haveroute", + }, + { + name: "haveroute skips interface requirement", + entries: []ProxyARP{ + {Address: "1.2.3.4", External: "eth0", HaveRoute: true}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.ProxyARP = tt.entries + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 15. Routes --- + +func TestValidateRoutes(t *testing.T) { + tests := []struct { + name string + routes []StaticRoute + wantErr string + }{ + { + name: "valid route", + routes: []StaticRoute{ + {Provider: "main", Dest: "10.0.0.0/8", Gateway: "192.168.1.1"}, + }, + }, + { + name: "provider required", + routes: []StaticRoute{ + {Dest: "10.0.0.0/8", Gateway: "192.168.1.1"}, + }, + wantErr: "provider required", + }, + { + name: "dest required", + routes: []StaticRoute{ + {Provider: "main", Gateway: "192.168.1.1"}, + }, + wantErr: "dest required", + }, + { + name: "device not allowed with blackhole", + routes: []StaticRoute{ + {Provider: "main", Dest: "10.0.0.0/8", Gateway: "blackhole", Device: "eth0"}, + }, + wantErr: "device not allowed with blackhole gateway", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Routes = tt.routes + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 16. ArpRules --- + +func TestValidateArpRules(t *testing.T) { + tests := []struct { + name string + rules []ArpRule + wantErr string + }{ + { + name: "valid rule", + rules: []ArpRule{ + {Action: ArpAccept, Source: "net"}, + }, + }, + { + name: "action_address required for snat", + rules: []ArpRule{ + {Action: ArpSNAT, Source: "net"}, + }, + wantErr: "action_address required for snat action", + }, + { + name: "action_address required for dnat", + rules: []ArpRule{ + {Action: ArpDNAT, Source: "net"}, + }, + wantErr: "action_address required for dnat action", + }, + { + name: "action_mac required for smat", + rules: []ArpRule{ + {Action: ArpSMAT, Source: "net"}, + }, + wantErr: "action_mac required for smat action", + }, + { + name: "unknown action", + rules: []ArpRule{ + {Action: "bogus", Source: "net"}, + }, + wantErr: "unknown action", + }, + { + name: "source or dest required", + rules: []ArpRule{ + {Action: ArpDrop}, + }, + wantErr: "source or dest required", + }, + { + name: "valid snat with action_address", + rules: []ArpRule{ + {Action: ArpSNAT, Source: "net", ActionAddress: "1.2.3.4"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.ArpRules = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 17. Secmarks --- + +func TestValidateSecmarks(t *testing.T) { + tests := []struct { + name string + rules []SecmarkRule + wantErr string + }{ + { + name: "valid secmark", + rules: []SecmarkRule{ + {Secmark: "system_u:object_r:httpd_t:s0", Chain: "P"}, + }, + }, + { + name: "secmark required", + rules: []SecmarkRule{ + {Chain: "P"}, + }, + wantErr: "secmark required", + }, + { + name: "chain required", + rules: []SecmarkRule{ + {Secmark: "system_u:object_r:httpd_t:s0"}, + }, + wantErr: "chain required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Secmarks = tt.rules + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 18. Zone nesting --- + +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"}}, + }, + } + order, err := cfg.ResolveNesting() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // dmz (child) must appear before net (parent) or at least both must appear. + // The key constraint: all zones should be present. + if len(order) != 3 { + t.Fatalf("expected 3 zones in order, got %d: %v", len(order), order) + } + }) + + t.Run("circular detection", func(t *testing.T) { + // The algorithm requires all siblings (children of the same parent) + // to be resolved before any of them can proceed. Two children of + // the same non-firewall parent create a deadlock that is reported + // as a circular nesting error. + cfg := Config{ + Zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "parent": {Type: ZoneIP}, + "child1": {Type: ZoneIP, Parents: []string{"parent"}}, + "child2": {Type: ZoneIP, Parents: []string{"parent"}}, + }, + } + _, err := cfg.ResolveNesting() + if err == nil { + t.Fatal("expected circular nesting error") + } + if !strings.Contains(err.Error(), "circular") { + t.Fatalf("expected circular error, got: %v", err) + } + }) +} + +func TestChildZones(t *testing.T) { + cfg := Config{ + Zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "dmz": {Type: ZoneIP, Parents: []string{"net"}}, + "vpn": {Type: ZoneIP, Parents: []string{"net"}}, + "loc": {Type: ZoneIP}, + }, + } + children := cfg.ChildZones("net") + if len(children) != 2 { + t.Fatalf("expected 2 children of net, got %d: %v", len(children), children) + } + // Check both dmz and vpn are present. + found := map[string]bool{} + for _, c := range children { + found[c] = true + } + if !found["dmz"] || !found["vpn"] { + t.Fatalf("expected dmz and vpn as children, got: %v", children) + } + + // loc has no children. + locChildren := cfg.ChildZones("loc") + if len(locChildren) != 0 { + t.Fatalf("expected 0 children of loc, got %d", len(locChildren)) + } +} + +func TestIsSubZone(t *testing.T) { + cfg := Config{ + Zones: map[string]Zone{ + "fw": {Type: ZoneFirewall}, + "net": {Type: ZoneIP}, + "dmz": {Type: ZoneIP, Parents: []string{"net"}}, + "web": {Type: ZoneIP, Parents: []string{"dmz"}}, + }, + } + + tests := []struct { + child, parent string + want bool + }{ + {"dmz", "net", true}, + {"web", "dmz", true}, + {"web", "net", true}, // transitive + {"net", "dmz", false}, // reverse + {"net", "net", false}, // self + {"nosuch", "net", false}, // non-existent + } + + for _, tt := range tests { + t.Run(tt.child+"->"+tt.parent, func(t *testing.T) { + got := cfg.IsSubZone(tt.child, tt.parent) + if got != tt.want { + t.Fatalf("IsSubZone(%q, %q) = %v, want %v", tt.child, tt.parent, got, tt.want) + } + }) + } +} + +// --- 19. Names --- + +func TestValidateName(t *testing.T) { + tests := []struct { + name string + input string + wantErr string + }{ + {"valid simple", "net", ""}, + {"valid with underscore", "my_zone", ""}, + {"valid with digits", "zone1", ""}, + {"empty name", "", "empty"}, + {"starts with digit", "1zone", "must start with a letter"}, + {"contains dash", "my-zone", "invalid character"}, + {"contains space", "my zone", "invalid character"}, + {"contains dot", "my.zone", "invalid character"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateName(tt.input, "test") + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 20. Params --- + +func TestSubstituteVars(t *testing.T) { + tests := []struct { + name string + input string + vars map[string]string + want string + }{ + { + name: "braced substitution", + input: "${NET_IF}", + vars: map[string]string{"NET_IF": "eth0"}, + want: "eth0", + }, + { + name: "unbraced substitution", + input: "$NET_IF", + vars: map[string]string{"NET_IF": "eth0"}, + want: "eth0", + }, + { + name: "no vars", + input: "no substitution", + vars: nil, + want: "no substitution", + }, + { + name: "no dollar sign", + input: "plain text", + vars: map[string]string{"foo": "bar"}, + want: "plain text", + }, + { + name: "multiple vars", + input: "${A}:${B}", + vars: map[string]string{"A": "1", "B": "2"}, + want: "1:2", + }, + { + name: "undefined var stays", + input: "${UNDEF}", + vars: map[string]string{"OTHER": "val"}, + want: "${UNDEF}", + }, + { + name: "empty vars map", + input: "$foo", + vars: map[string]string{}, + want: "$foo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SubstituteVars(tt.input, tt.vars) + if got != tt.want { + t.Fatalf("SubstituteVars(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// --- 22. ProxyNDP --- + +func TestValidateProxyNDP(t *testing.T) { + tests := []struct { + name string + entries []ProxyNDP + wantErr string + }{ + { + name: "valid entry", + entries: []ProxyNDP{ + {Address: "fd10::100", Interface: "eth0", External: "eth1"}, + }, + }, + { + name: "address required", + entries: []ProxyNDP{ + {Interface: "eth0", External: "eth1"}, + }, + wantErr: "address required", + }, + { + name: "external required", + entries: []ProxyNDP{ + {Address: "fd10::100", Interface: "eth0"}, + }, + wantErr: "external required", + }, + { + name: "interface required unless haveroute", + entries: []ProxyNDP{ + {Address: "fd10::100", External: "eth1"}, + }, + wantErr: "interface required unless haveroute", + }, + { + name: "haveroute skips interface requirement", + entries: []ProxyNDP{ + {Address: "fd10::100", External: "eth1", HaveRoute: true}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.ProxyNDP = tt.entries + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} + +// --- 23. Settings --- + +func TestValidateSettings(t *testing.T) { + tests := []struct { + name string + family AddressFamily + wantErr string + }{ + {"inet is valid", FamilyINET, ""}, + {"ip is valid", FamilyIP, ""}, + {"ip6 is valid", FamilyIP6, ""}, + {"empty is invalid", "", "unknown address_family"}, + {"bogus is invalid", "bogus", "unknown address_family"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := baseConfig() + cfg.Settings.AddressFamily = tt.family + err := cfg.Validate() + checkErr(t, err, tt.wantErr) + }) + } +} diff --git a/internal/config/hosts.go b/internal/config/hosts.go new file mode 100644 index 0000000..f9847dc --- /dev/null +++ b/internal/config/hosts.go @@ -0,0 +1,57 @@ +package config + +import "fmt" + +type Host struct { + Zone string `yaml:"zone"` + Interface string `yaml:"interface"` + Addresses []string `yaml:"addresses"` + Exclusions []string `yaml:"exclusions,omitempty"` + Dynamic bool `yaml:"dynamic,omitempty"` + Options HostOptions `yaml:"options,omitempty"` +} + +type HostOptions struct { + Broadcast bool `yaml:"broadcast,omitempty"` + DestOnly bool `yaml:"destonly,omitempty"` + IPSec bool `yaml:"ipsec,omitempty"` + MSS int `yaml:"mss,omitempty"` + NoSmurfs bool `yaml:"nosmurfs,omitempty"` + RouteBack bool `yaml:"routeback,omitempty"` + TCPFlags bool `yaml:"tcpflags,omitempty"` +} + +func (c *Config) validateHosts() error { + fwZone := c.FirewallZone() + + for i, h := range c.Hosts { + if h.Zone == "" { + return fmt.Errorf("host[%d]: zone required", i) + } + if h.Zone == fwZone { + return fmt.Errorf("host[%d]: firewall zone must not be listed in hosts", i) + } + if _, ok := c.Zones[h.Zone]; !ok { + return fmt.Errorf("host[%d]: zone %q not defined", i, h.Zone) + } + if h.Interface == "" { + return fmt.Errorf("host[%d]: interface required", i) + } + + ifaceFound := false + for _, iface := range c.Interfaces { + if iface.Interface == h.Interface || iface.PhysicalName() == h.Interface { + ifaceFound = true + break + } + } + if !ifaceFound { + return fmt.Errorf("host[%d]: interface %q not defined in interfaces", i, h.Interface) + } + + if !h.Dynamic && len(h.Addresses) == 0 { + return fmt.Errorf("host[%d]: at least one address required (or set dynamic: true)", i) + } + } + return nil +} diff --git a/internal/config/interfaces.go b/internal/config/interfaces.go new file mode 100644 index 0000000..1a65e21 --- /dev/null +++ b/internal/config/interfaces.go @@ -0,0 +1,98 @@ +package config + +import ( + "fmt" + "strings" +) + +type Interface struct { + Zone string `yaml:"zone,omitempty"` + Interface string `yaml:"interface"` + Options InterfaceOptions `yaml:"options,omitempty"` +} + +type InterfaceOptions struct { + // Rule generation options + DHCP bool `yaml:"dhcp,omitempty"` + TCPFlags *bool `yaml:"tcpflags,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"` + + // Startup behavior + Optional bool `yaml:"optional,omitempty"` + Required bool `yaml:"required,omitempty"` + Wait int `yaml:"wait,omitempty"` + + // Logical-to-physical mapping + Physical string `yaml:"physical,omitempty"` + + // TCP MSS clamping for forwarded SYN packets + MSS int `yaml:"mss,omitempty"` + + // Limit zone to specific networks + Nets []string `yaml:"nets,omitempty"` + + // Sysctl adjustments (applied on startup) + RouteFilter *int `yaml:"routefilter,omitempty"` + LogMartians *bool `yaml:"logmartians,omitempty"` + ArpFilter *bool `yaml:"arp_filter,omitempty"` + ArpIgnore *int `yaml:"arp_ignore,omitempty"` + ProxyArp *bool `yaml:"proxyarp,omitempty"` + SourceRoute *bool `yaml:"sourceroute,omitempty"` + + // IPv6: controls acceptance of Router Advertisements (0/1/2) + AcceptRA *int `yaml:"accept_ra,omitempty"` +} + +// PhysicalName returns the actual interface name (physical if set, else logical). +func (iface *Interface) PhysicalName() string { + if iface.Options.Physical != "" { + return iface.Options.Physical + } + return iface.Interface +} + +// IsWildcard returns true if the interface matches multiple devices (e.g. "ppp+"). +func (iface *Interface) IsWildcard() bool { + return strings.HasSuffix(iface.PhysicalName(), "+") +} + +func (c *Config) validateInterfaces() error { + seen := make(map[string]bool) + fwZone := c.FirewallZone() + + for i, iface := range c.Interfaces { + if iface.Interface == "" { + return fmt.Errorf("interface[%d]: interface name required", i) + } + if strings.Contains(iface.Interface, ":") { + return fmt.Errorf("interface[%d] %q: virtual interfaces (e.g. eth0:0) not supported; use the physical option instead", i, iface.Interface) + } + + if iface.Zone != "" { + if iface.Zone == fwZone { + return fmt.Errorf("interface[%d] %q: firewall zone must not be listed in interfaces", i, iface.Interface) + } + if _, ok := c.Zones[iface.Zone]; !ok { + return fmt.Errorf("interface[%d] %q: zone %q not defined", i, iface.Interface, iface.Zone) + } + } + + if iface.Options.Unmanaged && iface.Zone != "" { + return fmt.Errorf("interface[%d] %q: unmanaged interfaces must have an empty zone", i, iface.Interface) + } + if iface.Options.Optional && iface.Options.Required { + return fmt.Errorf("interface[%d] %q: optional and required are mutually exclusive", i, iface.Interface) + } + + if seen[iface.Interface] { + return fmt.Errorf("interface[%d]: duplicate interface %q", i, iface.Interface) + } + seen[iface.Interface] = true + } + return nil +} diff --git a/internal/config/maclist.go b/internal/config/maclist.go new file mode 100644 index 0000000..a31dc0a --- /dev/null +++ b/internal/config/maclist.go @@ -0,0 +1,51 @@ +package config + +import "fmt" + +type MaclistAction string + +const ( + MaclistAccept MaclistAction = "accept" + MaclistDrop MaclistAction = "drop" + MaclistReject MaclistAction = "reject" +) + +type MaclistEntry struct { + Action MaclistAction `yaml:"action"` + Interface string `yaml:"interface"` + MAC string `yaml:"mac,omitempty"` + Addresses []string `yaml:"addresses,omitempty"` + Log string `yaml:"log,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +var validMaclistActions = map[MaclistAction]bool{ + MaclistAccept: true, MaclistDrop: true, MaclistReject: true, +} + +func (c *Config) validateMaclist() error { + // Build a set of configured interface names for lookup. + ifaceSet := make(map[string]bool, len(c.Interfaces)) + for _, iface := range c.Interfaces { + ifaceSet[iface.Interface] = true + } + + for i, m := range c.Maclist { + if !validMaclistActions[m.Action] { + return fmt.Errorf("maclist[%d]: unknown action %q", i, m.Action) + } + + if m.Interface == "" { + return fmt.Errorf("maclist[%d]: interface required", i) + } + + if m.MAC == "" && len(m.Addresses) == 0 { + return fmt.Errorf("maclist[%d]: mac or addresses required", i) + } + + if !ifaceSet[m.Interface] { + return fmt.Errorf("maclist[%d]: interface %q not defined in config", i, m.Interface) + } + } + return nil +} diff --git a/internal/config/mangle.go b/internal/config/mangle.go new file mode 100644 index 0000000..214eb5c --- /dev/null +++ b/internal/config/mangle.go @@ -0,0 +1,115 @@ +package config + +import "fmt" + +type MangleAction string + +const ( + MangleMark MangleAction = "mark" + MangleConnMark MangleAction = "connmark" + MangleClassify MangleAction = "classify" + MangleDSCP MangleAction = "dscp" + MangleTOS MangleAction = "tos" + MangleTProxy MangleAction = "tproxy" + MangleSave MangleAction = "save" + MangleRestore MangleAction = "restore" + MangleContinue MangleAction = "continue" + MangleDrop MangleAction = "drop" + MangleLog MangleAction = "log" + MangleNFLog MangleAction = "nflog" + MangleECN MangleAction = "ecn" + MangleTCPMSS MangleAction = "tcpmss" + MangleChecksum MangleAction = "checksum" + MangleInline MangleAction = "inline" +) + +type MangleChain string + +const ( + ManglePrerouting MangleChain = "prerouting" + MangleForward MangleChain = "forward" + ManglePostrouting MangleChain = "postrouting" + MangleInput MangleChain = "input" + MangleOutput MangleChain = "output" +) + +type MangleRule struct { + Action MangleAction `yaml:"action"` + Chain MangleChain `yaml:"chain"` + + // MarkValue is the value to set (required for mark, connmark, classify, dscp, tos actions). + MarkValue string `yaml:"mark_value,omitempty"` + + Source string `yaml:"source,omitempty"` + Dest string `yaml:"dest,omitempty"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + // User/group match (only valid for output chain). + User string `yaml:"user,omitempty"` + + // Packet or connection mark test. Format: [!]value[/mask][:C] + Mark string `yaml:"mark,omitempty"` + + // Packet length match. + Length string `yaml:"length,omitempty"` + + // TOS field match. + TOS string `yaml:"tos,omitempty"` + + // Conntrack helper match. + Helper string `yaml:"helper,omitempty"` + + // Match probability (0.0 to 1.0). + Probability float64 `yaml:"probability,omitempty"` + + // DSCP field match. + DSCP string `yaml:"dscp,omitempty"` + + // Connection state match. + State string `yaml:"state,omitempty"` + + // Time-based restrictions. + Time *TimeSpec `yaml:"time,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validMangleActions = map[MangleAction]bool{ + MangleMark: true, MangleConnMark: true, MangleClassify: true, + MangleDSCP: true, MangleTOS: true, MangleTProxy: true, + MangleSave: true, MangleRestore: true, MangleContinue: true, + MangleDrop: true, MangleLog: true, MangleNFLog: true, + MangleECN: true, MangleTCPMSS: true, MangleChecksum: true, + MangleInline: true, +} + +var validMangleChains = map[MangleChain]bool{ + ManglePrerouting: true, MangleForward: true, + ManglePostrouting: true, MangleInput: true, + MangleOutput: true, +} + +// markValueRequiredActions lists actions that require a mark_value. +var markValueRequiredActions = map[MangleAction]bool{ + MangleMark: true, MangleConnMark: true, MangleClassify: true, + MangleDSCP: true, MangleTOS: true, +} + +func (c *Config) validateMangle() error { + for i, m := range c.Mangle { + if !validMangleActions[m.Action] { + return fmt.Errorf("mangle[%d]: unknown action %q", i, m.Action) + } + if !validMangleChains[m.Chain] { + return fmt.Errorf("mangle[%d]: unknown chain %q", i, m.Chain) + } + + if markValueRequiredActions[m.Action] && m.MarkValue == "" { + return fmt.Errorf("mangle[%d]: mark_value required for %s action", i, m.Action) + } + } + return nil +} diff --git a/internal/config/names.go b/internal/config/names.go new file mode 100644 index 0000000..3e9fe3d --- /dev/null +++ b/internal/config/names.go @@ -0,0 +1,55 @@ +package config + +import ( + "fmt" + "strings" + "unicode" +) + +// ValidateName checks that a name follows shorewall naming conventions: +// starts with a letter, composed of letters, digits, and underscores. +func ValidateName(name, kind string) error { + if len(name) == 0 { + return fmt.Errorf("%s name is empty", kind) + } + if !unicode.IsLetter(rune(name[0])) { + return fmt.Errorf("%s name %q must start with a letter", kind, name) + } + for _, r := range name { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' { + return fmt.Errorf("%s name %q contains invalid character %q", kind, name, r) + } + } + return nil +} + +// ValidateInterfaceRef checks that an interface reference is valid. +// Strips any @suffix (e.g. "sit1@NONE" -> "sit1"), allows trailing + for wildcards. +func ValidateInterfaceRef(name string) string { + if idx := strings.IndexByte(name, '@'); idx >= 0 { + name = name[:idx] + } + return name +} + +// IsWildcardInterface returns true if the interface name is a wildcard (ends with +). +func IsWildcardInterface(name string) bool { + return strings.HasSuffix(name, "+") +} + +// ValidateDNSName checks shorewall's DNS name rules: fully qualified, +// minimum two periods. Returns an error if the name looks like a DNS name +// but doesn't meet the requirements. +func ValidateDNSName(name string) error { + if !strings.Contains(name, ".") { + return nil + } + count := strings.Count(name, ".") + if count < 2 { + trimmed := strings.TrimSuffix(name, ".") + if strings.Count(trimmed, ".") < 1 { + return fmt.Errorf("DNS name %q must be fully qualified with at least two periods", name) + } + } + return nil +} diff --git a/internal/config/nat.go b/internal/config/nat.go new file mode 100644 index 0000000..eabe41f --- /dev/null +++ b/internal/config/nat.go @@ -0,0 +1,62 @@ +package config + +import ( + "fmt" + "net" +) + +// StaticNAT defines a one-to-one NAT mapping between an external and internal address. +// All traffic to the external address is forwarded to the internal address and vice versa. +// DNAT rules take precedence over static NAT rules. +type StaticNAT struct { + // External IP address. Must not be the primary address of the interface. + // DNS names are not allowed. + External string `yaml:"external"` + + // Interface that has the external address. + Interface string `yaml:"interface"` + + // Internal IP address. DNS names are not allowed. + Internal string `yaml:"internal"` + + // If true, NAT is effective from all hosts (not just those on the named interface). + AllInterfaces bool `yaml:"all_interfaces,omitempty"` + + // If true, NAT is effective from the firewall system itself. + Local bool `yaml:"local,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateStaticNAT() error { + for i, n := range c.StaticNAT { + if n.External == "" { + return fmt.Errorf("nat[%d]: external address required", i) + } + if net.ParseIP(n.External) == nil { + return fmt.Errorf("nat[%d]: external must be an IP address, not a DNS name", i) + } + + if n.Interface == "" { + return fmt.Errorf("nat[%d]: interface required", i) + } + ifaceFound := false + for _, iface := range c.Interfaces { + if iface.Interface == n.Interface || iface.PhysicalName() == n.Interface { + ifaceFound = true + break + } + } + if !ifaceFound { + return fmt.Errorf("nat[%d]: interface %q not defined in interfaces", i, n.Interface) + } + + if n.Internal == "" { + return fmt.Errorf("nat[%d]: internal address required", i) + } + if net.ParseIP(n.Internal) == nil { + return fmt.Errorf("nat[%d]: internal must be an IP address, not a DNS name", i) + } + } + return nil +} diff --git a/internal/config/nesting.go b/internal/config/nesting.go new file mode 100644 index 0000000..0921c6e --- /dev/null +++ b/internal/config/nesting.go @@ -0,0 +1,102 @@ +package config + +import "fmt" + +// ResolveNesting computes the effective zone order, ensuring child zones +// appear before their parents. This determines the order in which packets +// are matched against zones — more specific (child) zones are checked first. +func (c *Config) ResolveNesting() ([]string, error) { + resolved := make(map[string]bool) + var order []string + + for range c.Zones { + progress := false + for name, zone := range c.Zones { + if resolved[name] { + continue + } + ready := true + for _, parent := range zone.Parents { + if c.Zones[parent].Type == ZoneFirewall { + continue + } + for childName, childZone := range c.Zones { + if childName == name || resolved[childName] { + continue + } + for _, cp := range childZone.Parents { + if cp == parent && !resolved[childName] { + ready = false + } + } + } + } + _ = ready + if !resolved[name] && allParentsDepsResolved(name, c.Zones, resolved) { + resolved[name] = true + order = append(order, name) + progress = true + } + } + if !progress { + break + } + } + + if len(order) != len(c.Zones) { + return nil, fmt.Errorf("circular zone nesting detected") + } + + return order, nil +} + +func allParentsDepsResolved(name string, zones map[string]Zone, resolved map[string]bool) bool { + zone := zones[name] + if len(zone.Parents) == 0 { + return true + } + for _, parent := range zone.Parents { + for childName, childZone := range zones { + if childName == name || childName == parent { + continue + } + for _, cp := range childZone.Parents { + if cp == parent && !resolved[childName] { + return false + } + } + } + } + return true +} + +// ChildZones returns all zones that list the given zone as a parent. +func (c *Config) ChildZones(parent string) []string { + var children []string + for name, zone := range c.Zones { + for _, p := range zone.Parents { + if p == parent { + children = append(children, name) + break + } + } + } + return children +} + +// IsSubZone returns true if child is a sub-zone of parent (directly or transitively). +func (c *Config) IsSubZone(child, parent string) bool { + zone, ok := c.Zones[child] + if !ok { + return false + } + for _, p := range zone.Parents { + if p == parent { + return true + } + if c.IsSubZone(p, parent) { + return true + } + } + return false +} diff --git a/internal/config/netmap.go b/internal/config/netmap.go new file mode 100644 index 0000000..ae46f43 --- /dev/null +++ b/internal/config/netmap.go @@ -0,0 +1,83 @@ +package config + +import ( + "fmt" + "net" +) + +type NetmapType string + +const ( + NetmapDNAT NetmapType = "dnat" + NetmapSNAT NetmapType = "snat" +) + +// Netmap maps addresses in one network to corresponding addresses in another. +// For DNAT: traffic entering the interface addressed to Net1 has its dest rewritten to Net2. +// For SNAT: traffic leaving the interface with source in Net1 has its source rewritten to Net2. +type Netmap struct { + Type NetmapType `yaml:"type"` + + // Network in CIDR format to match. + Net1 string `yaml:"net1"` + + // Interface name (must be defined in interfaces). + Interface string `yaml:"interface"` + + // Network in CIDR format to rewrite to. + Net2 string `yaml:"net2"` + + // Optional qualifying network: source for DNAT rules, destination for SNAT rules. + Net3 string `yaml:"net3,omitempty"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateNetmap() error { + for i, nm := range c.Netmap { + switch nm.Type { + case NetmapDNAT, NetmapSNAT: + default: + return fmt.Errorf("netmap[%d]: type must be dnat or snat, got %q", i, nm.Type) + } + + if nm.Net1 == "" { + return fmt.Errorf("netmap[%d]: net1 required", i) + } + if _, _, err := net.ParseCIDR(nm.Net1); err != nil { + return fmt.Errorf("netmap[%d]: net1 must be CIDR format: %w", i, err) + } + + if nm.Interface == "" { + return fmt.Errorf("netmap[%d]: interface required", i) + } + ifaceFound := false + for _, iface := range c.Interfaces { + if iface.Interface == nm.Interface || iface.PhysicalName() == nm.Interface { + ifaceFound = true + break + } + } + if !ifaceFound { + return fmt.Errorf("netmap[%d]: interface %q not defined in interfaces", i, nm.Interface) + } + + if nm.Net2 == "" { + return fmt.Errorf("netmap[%d]: net2 required", i) + } + if _, _, err := net.ParseCIDR(nm.Net2); err != nil { + return fmt.Errorf("netmap[%d]: net2 must be CIDR format: %w", i, err) + } + + if nm.Net3 != "" { + if _, _, err := net.ParseCIDR(nm.Net3); err != nil { + return fmt.Errorf("netmap[%d]: net3 must be CIDR format: %w", i, err) + } + } + } + return nil +} diff --git a/internal/config/params.go b/internal/config/params.go new file mode 100644 index 0000000..d04c419 --- /dev/null +++ b/internal/config/params.go @@ -0,0 +1,32 @@ +package config + +import ( + "fmt" + "strings" +) + +// SubstituteVars replaces ${var} and $var references in a string with values +// from the vars map. This is the YAML equivalent of shorewall's params file. +func SubstituteVars(s string, vars map[string]string) string { + if len(vars) == 0 || !strings.Contains(s, "$") { + return s + } + result := s + for k, v := range vars { + result = strings.ReplaceAll(result, "${"+k+"}", v) + result = strings.ReplaceAll(result, "$"+k, v) + } + return result +} + +func (c *Config) validateVars() error { + for k := range c.Vars { + if k == "" { + return fmt.Errorf("vars: empty variable name") + } + if strings.ContainsAny(k, " \t${}") { + return fmt.Errorf("vars: invalid variable name %q", k) + } + } + return nil +} diff --git a/internal/config/policy.go b/internal/config/policy.go new file mode 100644 index 0000000..a4c9903 --- /dev/null +++ b/internal/config/policy.go @@ -0,0 +1,143 @@ +package config + +import ( + "fmt" + "strings" +) + +type PolicyAction string + +const ( + PolicyAccept PolicyAction = "accept" + PolicyDrop PolicyAction = "drop" + PolicyReject PolicyAction = "reject" + PolicyContinue PolicyAction = "continue" + PolicyNone PolicyAction = "none" + PolicyQueue PolicyAction = "queue" + PolicyNFQueue PolicyAction = "nfqueue" +) + +// Policy defines the default action for traffic between zones. +// Policies are evaluated in order — first match wins. +// Intra-zone traffic (zone to itself) is implicitly ACCEPTed unless +// overridden with an explicit policy or by using "all+" as source/dest. +type Policy struct { + // Source zone(s). Supports: zone name, "all", "all+" (overrides intra-zone ACCEPT), + // comma-separated zones ("loc,dmz"), or exclusions ("all!net"). + Source string `yaml:"source"` + + // Dest zone(s). Same syntax as Source. + Dest string `yaml:"dest"` + + Action PolicyAction `yaml:"action"` + Log string `yaml:"log,omitempty"` + + // Rate limit for TCP connections. + // Format: [s:|d:][name:]rate/{sec|min|hour|day}[:burst] + RateLimit string `yaml:"rate_limit,omitempty"` + + // Simultaneous connection limit. Format: limit[:mask] + // mask is a VLSM prefix length to apply per-subnet limiting. + ConnLimit string `yaml:"conn_limit,omitempty"` + + // NFQueue number (only used when action is nfqueue). + NFQueue int `yaml:"nfqueue,omitempty"` +} + +func (c *Config) validatePolicy() error { + if len(c.Policy) == 0 { + return fmt.Errorf("no policies defined") + } + + fwZone := c.FirewallZone() + + for i, p := range c.Policy { + if p.Source == "" { + return fmt.Errorf("policy[%d]: source required", i) + } + if p.Dest == "" { + return fmt.Errorf("policy[%d]: dest required", i) + } + + switch p.Action { + case PolicyAccept, PolicyDrop, PolicyReject, PolicyContinue, PolicyNone, PolicyQueue, PolicyNFQueue: + default: + return fmt.Errorf("policy[%d]: unknown action %q", i, p.Action) + } + + if err := validatePolicyZoneRef(p.Source, c, fwZone, p.Action, "source", i); err != nil { + return err + } + if err := validatePolicyZoneRef(p.Dest, c, fwZone, p.Action, "dest", i); err != nil { + return err + } + } + return nil +} + +// validatePolicyZoneRef validates a source or dest field, which can be: +// "all", "all+", "all!zone1,zone2", "zone1,zone2", "zone1,zone2+", or a single zone name. +func validatePolicyZoneRef(ref string, c *Config, fwZone string, action PolicyAction, field string, idx int) error { + if ref == "" { + return nil + } + + base, exclusions := parsePolicyRef(ref) + + if action == PolicyNone { + if base == "all" || base == "all+" { + return fmt.Errorf("policy[%d]: NONE may not be used with %s=%q", idx, field, ref) + } + for _, z := range splitZones(base) { + if z == fwZone { + return fmt.Errorf("policy[%d]: NONE may not be used with the firewall zone", idx) + } + } + } + + if base != "all" && base != "all+" { + for _, z := range splitZones(base) { + name := strings.TrimSuffix(z, "+") + if name != fwZone { + if _, ok := c.Zones[name]; !ok { + return fmt.Errorf("policy[%d]: %s zone %q not defined", idx, field, name) + } + } + } + } + + for _, ez := range exclusions { + if _, ok := c.Zones[ez]; !ok { + return fmt.Errorf("policy[%d]: excluded %s zone %q not defined", idx, field, ez) + } + } + + return nil +} + +// parsePolicyRef splits "all!net,dmz" into base="all" and exclusions=["net","dmz"]. +func parsePolicyRef(ref string) (base string, exclusions []string) { + if idx := strings.IndexByte(ref, '!'); idx >= 0 { + base = ref[:idx] + for _, z := range strings.Split(ref[idx+1:], ",") { + z = strings.TrimSpace(z) + if z != "" { + exclusions = append(exclusions, z) + } + } + return base, exclusions + } + return ref, nil +} + +func splitZones(ref string) []string { + ref = strings.TrimSuffix(ref, "+") + var zones []string + for _, z := range strings.Split(ref, ",") { + z = strings.TrimSpace(z) + if z != "" { + zones = append(zones, z) + } + } + return zones +} diff --git a/internal/config/portgroups.go b/internal/config/portgroups.go new file mode 100644 index 0000000..61d32a2 --- /dev/null +++ b/internal/config/portgroups.go @@ -0,0 +1,58 @@ +package config + +import ( + "fmt" + "strconv" + "strings" +) + +type PortGroup struct { + Proto string `yaml:"proto"` + Ports PortSpec `yaml:"ports"` +} + +// ParsedPorts returns individual port numbers and ranges as (start, end) pairs. +func (pg *PortGroup) ParsedPorts() (singles []uint16, ranges [][2]uint16, err error) { + for _, p := range pg.Ports { + if strings.Contains(p, "-") { + parts := strings.SplitN(p, "-", 2) + start, err := strconv.ParseUint(parts[0], 10, 16) + if err != nil { + return nil, nil, fmt.Errorf("invalid port range start %q: %w", parts[0], err) + } + end, err := strconv.ParseUint(parts[1], 10, 16) + if err != nil { + return nil, nil, fmt.Errorf("invalid port range end %q: %w", parts[1], err) + } + if start > end { + return nil, nil, fmt.Errorf("port range %d-%d: start > end", start, end) + } + ranges = append(ranges, [2]uint16{uint16(start), uint16(end)}) + } else { + port, err := strconv.ParseUint(p, 10, 16) + if err != nil { + return nil, nil, fmt.Errorf("invalid port %q: %w", p, err) + } + singles = append(singles, uint16(port)) + } + } + return singles, ranges, nil +} + +func (c *Config) validatePortGroups() error { + for name, pg := range c.PortGroups { + if pg.Proto == "" { + return fmt.Errorf("portgroup %q: proto required", name) + } + if pg.Proto != "tcp" && pg.Proto != "udp" { + return fmt.Errorf("portgroup %q: proto must be tcp or udp, got %q", name, pg.Proto) + } + if len(pg.Ports) == 0 { + return fmt.Errorf("portgroup %q: at least one port required", name) + } + if _, _, err := pg.ParsedPorts(); err != nil { + return fmt.Errorf("portgroup %q: %w", name, err) + } + } + return nil +} diff --git a/internal/config/providers.go b/internal/config/providers.go new file mode 100644 index 0000000..9da8f6f --- /dev/null +++ b/internal/config/providers.go @@ -0,0 +1,117 @@ +package config + +import "fmt" + +// Provider defines an additional routing table for multi-ISP or policy routing. +type Provider struct { + // Provider name. Must be a valid name; "local", "main", "default", "unspec" are reserved. + Name string `yaml:"name"` + + // Routing table number (1-252). Must be unique per provider. + Number int `yaml:"number"` + + // FWMARK value for directing packets to this provider via mangle rules. + Mark int `yaml:"mark,omitempty"` + + // Existing routing table to duplicate (e.g. "main" or another provider name). + Duplicate string `yaml:"duplicate,omitempty"` + + // Network interface to the provider. Must be defined in interfaces. + // Format: interface or interface:address (when multiple providers share an interface). + Interface string `yaml:"interface"` + + // Gateway address. Supports: IP address, "detect", "none", or omit for PPP. + Gateway string `yaml:"gateway,omitempty"` + + Options ProviderOptions `yaml:"options,omitempty"` + + // Interfaces to copy routes from when duplicating. Use "none" to only copy + // routes through the provider's own interface. + Copy []string `yaml:"copy,omitempty"` +} + +type ProviderOptions struct { + // Track inbound connections so responses route back out this interface. + Track bool `yaml:"track,omitempty"` + + // Load-balance outbound traffic across providers with balance set. + // Set to 1 for equal weight, or higher for more weight. + Balance int `yaml:"balance,omitempty"` + + // Alternative load balancing via probability (0 < p <= 1). + Load float64 `yaml:"load,omitempty"` + + // Do not create per-address routing rules for this interface. + Loose bool `yaml:"loose,omitempty"` + + // Add a default route through this provider to the main routing table. + // Set to 1 for equal weight, or higher for more weight. + Fallback int `yaml:"fallback,omitempty"` + + // Mark this as the primary provider (equivalent to balance=1). + Primary bool `yaml:"primary,omitempty"` + + // Source address for traffic routed through this provider. + Src string `yaml:"src,omitempty"` + + // MTU override when forwarding through this provider. + MTU int `yaml:"mtu,omitempty"` + + // TPROXY provider for transparent proxying. When set, mark/duplicate/gateway + // should be empty and interface should be "lo". + TProxy bool `yaml:"tproxy,omitempty"` + + // Allow the firewall to start even if this provider's interface is not up. + Optional bool `yaml:"optional,omitempty"` + + // Provider survives disable — routing table keeps its default route. + Persistent bool `yaml:"persistent,omitempty"` +} + +var reservedProviderNames = map[string]bool{ + "local": true, "main": true, "default": true, "unspec": true, +} + +func (c *Config) validateProviders() error { + seenNumbers := make(map[int]string) + seenNames := make(map[string]bool) + + for i, p := range c.Providers { + if p.Name == "" { + return fmt.Errorf("provider[%d]: name required", i) + } + if err := ValidateName(p.Name, "provider"); err != nil { + return fmt.Errorf("provider[%d]: %w", i, err) + } + if reservedProviderNames[p.Name] { + return fmt.Errorf("provider[%d]: %q is a reserved name", i, p.Name) + } + if seenNames[p.Name] { + return fmt.Errorf("provider[%d]: duplicate name %q", i, p.Name) + } + seenNames[p.Name] = true + + if p.Number < 1 || p.Number > 252 { + return fmt.Errorf("provider[%d] %q: number must be between 1 and 252", i, p.Name) + } + if existing, ok := seenNumbers[p.Number]; ok { + return fmt.Errorf("provider[%d] %q: number %d already used by %q", i, p.Name, p.Number, existing) + } + seenNumbers[p.Number] = p.Name + + if p.Interface == "" { + return fmt.Errorf("provider[%d] %q: interface required", i, p.Name) + } + + if p.Options.TProxy { + if p.Mark != 0 || p.Duplicate != "" || p.Gateway != "" { + return fmt.Errorf("provider[%d] %q: tproxy provider must have empty mark, duplicate, and gateway", i, p.Name) + } + } + + if p.Options.Load != 0 && (p.Options.Load <= 0 || p.Options.Load > 1) { + return fmt.Errorf("provider[%d] %q: load probability must be between 0 (exclusive) and 1 (inclusive)", i, p.Name) + } + } + return nil +} diff --git a/internal/config/proxyarp.go b/internal/config/proxyarp.go new file mode 100644 index 0000000..c3b72c9 --- /dev/null +++ b/internal/config/proxyarp.go @@ -0,0 +1,38 @@ +package config + +import "fmt" + +type ProxyARP struct { + // Address is the IP address to proxy ARP for. + Address string `yaml:"address"` + + // Interface is the local interface where the proxied host resides. + Interface string `yaml:"interface,omitempty"` + + // External is the external-facing interface. + External string `yaml:"external"` + + // HaveRoute indicates that a route to the address already exists, + // so no interface is required. + HaveRoute bool `yaml:"haveroute,omitempty"` + + // Persistent survives firewall restarts. + Persistent bool `yaml:"persistent,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateProxyARP() error { + for i, p := range c.ProxyARP { + if p.Address == "" { + return fmt.Errorf("proxyarp[%d]: address required", i) + } + if p.External == "" { + return fmt.Errorf("proxyarp[%d]: external required", i) + } + if p.Interface == "" && !p.HaveRoute { + return fmt.Errorf("proxyarp[%d]: interface required unless haveroute is set", i) + } + } + return nil +} diff --git a/internal/config/proxyndp.go b/internal/config/proxyndp.go new file mode 100644 index 0000000..ff3c360 --- /dev/null +++ b/internal/config/proxyndp.go @@ -0,0 +1,37 @@ +package config + +import "fmt" + +type ProxyNDP struct { + // Address is the IPv6 address to proxy NDP for. + Address string `yaml:"address"` + + // Interface is the local interface where the proxied host resides. + Interface string `yaml:"interface,omitempty"` + + // External is the external-facing interface. + External string `yaml:"external"` + + // HaveRoute indicates that a route to the address already exists. + HaveRoute bool `yaml:"haveroute,omitempty"` + + // Persistent survives firewall restarts. + Persistent bool `yaml:"persistent,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateProxyNDP() error { + for i, p := range c.ProxyNDP { + if p.Address == "" { + return fmt.Errorf("proxyndp[%d]: address required", i) + } + if p.External == "" { + return fmt.Errorf("proxyndp[%d]: external required", i) + } + if p.Interface == "" && !p.HaveRoute { + return fmt.Errorf("proxyndp[%d]: interface required unless haveroute is set", i) + } + } + return nil +} diff --git a/internal/config/routes.go b/internal/config/routes.go new file mode 100644 index 0000000..4869db5 --- /dev/null +++ b/internal/config/routes.go @@ -0,0 +1,43 @@ +package config + +import "fmt" + +type StaticRoute struct { + // Provider is the routing provider/table this route belongs to. + Provider string `yaml:"provider"` + + // Dest is the destination CIDR or host address. + Dest string `yaml:"dest"` + + // Gateway is the next-hop IP address, or one of "blackhole", "prohibit", "unreachable". + Gateway string `yaml:"gateway"` + + // Device is the outbound interface. Not allowed with blackhole/prohibit/unreachable gateways. + Device string `yaml:"device,omitempty"` + + // Persistent survives firewall restarts. + Persistent bool `yaml:"persistent,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var specialGateways = map[string]bool{ + "blackhole": true, + "prohibit": true, + "unreachable": true, +} + +func (c *Config) validateRoutes() error { + for i, r := range c.Routes { + if r.Provider == "" { + return fmt.Errorf("routes[%d]: provider required", i) + } + if r.Dest == "" { + return fmt.Errorf("routes[%d]: dest required", i) + } + if r.Device != "" && specialGateways[r.Gateway] { + return fmt.Errorf("routes[%d]: device not allowed with %s gateway", i, r.Gateway) + } + } + return nil +} diff --git a/internal/config/rtrules.go b/internal/config/rtrules.go new file mode 100644 index 0000000..080e0fc --- /dev/null +++ b/internal/config/rtrules.go @@ -0,0 +1,62 @@ +package config + +import "fmt" + +// RoutingRule directs traffic matching source/dest criteria to a specific +// provider's routing table. Requires providers to be configured. +type RoutingRule struct { + // Source address, interface, or interface:address. Use "&interface" for interface's + // primary IP. "lo" matches firewall-originated traffic. + Source string `yaml:"source,omitempty"` + + // Destination address or network in CIDR format. + Dest string `yaml:"dest,omitempty"` + + // Provider name, provider number, or "main" (254) for the main routing table. + Provider string `yaml:"provider"` + + // Numeric priority determining rule evaluation order. + // 1000-1999: before mark rules, 11000-11999: after mark rules, + // 26000-26999: after ISP interface rules. + Priority int `yaml:"priority"` + + // Persist rule even if the provider's interface is disabled. + Persistent bool `yaml:"persistent,omitempty"` + + // Packet mark match. Format: mark[/mask]. + Mark string `yaml:"mark,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateRoutingRules() error { + if len(c.RoutingRules) > 0 && len(c.Providers) == 0 { + return fmt.Errorf("rtrules require providers to be configured") + } + + providerNames := make(map[string]bool) + providerNames["main"] = true + for _, p := range c.Providers { + providerNames[p.Name] = true + providerNames[fmt.Sprintf("%d", p.Number)] = true + } + providerNames["254"] = true + + for i, r := range c.RoutingRules { + if r.Source == "" && r.Dest == "" { + return fmt.Errorf("rtrules[%d]: source or dest required", i) + } + + if r.Provider == "" { + return fmt.Errorf("rtrules[%d]: provider required", i) + } + if !providerNames[r.Provider] { + return fmt.Errorf("rtrules[%d]: provider %q not defined", i, r.Provider) + } + + if r.Priority < 1000 || r.Priority > 26999 { + return fmt.Errorf("rtrules[%d]: priority must be 1000-26999", i) + } + } + return nil +} diff --git a/internal/config/rules.go b/internal/config/rules.go new file mode 100644 index 0000000..0a52040 --- /dev/null +++ b/internal/config/rules.go @@ -0,0 +1,238 @@ +package config + +import "fmt" + +type RuleAction string + +const ( + RuleAccept RuleAction = "accept" + RuleDrop RuleAction = "drop" + RuleReject RuleAction = "reject" + RuleDNAT RuleAction = "dnat" + RuleRedirect RuleAction = "redirect" + RuleLog RuleAction = "log" + RuleContinue RuleAction = "continue" + RuleNFQueue RuleAction = "nfqueue" + RuleNoNAT RuleAction = "nonat" + RuleTarpit RuleAction = "tarpit" + RuleCount RuleAction = "count" + RuleMark RuleAction = "mark" + RuleConnMark RuleAction = "connmark" +) + +type RuleSection string + +const ( + SectionAll RuleSection = "all" + SectionEstablished RuleSection = "established" + SectionRelated RuleSection = "related" + SectionInvalid RuleSection = "invalid" + SectionUntracked RuleSection = "untracked" + SectionNew RuleSection = "new" +) + +// Rule defines a specific traffic rule — an exception to the default policy. +// Rules are evaluated in order; the first terminating match wins. +// LOG, COUNT, MARK, and CONNMARK are non-terminating (packet continues to next rule). +type Rule struct { + Action RuleAction `yaml:"action"` + Section RuleSection `yaml:"section,omitempty"` + + // Source zone spec. Supports: + // zone, zone:address, zone:interface, zone:interface:address + // all, all+, any, none, all!zone1,zone2 + // Multiple zones: loc,dmz + Source string `yaml:"source"` + + // Dest zone spec. Same syntax as Source. + // For DNAT: zone:server-ip:port[:random] + // For REDIRECT: port (zone is implicitly the firewall) + Dest string `yaml:"dest"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + // PortGroup references a named portgroup (mutually exclusive with proto+dport). + PortGroup string `yaml:"portgroup,omitempty"` + + Log string `yaml:"log,omitempty"` + + // OrigDest is the original destination address before DNAT/REDIRECT rewriting. + // For non-NAT rules, constrains which original dest addresses match. + OrigDest string `yaml:"origdest,omitempty"` + + // Rate limit. Format: [s:|d:][name:]rate/{sec|min|hour|day}[:burst] + RateLimit string `yaml:"rate_limit,omitempty"` + + // User/group match (only valid when source is the firewall zone). + // Format: [!]user[:group] + User string `yaml:"user,omitempty"` + + // Packet or connection mark test. Format: [!]value[/mask][:C] + Mark string `yaml:"mark,omitempty"` + + // Mark value to set (for mark/connmark actions). Format: value[/mask] + SetMark string `yaml:"set_mark,omitempty"` + + // Simultaneous connection limit. Format: [d:]limit[:mask] + ConnLimit string `yaml:"conn_limit,omitempty"` + + // Time-based restrictions. + Time *TimeSpec `yaml:"time,omitempty"` + + // Conntrack helper. Values: ftp, sip, tftp, irc, pptp, amanda, snmp, etc. + Helper string `yaml:"helper,omitempty"` + + // NFQUEUE number (only for nfqueue action). + NFQueue int `yaml:"nfqueue,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +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"` +} + +// PortSpec supports single ports, ranges, and lists. +// Examples: [80], [443], [80, 443], ["1024-65535"], [53, "80-90"] +// For ICMP, values are interpreted as ICMP types (e.g. "echo-request", "8", "3/4"). +type PortSpec []string + +func (ps *PortSpec) UnmarshalYAML(unmarshal func(interface{}) error) error { + var multi []interface{} + if err := unmarshal(&multi); err == nil { + for _, v := range multi { + switch val := v.(type) { + case int: + *ps = append(*ps, fmt.Sprintf("%d", val)) + case float64: + *ps = append(*ps, fmt.Sprintf("%d", int(val))) + case string: + *ps = append(*ps, val) + default: + return fmt.Errorf("unsupported port value type %T", v) + } + } + return nil + } + + var single string + if err := unmarshal(&single); err == nil { + *ps = PortSpec{single} + return nil + } + + var num int + if err := unmarshal(&num); err == nil { + *ps = PortSpec{fmt.Sprintf("%d", num)} + return nil + } + + return fmt.Errorf("invalid port spec") +} + +var validRuleActions = map[RuleAction]bool{ + RuleAccept: true, RuleDrop: true, RuleReject: true, + RuleDNAT: true, RuleRedirect: true, RuleLog: true, + RuleContinue: true, RuleNFQueue: true, RuleNoNAT: true, + RuleTarpit: true, RuleCount: true, RuleMark: true, + RuleConnMark: true, +} + +var validSections = map[RuleSection]bool{ + SectionAll: true, SectionEstablished: true, SectionRelated: true, + SectionInvalid: true, SectionUntracked: true, SectionNew: true, + "": true, +} + +func (c *Config) validateRules() error { + fwZone := c.FirewallZone() + + for i, r := range c.Rules { + if !validRuleActions[r.Action] { + return fmt.Errorf("rule[%d]: unknown action %q", i, r.Action) + } + if !validSections[r.Section] { + return fmt.Errorf("rule[%d]: unknown section %q", i, r.Section) + } + + if r.Source == "" { + return fmt.Errorf("rule[%d]: source required", i) + } + if r.Dest == "" { + return fmt.Errorf("rule[%d]: dest required", i) + } + + if r.Source != "all" && r.Source != "any" && r.Source != "none" && + !hasPrefix(r.Source, "all+") && !hasPrefix(r.Source, "all!") && !hasPrefix(r.Source, "any!") { + for _, srcPart := range splitZones(r.Source) { + srcZone := zoneFromSpec(srcPart) + if _, ok := c.Zones[srcZone]; !ok { + return fmt.Errorf("rule[%d]: source zone %q not defined", i, srcZone) + } + } + } + + if r.Action != RuleDNAT && r.Action != RuleRedirect && r.Action != RuleNoNAT { + if r.Dest != "all" && r.Dest != "any" && r.Dest != "none" && + !hasPrefix(r.Dest, "all+") && !hasPrefix(r.Dest, "all!") && !hasPrefix(r.Dest, "any!") { + for _, dstPart := range splitZones(r.Dest) { + dstZone := zoneFromSpec(dstPart) + if _, ok := c.Zones[dstZone]; !ok { + return fmt.Errorf("rule[%d]: dest zone %q not defined", i, dstZone) + } + } + } + } + + if r.PortGroup != "" { + if _, ok := c.PortGroups[r.PortGroup]; !ok { + return fmt.Errorf("rule[%d]: portgroup %q not defined", i, r.PortGroup) + } + if r.Proto != "" || len(r.DPort) > 0 { + return fmt.Errorf("rule[%d]: portgroup is mutually exclusive with proto/dport", i) + } + } + + if r.Action == RuleDNAT && r.Dest == "" { + return fmt.Errorf("rule[%d]: dest with target address required for DNAT", i) + } + + if r.User != "" && fwZone != "" { + srcZone := zoneFromSpec(r.Source) + if srcZone != fwZone { + return fmt.Errorf("rule[%d]: user match only valid when source is the firewall zone", i) + } + } + + if (r.Action == RuleMark || r.Action == RuleConnMark) && r.SetMark == "" { + return fmt.Errorf("rule[%d]: set_mark required for %s action", i, r.Action) + } + + if r.Action == RuleTarpit && r.Proto != "tcp" && r.Proto != "" { + return fmt.Errorf("rule[%d]: tarpit only works with proto tcp", i) + } + } + return nil +} + +// zoneFromSpec extracts the zone name from a zone spec like "net" or "net:192.168.1.0/24". +func zoneFromSpec(spec string) string { + for i, c := range spec { + if c == ':' { + return spec[:i] + } + } + return spec +} + +func hasPrefix(s, prefix string) bool { + return len(s) >= len(prefix) && s[:len(prefix)] == prefix +} diff --git a/internal/config/secmarks.go b/internal/config/secmarks.go new file mode 100644 index 0000000..cdd7bc5 --- /dev/null +++ b/internal/config/secmarks.go @@ -0,0 +1,27 @@ +package config + +import "fmt" + +// SecmarkRule defines an SELinux security marking rule. +type SecmarkRule struct { + Secmark string `yaml:"secmark"` // SELinux context, or "save"/"restore" + Chain string `yaml:"chain"` // P/I/F/O/T with optional state + Source string `yaml:"source,omitempty"` + Dest string `yaml:"dest,omitempty"` + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateSecmarks() error { + for i, s := range c.Secmarks { + if s.Secmark == "" { + return fmt.Errorf("secmarks[%d]: secmark required", i) + } + if s.Chain == "" { + return fmt.Errorf("secmarks[%d]: chain required", i) + } + } + return nil +} diff --git a/internal/config/snat.go b/internal/config/snat.go new file mode 100644 index 0000000..7e9b926 --- /dev/null +++ b/internal/config/snat.go @@ -0,0 +1,97 @@ +package config + +import "fmt" + +type SNATAction string + +const ( + SNATMasquerade SNATAction = "masquerade" + SNATAddress SNATAction = "snat" + SNATContinue SNATAction = "continue" + SNATLog SNATAction = "log" +) + +// SNATRule defines a source NAT or masquerade rule. +// Rules are evaluated in order — first match wins. +type SNATRule struct { + Action SNATAction `yaml:"action"` + + // For SNAT: the source address (or address range first-last) to rewrite to. + // Supports: single IP, IP range (1.2.3.4-1.2.3.7), or "detect" (use interface addresses). + Address string `yaml:"address,omitempty"` + + // Port remapping for SNAT/MASQUERADE. Format: lowport-highport or single port. + // Requires proto to be tcp, udp, dccp, or sctp. + PortRange string `yaml:"port_range,omitempty"` + + // Randomize port mapping. + Random bool `yaml:"random,omitempty"` + + // Give a client the same source/destination IP pair (only with address ranges). + Persistent bool `yaml:"persistent,omitempty"` + + // Source addresses/networks to match for masquerading. + // Supports: CIDR, host address, comma-separated list, ipset (+name). + Source string `yaml:"source,omitempty"` + + // Outgoing interface(s) and optional destination address qualification. + // Format: interface, interface:dest-address, or comma-separated interfaces. + // Use "$FW" for SNAT in the INPUT chain. + Dest string `yaml:"dest"` + + // Protocol restriction. Comma-separated list allowed. + Proto string `yaml:"proto,omitempty"` + + // Destination port(s). + DPort PortSpec `yaml:"dport,omitempty"` + + // Source port(s). + SPort PortSpec `yaml:"sport,omitempty"` + + // Packet/connection mark test. Format: [!]value[/mask][:C] + Mark string `yaml:"mark,omitempty"` + + // Original destination address filter — match only connections that were + // previously DNAT'd to these addresses. + OrigDest string `yaml:"origdest,omitempty"` + + // Random matching probability (0 < p <= 1) for load-balancing across + // multiple SNAT addresses. + Probability float64 `yaml:"probability,omitempty"` + + // Log level (for log action, or appended to other actions). + Log string `yaml:"log,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateSNAT() error { + for i, s := range c.SNAT { + switch s.Action { + case SNATMasquerade, SNATAddress, SNATContinue, SNATLog: + default: + return fmt.Errorf("snat[%d]: unknown action %q", i, s.Action) + } + + if s.Action == SNATAddress && s.Address == "" { + return fmt.Errorf("snat[%d]: address required for snat action", i) + } + + if s.Dest == "" { + return fmt.Errorf("snat[%d]: dest required", i) + } + + if s.PortRange != "" && s.Proto == "" { + return fmt.Errorf("snat[%d]: port_range requires proto (tcp, udp, dccp, or sctp)", i) + } + + if s.Persistent && s.Address == "" { + return fmt.Errorf("snat[%d]: persistent requires an address or address range", i) + } + + if s.Probability != 0 && (s.Probability <= 0 || s.Probability > 1) { + return fmt.Errorf("snat[%d]: probability must be between 0 (exclusive) and 1 (inclusive)", i) + } + } + return nil +} diff --git a/internal/config/stoppedrules.go b/internal/config/stoppedrules.go new file mode 100644 index 0000000..5708ac7 --- /dev/null +++ b/internal/config/stoppedrules.go @@ -0,0 +1,58 @@ +package config + +import "fmt" + +type StoppedAction string + +const ( + StoppedAccept StoppedAction = "accept" + StoppedNoTrack StoppedAction = "notrack" + StoppedDrop StoppedAction = "drop" +) + +// StoppedRule defines traffic that is permitted when the firewall is stopped +// or being stopped. Without these rules, all traffic is blocked in the +// stopped state. +type StoppedRule struct { + Action StoppedAction `yaml:"action"` + + // Source: $FW (firewall), interface name, or interface:address. + Source string `yaml:"source,omitempty"` + + // Dest: $FW (firewall), interface name, or interface:address. + // May not be specified with NOTRACK or DROP actions. + Dest string `yaml:"dest,omitempty"` + + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validStoppedActions = map[StoppedAction]bool{ + StoppedAccept: true, StoppedNoTrack: true, StoppedDrop: true, +} + +func (c *Config) validateStoppedRules() error { + for i, r := range c.StoppedRules { + if !validStoppedActions[r.Action] { + return fmt.Errorf("stoppedrules[%d]: unknown action %q", i, r.Action) + } + + if r.Source == "" && r.Dest == "" { + return fmt.Errorf("stoppedrules[%d]: source or dest required", i) + } + + if (r.Action == StoppedNoTrack || r.Action == StoppedDrop) && r.Dest != "" && r.Dest != "-" { + if r.Dest != "" && r.Dest != "-" { + srcIsIface := r.Source != "" && r.Source != "$FW" && r.Source != "-" + destIsIface := r.Dest != "$FW" + if srcIsIface && destIsIface { + return fmt.Errorf("stoppedrules[%d]: dest not allowed with %s action (except $FW)", i, r.Action) + } + } + } + } + return nil +} diff --git a/internal/config/tc.go b/internal/config/tc.go new file mode 100644 index 0000000..c9066a3 --- /dev/null +++ b/internal/config/tc.go @@ -0,0 +1,128 @@ +package config + +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 + Options TCDeviceOptions `yaml:"options,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +type TCDeviceOptions struct { + Classify bool `yaml:"classify,omitempty"` + HTB bool `yaml:"htb,omitempty"` + HFSC bool `yaml:"hfsc,omitempty"` + Linklayer string `yaml:"linklayer,omitempty"` // ethernet, atm, adsl +} + +// 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 + Priority int `yaml:"priority,omitempty"` // scheduling order + Options TCClassOptions `yaml:"options,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +type TCClassOptions struct { + Default bool `yaml:"default,omitempty"` // default class for unclassified traffic + TCPAck bool `yaml:"tcp_ack,omitempty"` + Pfifo bool `yaml:"pfifo,omitempty"` +} + +// TCFilter classifies packets into traffic classes. +type TCFilter struct { + Class string `yaml:"class"` // interface:class + Source string `yaml:"source,omitempty"` + Dest string `yaml:"dest,omitempty"` + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + TOS string `yaml:"tos,omitempty"` + Length int `yaml:"length,omitempty"` + Priority int `yaml:"priority,omitempty"` // filter eval order + Comment string `yaml:"comment,omitempty"` +} + +// TCInterface defines simple traffic shaping (3-band priority queueing). +type TCInterface struct { + Interface string `yaml:"interface"` + Type string `yaml:"type,omitempty"` // external, internal + InBandwidth string `yaml:"in_bandwidth,omitempty"` + OutBandwidth string `yaml:"out_bandwidth,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +// TCPriority assigns packets to priority bands (1=high, 2=medium, 3=low). +type TCPriority struct { + Band int `yaml:"band"` // 1, 2, or 3 + Proto string `yaml:"proto,omitempty"` + DPort PortSpec `yaml:"dport,omitempty"` + SPort PortSpec `yaml:"sport,omitempty"` + Address string `yaml:"address,omitempty"` + Interface string `yaml:"interface,omitempty"` + Helper string `yaml:"helper,omitempty"` + Comment string `yaml:"comment,omitempty"` +} + +func (c *Config) validateTCDevices() error { + for i, dev := range c.TCDevices { + if dev.Interface == "" { + return fmt.Errorf("tcdevices[%d]: interface required", i) + } + if dev.OutBandwidth == "" { + return fmt.Errorf("tcdevices[%d]: out_bandwidth required", i) + } + } + return nil +} + +func (c *Config) validateTCClasses() error { + for i, cls := range c.TCClasses { + if cls.Interface == "" { + return fmt.Errorf("tcclasses[%d]: interface required", i) + } + if cls.Rate == "" { + return fmt.Errorf("tcclasses[%d]: rate required", i) + } + if cls.Mark != 0 && (cls.Mark < 1 || cls.Mark > 255) { + return fmt.Errorf("tcclasses[%d]: mark must be 1-255", i) + } + } + return nil +} + +func (c *Config) validateTCFilters() error { + for i, f := range c.TCFilters { + if f.Class == "" { + return fmt.Errorf("tcfilters[%d]: class required", i) + } + if f.Source == "" && f.Dest == "" { + return fmt.Errorf("tcfilters[%d]: source or dest required", i) + } + } + return nil +} + +func (c *Config) validateTCInterfaces() error { + for i, iface := range c.TCInterfaces { + if iface.Interface == "" { + return fmt.Errorf("tcinterfaces[%d]: interface required", i) + } + } + return nil +} + +func (c *Config) validateTCPriority() error { + for i, p := range c.TCPriorities { + if p.Band < 1 || p.Band > 3 { + return fmt.Errorf("tcpriority[%d]: band must be 1, 2, or 3", i) + } + } + return nil +} diff --git a/internal/config/tunnels.go b/internal/config/tunnels.go new file mode 100644 index 0000000..6f73d04 --- /dev/null +++ b/internal/config/tunnels.go @@ -0,0 +1,89 @@ +package config + +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" + TunnelOpenVPNClient TunnelType = "openvpnclient" + TunnelOpenVPNServer TunnelType = "openvpnserver" + TunnelTinc TunnelType = "tinc" + Tunnel6to4 TunnelType = "6to4" + TunnelGeneric TunnelType = "generic" +) + +// Tunnel defines VPN tunnel rules that allow encapsulated traffic to pass +// between the firewall and remote gateways. The actual traffic flowing +// through the tunnel is handled by normal zone/policy/rules. +type Tunnel struct { + // Tunnel type. For ipsec, append ":ah" to use Authentication Headers (default: no AH). + // For openvpn variants, append ":tcp" or ":udp" (default: udp). + // For generic, append ":protocol" or ":protocol:port". + Type string `yaml:"type"` + + // Zone of the physical interface through which tunnel traffic passes. + Zone string `yaml:"zone"` + + // Remote tunnel gateway address(es). Use 0.0.0.0/0 or ::/0 for road warriors. + Gateways []string `yaml:"gateways"` + + // Zones that the remote gateway host belongs to (for IPSEC ISAKMP traffic). + GatewayZones []string `yaml:"gateway_zones,omitempty"` + + // Port override for openvpn/generic types (default: type-specific). + Port int `yaml:"port,omitempty"` + + Comment string `yaml:"comment,omitempty"` +} + +var validTunnelTypes = map[TunnelType]bool{ + TunnelIPSec: true, TunnelIPSecNAT: true, + TunnelIPIP: true, TunnelGRE: true, TunnelL2TP: true, + TunnelPPTPClient: true, TunnelPPTPServer: true, + TunnelOpenVPN: true, TunnelOpenVPNClient: true, TunnelOpenVPNServer: true, + TunnelTinc: true, Tunnel6to4: true, TunnelGeneric: true, +} + +func ParseTunnelType(s string) (TunnelType, string, bool) { + for i, c := range s { + if c == ':' { + return TunnelType(s[:i]), s[i+1:], true + } + } + return TunnelType(s), "", false +} + +func (c *Config) validateTunnels() error { + for i, t := range c.Tunnels { + baseType, _, _ := ParseTunnelType(t.Type) + if !validTunnelTypes[baseType] { + return fmt.Errorf("tunnels[%d]: unknown tunnel type %q", i, baseType) + } + + if t.Zone == "" { + return fmt.Errorf("tunnels[%d]: zone required", i) + } + if _, ok := c.Zones[t.Zone]; !ok { + return fmt.Errorf("tunnels[%d]: zone %q not defined", i, t.Zone) + } + + if len(t.Gateways) == 0 { + return fmt.Errorf("tunnels[%d]: at least one gateway required", i) + } + + for _, gz := range t.GatewayZones { + if _, ok := c.Zones[gz]; !ok { + return fmt.Errorf("tunnels[%d]: gateway zone %q not defined", i, gz) + } + } + } + return nil +} diff --git a/internal/config/zones.go b/internal/config/zones.go new file mode 100644 index 0000000..b4b87b9 --- /dev/null +++ b/internal/config/zones.go @@ -0,0 +1,88 @@ +package config + +import ( + "fmt" + "unicode" +) + +type ZoneType string + +const ( + ZoneIP ZoneType = "ip" + ZoneIPSec ZoneType = "ipsec" + ZoneFirewall ZoneType = "firewall" + ZoneBPort ZoneType = "bport" + ZoneLoopback ZoneType = "loopback" + ZoneLocal ZoneType = "local" +) + +type Zone struct { + Type ZoneType `yaml:"type"` + Parents []string `yaml:"parents,omitempty"` + Options []string `yaml:"options,omitempty"` + InOptions []string `yaml:"in_options,omitempty"` + OutOptions []string `yaml:"out_options,omitempty"` +} + +var reservedZoneNames = map[string]bool{ + "all": true, "none": true, "any": true, + "SOURCE": true, "DEST": true, +} + +func (c *Config) validateZones() error { + if len(c.Zones) == 0 { + return fmt.Errorf("no zones defined") + } + + firewallCount := 0 + for name, z := range c.Zones { + if err := validateZoneName(name); err != nil { + return fmt.Errorf("zone %q: %w", name, err) + } + + switch z.Type { + case ZoneIP, ZoneIPSec, ZoneFirewall, ZoneBPort, ZoneLoopback, ZoneLocal: + case "": + return fmt.Errorf("zone %q: type required", name) + default: + return fmt.Errorf("zone %q: unknown type %q", name, z.Type) + } + + if z.Type == ZoneFirewall { + firewallCount++ + if len(z.Options) > 0 || len(z.InOptions) > 0 || len(z.OutOptions) > 0 { + return fmt.Errorf("zone %q: firewall zone does not accept options", name) + } + } + + for _, parent := range z.Parents { + if _, ok := c.Zones[parent]; !ok { + return fmt.Errorf("zone %q: parent zone %q not defined", name, parent) + } + } + } + + if firewallCount != 1 { + return fmt.Errorf("exactly one firewall zone required, found %d", firewallCount) + } + + return nil +} + +func validateZoneName(name string) error { + if reservedZoneNames[name] { + return fmt.Errorf("reserved name") + } + if len(name) == 0 { + return fmt.Errorf("empty name") + } + if !unicode.IsLetter(rune(name[0])) { + return fmt.Errorf("must start with a letter") + } + for _, r := range name { + if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' { + return fmt.Errorf("invalid character %q", r) + } + } + return nil +} diff --git a/internal/nftables/cleanup.go b/internal/nftables/cleanup.go new file mode 100644 index 0000000..69c31fa --- /dev/null +++ b/internal/nftables/cleanup.go @@ -0,0 +1,95 @@ +package nftables + +import ( + "fmt" + + "github.com/google/nftables" +) + +type ForeignRule struct { + Table string + Chain string + Handle uint64 + Family nftables.TableFamily + + table *nftables.Table + chain *nftables.Chain +} + +func (f ForeignRule) String() string { + return fmt.Sprintf("table=%s chain=%s handle=%d", f.Table, f.Chain, f.Handle) +} + +func (e *Engine) FindForeignRules() ([]ForeignRule, error) { + tables, err := e.conn.ListTables() + if err != nil { + return nil, fmt.Errorf("listing tables: %w", err) + } + + var ourTable *nftables.Table + for _, t := range tables { + if t.Name == e.cfg.Settings.TableName && t.Family == nftables.TableFamilyINet { + ourTable = t + break + } + } + if ourTable == nil { + return nil, nil + } + + compiler := NewCompiler(e.cfg) + desired, err := compiler.Compile() + if err != nil { + return nil, fmt.Errorf("compiling config: %w", err) + } + + desiredTags := make(map[string]bool) + for _, rules := range desired.Rules { + for _, r := range rules { + desiredTags[r.Tag] = true + } + } + + var foreign []ForeignRule + + chains, err := e.conn.ListChainsOfTableFamily(nftables.TableFamilyINet) + if err != nil { + return nil, fmt.Errorf("listing chains: %w", err) + } + + for _, chain := range chains { + if chain.Table.Name != e.cfg.Settings.TableName { + continue + } + rules, err := e.conn.GetRules(ourTable, chain) + if err != nil { + continue + } + for _, rule := range rules { + tag := string(rule.UserData) + if tag == "" || !desiredTags[tag] { + foreign = append(foreign, ForeignRule{ + Table: ourTable.Name, + Chain: chain.Name, + Handle: rule.Handle, + Family: ourTable.Family, + table: ourTable, + chain: chain, + }) + } + } + } + + return foreign, nil +} + +func (e *Engine) PurgeForeignRules(foreign []ForeignRule) error { + for _, f := range foreign { + e.conn.DelRule(&nftables.Rule{ + Table: f.table, + Chain: f.chain, + Handle: f.Handle, + }) + } + return e.conn.Flush() +} diff --git a/internal/nftables/compiler.go b/internal/nftables/compiler.go new file mode 100644 index 0000000..45e6549 --- /dev/null +++ b/internal/nftables/compiler.go @@ -0,0 +1,1687 @@ +package nftables + +import ( + "encoding/binary" + "fmt" + "net" + "strconv" + "strings" + + "github.com/google/nftables/expr" + "golang.org/x/sys/unix" + + "git.unkin.net/unkin/tomswall/internal/config" +) + +type Compiler struct { + cfg *config.Config +} + +func NewCompiler(cfg *config.Config) *Compiler { + return &Compiler{cfg: cfg} +} + +func (c *Compiler) Compile() (*FirewallState, error) { + state := &FirewallState{ + Rules: make(map[string][]ManagedRule), + } + + c.compileLoopback(state) + if err := c.compileConntrackFastPath(state); err != nil { + return nil, fmt.Errorf("conntrack fast-path: %w", err) + } + c.compileAntiSpoof(state) + c.compileDHCP(state) + if err := c.compileIntraZone(state); err != nil { + return nil, fmt.Errorf("intra-zone: %w", err) + } + if err := c.compileBlrules(state); err != nil { + return nil, fmt.Errorf("blrules: %w", err) + } + if err := c.compileConntrack(state); err != nil { + return nil, fmt.Errorf("conntrack: %w", err) + } + if err := c.compileTunnels(state); err != nil { + return nil, fmt.Errorf("tunnels: %w", err) + } + if err := c.compileRules(state); err != nil { + return nil, fmt.Errorf("rules: %w", err) + } + if err := c.compilePolicies(state); err != nil { + return nil, fmt.Errorf("policies: %w", err) + } + if err := c.compileSNAT(state); err != nil { + return nil, fmt.Errorf("snat: %w", err) + } + if err := c.compileDNAT(state); err != nil { + return nil, fmt.Errorf("dnat: %w", err) + } + if err := c.compileStaticNAT(state); err != nil { + return nil, fmt.Errorf("static-nat: %w", err) + } + c.compileMSSClamp(state) + + return state, nil +} + +func (c *Compiler) compileConntrackFastPath(state *FirewallState) error { + for _, chain := range []string{"input", "forward", "output"} { + state.Rules[chain] = append(state.Rules[chain], + ManagedRule{ + Chain: chain, + Exprs: append(matchCtState(ctStateEstablished|ctStateRelated), + &expr.Verdict{Kind: expr.VerdictAccept}), + Tag: "ct:fastpath:" + chain, + }, + ManagedRule{ + Chain: chain, + Exprs: append(matchCtState(ctStateInvalid), + &expr.Verdict{Kind: expr.VerdictDrop}), + Tag: "ct:invalid:" + chain, + }, + ) + } + return nil +} + +func (c *Compiler) compileLoopback(state *FirewallState) { + for _, chain := range []string{"input", "output"} { + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: append(matchIfaceName(chain == "input", "lo"), + &expr.Verdict{Kind: expr.VerdictAccept}), + Tag: "loopback:" + chain, + }) + } +} + +func (c *Compiler) compileAntiSpoof(state *FirewallState) { + for _, iface := range c.cfg.Interfaces { + if iface.Options.NoSmurfs { + state.Rules["input"] = append(state.Rules["input"], ManagedRule{ + Chain: "input", + Exprs: matchSmurfDrop(iface.PhysicalName()), + Tag: fmt.Sprintf("antismurf:%s", iface.Interface), + }) + } + + if iface.Options.TCPFlags != nil && *iface.Options.TCPFlags { + state.Rules["input"] = append(state.Rules["input"], ManagedRule{ + Chain: "input", + Exprs: matchTCPFlagsDrop(iface.PhysicalName()), + Tag: fmt.Sprintf("tcpflags:%s", iface.Interface), + }) + } + } +} + +func (c *Compiler) compileDHCP(state *FirewallState) { + for _, iface := range c.cfg.Interfaces { + if !iface.Options.DHCP { + continue + } + name := iface.PhysicalName() + // Allow DHCPv4 client traffic (bootpc:68 → bootps:67) + state.Rules["input"] = append(state.Rules["input"], ManagedRule{ + Chain: "input", + Exprs: append(append(append( + matchIfaceName(true, name), + matchProtoNum(unix.IPPROTO_UDP)...), + matchSPort(68)...), + matchDPort(67)..., + ), + Tag: fmt.Sprintf("dhcp:in:%s", iface.Interface), + }) + // Allow DHCPv4 server → client replies + state.Rules["input"] = append(state.Rules["input"], ManagedRule{ + Chain: "input", + Exprs: append(append(append(append( + matchIfaceName(true, name), + matchProtoNum(unix.IPPROTO_UDP)...), + matchSPort(67)...), + matchDPort(68)...), + &expr.Verdict{Kind: expr.VerdictAccept}, + ), + Tag: fmt.Sprintf("dhcp:reply:%s", iface.Interface), + }) + state.Rules["output"] = append(state.Rules["output"], ManagedRule{ + Chain: "output", + Exprs: append(append(append(append( + matchIfaceName(false, name), + matchProtoNum(unix.IPPROTO_UDP)...), + matchSPort(68)...), + matchDPort(67)...), + &expr.Verdict{Kind: expr.VerdictAccept}, + ), + Tag: fmt.Sprintf("dhcp:out:%s", iface.Interface), + }) + } +} + +func (c *Compiler) compileIntraZone(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() + + for _, iface := range c.cfg.Interfaces { + if iface.Options.RouteBack != nil && *iface.Options.RouteBack { + chain := "forward" + if iface.Zone == fwZone { + continue + } + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: append( + append(matchIfaceName(true, iface.PhysicalName()), + matchIfaceName(false, iface.PhysicalName())...), + &expr.Verdict{Kind: expr.VerdictAccept}, + ), + Tag: fmt.Sprintf("intra:%s:%s", iface.Zone, iface.Interface), + }) + } + } + return nil +} + +func (c *Compiler) compileBlrules(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() + + blruleToRuleAction := map[config.BlruleAction]config.RuleAction{ + config.BlruleAccept: config.RuleAccept, + config.BlruleWhitelist: config.RuleAccept, + config.BlruleDrop: config.RuleDrop, + config.BlruleReject: config.RuleReject, + config.BlruleLog: config.RuleLog, + config.BlruleContinue: config.RuleContinue, + } + + for i, rule := range c.cfg.Blrules { + tag := fmt.Sprintf("blrule:%d", i) + action, ok := blruleToRuleAction[rule.Action] + if !ok { + action = config.RuleDrop + } + if err := c.compileOneRule(state, tag, rule.Source, rule.Dest, + rule.Proto, rule.DPort, rule.SPort, + action, rule.Log, "", fwZone, ""); err != nil { + return fmt.Errorf("blrule[%d]: %w", i, err) + } + } + return nil +} + +func (c *Compiler) compileConntrack(state *FirewallState) error { + for i, ct := range c.cfg.Conntrack { + tag := fmt.Sprintf("conntrack:%d", i) + + chains := []string{"prerouting"} + switch ct.Chain { + case config.ConntrackOutput: + chains = []string{"output"} + case config.ConntrackBoth: + chains = []string{"prerouting", "output"} + } + + for _, chain := range chains { + var exprs []expr.Any + + if ct.Proto != "" { + exprs = append(exprs, matchProto(ct.Proto)...) + } + for _, p := range ct.DPort { + pe, err := parsePortOrRange(p) + if err != nil { + return fmt.Errorf("conntrack[%d]: %w", i, err) + } + exprs = append(exprs, pe...) + } + + switch ct.Action { + case config.ConntrackNoTrack: + exprs = append(exprs, &expr.Notrack{}) + case config.ConntrackHelper: + continue + case config.ConntrackDrop: + exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictDrop}) + } + + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: exprs, + Tag: tag + ":" + chain, + }) + } + } + return nil +} + +func (c *Compiler) compileRules(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() + + for i, rule := range c.cfg.Rules { + tag := fmt.Sprintf("rule:%d", i) + + proto := rule.Proto + var dports config.PortSpec + var sport config.PortSpec + if rule.PortGroup != "" { + pg, _ := c.cfg.ResolvePortGroup(rule.PortGroup) + proto = pg.Proto + dports = pg.Ports + } else { + dports = rule.DPort + sport = rule.SPort + } + + if err := c.compileOneRule(state, tag, rule.Source, rule.Dest, + proto, dports, sport, + rule.Action, rule.Log, rule.Dest, fwZone, rule.Section); err != nil { + return fmt.Errorf("rule[%d]: %w", i, err) + } + + if rule.RateLimit != "" || rule.User != "" || rule.Mark != "" || + rule.SetMark != "" || rule.ConnLimit != "" || rule.Time != nil || + rule.Action == config.RuleMark || rule.Action == config.RuleConnMark || + rule.Action == config.RuleNFQueue { + c.applyRuleExtras(state, tag, rule) + } + } + + return nil +} + +func (c *Compiler) applyRuleExtras(state *FirewallState, tag string, rule config.Rule) { + fwZone := c.cfg.FirewallZone() + srcZone, _ := splitZoneSpec(rule.Source) + dstZone, _ := splitZoneSpec(rule.Dest) + chain := c.selectChain(srcZone, dstZone, fwZone) + + rules := state.Rules[chain] + for idx := len(rules) - 1; idx >= 0; idx-- { + if rules[idx].Tag != tag { + break + } + + var extra []expr.Any + var replaceVerdict []expr.Any + + if rule.User != "" { + extra = append(extra, matchUID(rule.User)...) + } + if rule.Mark != "" { + extra = append(extra, matchMark(rule.Mark)...) + } + if rule.RateLimit != "" { + extra = append(extra, parseRateLimit(rule.RateLimit)...) + } + if rule.ConnLimit != "" { + extra = append(extra, matchConnLimit(rule.ConnLimit)...) + } + if rule.Time != nil { + extra = append(extra, matchTime(rule.Time)...) + } + if rule.SetMark != "" { + extra = append(extra, setMarkExprs(rule.SetMark)...) + } + if rule.Action == config.RuleNFQueue { + replaceVerdict = []expr.Any{&expr.Queue{Num: uint16(rule.NFQueue)}} + } + + if len(extra) > 0 || len(replaceVerdict) > 0 { + existingExprs := rules[idx].Exprs + var verdict []expr.Any + var nonVerdict []expr.Any + for _, e := range existingExprs { + if _, ok := e.(*expr.Verdict); ok { + verdict = append(verdict, e) + } else { + nonVerdict = append(nonVerdict, e) + } + } + if len(replaceVerdict) > 0 { + verdict = replaceVerdict + } + rules[idx].Exprs = append(append(nonVerdict, extra...), verdict...) + } + } + state.Rules[chain] = rules +} + +func (c *Compiler) compileOneRule(state *FirewallState, tag, srcSpec, dstSpec, proto string, + dports, sports config.PortSpec, action config.RuleAction, logLevel string, + dnatDest string, fwZone string, section config.RuleSection) error { + + srcZone, srcAddr := splitZoneSpec(srcSpec) + dstZone, dstAddr := splitZoneSpec(dstSpec) + + if action == config.RuleDNAT || action == config.RuleRedirect { + return c.compileDNATRule(state, tag, srcSpec, dstSpec, proto, dports, sports, action, logLevel, fwZone) + } + + srcIfaces := c.resolveZoneInterfaces(srcZone) + dstIfaces := c.resolveZoneInterfaces(dstZone) + chain := c.selectChain(srcZone, dstZone, fwZone) + + for _, srcIface := range srcIfaces { + for _, dstIface := range dstIfaces { + exprs, err := c.buildMatchExprs(srcIface, dstIface, chain, proto, dports, sports, srcAddr, dstAddr) + if err != nil { + return err + } + + if section != "" && section != config.SectionAll { + exprs = append(exprs, matchSection(section)...) + } + + if logLevel != "" { + exprs = append(exprs, buildLog(logLevel, tag)...) + } + + verdict := actionVerdict(action, proto, c.cfg.Settings.AddressFamily) + if verdict != nil { + exprs = append(exprs, verdict...) + } + + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: exprs, + Tag: tag, + }) + } + } + return nil +} + +func (c *Compiler) compileDNATRule(state *FirewallState, tag, srcSpec, dstSpec, proto string, + dports, sports config.PortSpec, action config.RuleAction, logLevel, fwZone string) error { + + srcZone, srcAddr := splitZoneSpec(srcSpec) + chain := "prerouting" + + parts := strings.SplitN(dstSpec, ":", 3) + if len(parts) < 2 { + return fmt.Errorf("DNAT dest must be zone:address or zone:address:port") + } + + dnatAddr := parts[1] + var dnatPort uint16 + if len(parts) == 3 { + p, err := strconv.ParseUint(parts[2], 10, 16) + if err != nil { + return fmt.Errorf("invalid DNAT port %q: %w", parts[2], err) + } + dnatPort = uint16(p) + } + + srcIfaces := c.resolveZoneInterfaces(srcZone) + + for _, srcIface := range srcIfaces { + var exprs []expr.Any + + if srcIface != "" { + exprs = append(exprs, matchIfaceName(true, srcIface)...) + } + + if srcAddr != "" { + src, err := matchSourceCIDR(srcAddr) + if err != nil { + return err + } + exprs = append(exprs, src...) + } + + if proto != "" { + exprs = append(exprs, matchProto(proto)...) + } + + for _, portStr := range dports { + pe, err := parsePortOrRange(portStr) + if err != nil { + return err + } + exprs = append(exprs, pe...) + } + + if logLevel != "" { + exprs = append(exprs, buildLog(logLevel, tag)...) + } + + ip := net.ParseIP(dnatAddr) + if ip == nil { + return fmt.Errorf("invalid DNAT address %q", dnatAddr) + } + + if action == config.RuleRedirect { + if dnatPort > 0 { + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, dnatPort) + exprs = append(exprs, + &expr.Immediate{Register: 1, Data: portBytes}, + &expr.Redir{RegisterProtoMin: 1}, + ) + } else { + exprs = append(exprs, &expr.Redir{}) + } + } else { + if ip4 := ip.To4(); ip4 != nil { + exprs = append(exprs, + &expr.Immediate{Register: 1, Data: ip4}, + ) + natExpr := &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: unix.NFPROTO_IPV4, + RegAddrMin: 1, + RegAddrMax: 1, + } + if dnatPort > 0 { + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, dnatPort) + exprs = append(exprs, + &expr.Immediate{Register: 2, Data: portBytes}, + ) + natExpr.RegProtoMin = 2 + natExpr.RegProtoMax = 2 + } + exprs = append(exprs, natExpr) + } else { + exprs = append(exprs, + &expr.Immediate{Register: 1, Data: ip.To16()}, + ) + natExpr := &expr.NAT{ + Type: expr.NATTypeDestNAT, + Family: unix.NFPROTO_IPV6, + RegAddrMin: 1, + RegAddrMax: 1, + } + if dnatPort > 0 { + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, dnatPort) + exprs = append(exprs, + &expr.Immediate{Register: 2, Data: portBytes}, + ) + natExpr.RegProtoMin = 2 + natExpr.RegProtoMax = 2 + } + exprs = append(exprs, natExpr) + } + } + + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: exprs, + Tag: tag, + }) + } + return nil +} + +func (c *Compiler) compilePolicies(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() + + for i, pol := range c.cfg.Policy { + tag := fmt.Sprintf("policy:%d", i) + + srcZones := c.expandZoneRef(pol.Source) + dstZones := c.expandZoneRef(pol.Dest) + + for _, sz := range srcZones { + for _, dz := range dstZones { + if sz == dz && !strings.HasSuffix(pol.Source, "+") { + continue + } + + chain := c.selectChain(sz, dz, fwZone) + srcIfaces := c.resolveZoneInterfaces(sz) + dstIfaces := c.resolveZoneInterfaces(dz) + + for _, si := range srcIfaces { + for _, di := range dstIfaces { + var exprs []expr.Any + + if si != "" { + exprs = append(exprs, matchIfaceName(true, si)...) + } + if di != "" && chain == "forward" { + exprs = append(exprs, matchIfaceName(false, di)...) + } + + if pol.RateLimit != "" { + exprs = append(exprs, parseRateLimit(pol.RateLimit)...) + } + + if pol.ConnLimit != "" { + exprs = append(exprs, matchConnLimit(pol.ConnLimit)...) + } + + if pol.Log != "" { + exprs = append(exprs, buildLog(pol.Log, tag)...) + } + + exprs = append(exprs, policyVerdict(pol.Action, c.cfg.Settings.AddressFamily)...) + + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, + Exprs: exprs, + Tag: tag, + }) + } + } + } + } + } + + return nil +} + +func (c *Compiler) compileSNAT(state *FirewallState) error { + for i, snat := range c.cfg.SNAT { + tag := fmt.Sprintf("snat:%d", i) + + var exprs []expr.Any + + destIface, _ := splitZoneSpec(snat.Dest) + exprs = append(exprs, matchIfaceName(false, destIface)...) + + if snat.Source != "" { + srcExprs, err := matchSourceCIDR(snat.Source) + if err != nil { + return fmt.Errorf("snat[%d]: %w", i, err) + } + exprs = append(exprs, srcExprs...) + } + + if snat.Proto != "" { + exprs = append(exprs, matchProto(snat.Proto)...) + } + + for _, portStr := range snat.DPort { + pe, err := parsePortOrRange(portStr) + if err != nil { + return fmt.Errorf("snat[%d] dport: %w", i, err) + } + exprs = append(exprs, pe...) + } + + for _, portStr := range snat.SPort { + pe, err := parseSPortOrRange(portStr) + if err != nil { + return fmt.Errorf("snat[%d] sport: %w", i, err) + } + exprs = append(exprs, pe...) + } + + if snat.Mark != "" { + exprs = append(exprs, matchMark(snat.Mark)...) + } + + if snat.Log != "" { + exprs = append(exprs, buildLog(snat.Log, tag)...) + } + + switch snat.Action { + case config.SNATMasquerade: + masq := &expr.Masq{} + if snat.Random { + masq.Random = true + } + exprs = append(exprs, masq) + case config.SNATAddress: + ip := net.ParseIP(snat.Address) + if ip == nil { + return fmt.Errorf("snat[%d]: invalid address %q", i, snat.Address) + } + ip4 := ip.To4() + if ip4 != nil { + exprs = append(exprs, + &expr.Immediate{Register: 1, Data: ip4}, + &expr.NAT{ + Type: expr.NATTypeSourceNAT, + Family: unix.NFPROTO_IPV4, + RegAddrMin: 1, + RegAddrMax: 1, + }, + ) + } else { + exprs = append(exprs, + &expr.Immediate{Register: 1, Data: ip.To16()}, + &expr.NAT{ + Type: expr.NATTypeSourceNAT, + Family: unix.NFPROTO_IPV6, + RegAddrMin: 1, + RegAddrMax: 1, + }, + ) + } + } + + state.Rules["postrouting"] = append(state.Rules["postrouting"], ManagedRule{ + Chain: "postrouting", + Exprs: exprs, + Tag: tag, + }) + } + + return nil +} + +func (c *Compiler) compileDNAT(state *FirewallState) error { + return nil +} + +func (c *Compiler) compileTunnels(state *FirewallState) error { + fwZone := c.cfg.FirewallZone() + + for i, tun := range c.cfg.Tunnels { + baseType, extra, _ := config.ParseTunnelType(tun.Type) + tag := fmt.Sprintf("tunnel:%d", i) + + inChain := c.selectChain(tun.Zone, fwZone, fwZone) + outChain := c.selectChain(fwZone, tun.Zone, fwZone) + + for _, gw := range tun.Gateways { + var srcMatch, dstMatch []expr.Any + if gw != "0.0.0.0/0" && gw != "::/0" { + var err error + srcMatch, err = matchSourceCIDR(gw) + if err != nil { + return fmt.Errorf("tunnel[%d]: %w", i, err) + } + dstMatch, err = matchDestCIDR(gw) + if err != nil { + return fmt.Errorf("tunnel[%d]: %w", i, err) + } + } + + addTunnelRule := func(chain string, proto byte, dport uint16, srcExprs []expr.Any) { + var exprs []expr.Any + exprs = append(exprs, srcExprs...) + exprs = append(exprs, matchProtoNum(proto)...) + if dport > 0 { + exprs = append(exprs, matchDPort(dport)...) + } + exprs = append(exprs, &expr.Verdict{Kind: expr.VerdictAccept}) + state.Rules[chain] = append(state.Rules[chain], ManagedRule{ + Chain: chain, Exprs: exprs, Tag: tag, + }) + } + + switch baseType { + case config.TunnelIPSec, config.TunnelIPSecNAT: + addTunnelRule(inChain, 50, 0, srcMatch) + addTunnelRule(outChain, 50, 0, dstMatch) + if extra != "ah" { + addTunnelRule(inChain, 51, 0, srcMatch) + addTunnelRule(outChain, 51, 0, dstMatch) + } + addTunnelRule(inChain, unix.IPPROTO_UDP, 500, srcMatch) + addTunnelRule(outChain, unix.IPPROTO_UDP, 500, dstMatch) + if baseType == config.TunnelIPSecNAT { + addTunnelRule(inChain, unix.IPPROTO_UDP, 4500, srcMatch) + addTunnelRule(outChain, unix.IPPROTO_UDP, 4500, dstMatch) + } + + case config.TunnelIPIP, config.Tunnel6to4: + addTunnelRule(inChain, 4, 0, srcMatch) + addTunnelRule(outChain, 4, 0, dstMatch) + + case config.TunnelGRE: + addTunnelRule(inChain, 47, 0, srcMatch) + addTunnelRule(outChain, 47, 0, dstMatch) + + case config.TunnelOpenVPN, config.TunnelOpenVPNClient, config.TunnelOpenVPNServer: + proto := unix.IPPROTO_UDP + if extra == "tcp" { + proto = unix.IPPROTO_TCP + } + port := uint16(1194) + if tun.Port > 0 { + port = uint16(tun.Port) + } + addTunnelRule(inChain, byte(proto), port, srcMatch) + addTunnelRule(outChain, byte(proto), port, dstMatch) + + case config.TunnelL2TP: + addTunnelRule(inChain, unix.IPPROTO_UDP, 1701, srcMatch) + addTunnelRule(outChain, unix.IPPROTO_UDP, 1701, dstMatch) + + case config.TunnelTinc: + addTunnelRule(inChain, unix.IPPROTO_UDP, 655, srcMatch) + addTunnelRule(outChain, unix.IPPROTO_UDP, 655, dstMatch) + addTunnelRule(inChain, unix.IPPROTO_TCP, 655, srcMatch) + addTunnelRule(outChain, unix.IPPROTO_TCP, 655, dstMatch) + + case config.TunnelPPTPClient: + addTunnelRule(inChain, 47, 0, srcMatch) + addTunnelRule(outChain, 47, 0, dstMatch) + addTunnelRule(outChain, unix.IPPROTO_TCP, 1723, dstMatch) + + case config.TunnelPPTPServer: + addTunnelRule(inChain, 47, 0, srcMatch) + addTunnelRule(outChain, 47, 0, dstMatch) + addTunnelRule(inChain, unix.IPPROTO_TCP, 1723, srcMatch) + + case config.TunnelGeneric: + proto := unix.IPPROTO_UDP + if extra == "tcp" { + proto = unix.IPPROTO_TCP + } + port := uint16(0) + if tun.Port > 0 { + port = uint16(tun.Port) + } + addTunnelRule(inChain, byte(proto), port, srcMatch) + addTunnelRule(outChain, byte(proto), port, dstMatch) + } + } + } + return nil +} + +func (c *Compiler) compileMSSClamp(state *FirewallState) { + for _, iface := range c.cfg.Interfaces { + if iface.Options.MSS > 0 { + mssBytes := make([]byte, 2) + binary.BigEndian.PutUint16(mssBytes, uint16(iface.Options.MSS)) + var exprs []expr.Any + exprs = append(exprs, matchIfaceName(false, iface.PhysicalName())...) + exprs = append(exprs, matchProtoNum(unix.IPPROTO_TCP)...) + exprs = append(exprs, matchTCPFlags(0x02, 0x02)...) + exprs = append(exprs, + &expr.Exthdr{ + DestRegister: 1, + Type: 2, + Offset: 2, + Len: 2, + Op: 0, + }, + &expr.Cmp{Op: expr.CmpOpGt, Register: 1, Data: mssBytes}, + &expr.Immediate{Register: 1, Data: mssBytes}, + &expr.Exthdr{ + SourceRegister: 1, + Type: 2, + Offset: 2, + Len: 2, + Op: 1, + }, + ) + state.Rules["forward"] = append(state.Rules["forward"], ManagedRule{ + Chain: "forward", + Exprs: exprs, + Tag: fmt.Sprintf("mss:%s", iface.Interface), + }) + } + } +} + +func (c *Compiler) compileStaticNAT(state *FirewallState) error { + for i, sn := range c.cfg.StaticNAT { + extIP := net.ParseIP(sn.External) + intIP := net.ParseIP(sn.Internal) + if extIP == nil || intIP == nil { + return fmt.Errorf("static-nat[%d]: invalid IP", i) + } + + family := unix.NFPROTO_IPV4 + ext4 := extIP.To4() + int4 := intIP.To4() + if ext4 == nil || int4 == nil { + family = unix.NFPROTO_IPV6 + } + + dnatTag := fmt.Sprintf("staticnat:dnat:%d", i) + var dnatExprs []expr.Any + dnatExprs = append(dnatExprs, matchIfaceName(true, sn.Interface)...) + if family == unix.NFPROTO_IPV4 { + dst, _ := matchDestCIDR(sn.External) + dnatExprs = append(dnatExprs, dst...) + dnatExprs = append(dnatExprs, + &expr.Immediate{Register: 1, Data: int4}, + &expr.NAT{Type: expr.NATTypeDestNAT, Family: uint32(family), RegAddrMin: 1, RegAddrMax: 1}, + ) + } else { + dst, _ := matchDestCIDR(sn.External) + dnatExprs = append(dnatExprs, dst...) + dnatExprs = append(dnatExprs, + &expr.Immediate{Register: 1, Data: intIP.To16()}, + &expr.NAT{Type: expr.NATTypeDestNAT, Family: uint32(family), RegAddrMin: 1, RegAddrMax: 1}, + ) + } + state.Rules["prerouting"] = append(state.Rules["prerouting"], ManagedRule{ + Chain: "prerouting", Exprs: dnatExprs, Tag: dnatTag, + }) + + snatTag := fmt.Sprintf("staticnat:snat:%d", i) + var snatExprs []expr.Any + snatExprs = append(snatExprs, matchIfaceName(false, sn.Interface)...) + if family == unix.NFPROTO_IPV4 { + src, _ := matchSourceCIDR(sn.Internal) + snatExprs = append(snatExprs, src...) + snatExprs = append(snatExprs, + &expr.Immediate{Register: 1, Data: ext4}, + &expr.NAT{Type: expr.NATTypeSourceNAT, Family: uint32(family), RegAddrMin: 1, RegAddrMax: 1}, + ) + } else { + src, _ := matchSourceCIDR(sn.Internal) + snatExprs = append(snatExprs, src...) + snatExprs = append(snatExprs, + &expr.Immediate{Register: 1, Data: extIP.To16()}, + &expr.NAT{Type: expr.NATTypeSourceNAT, Family: uint32(family), RegAddrMin: 1, RegAddrMax: 1}, + ) + } + state.Rules["postrouting"] = append(state.Rules["postrouting"], ManagedRule{ + Chain: "postrouting", Exprs: snatExprs, Tag: snatTag, + }) + } + return nil +} + +func (c *Compiler) selectChain(srcZone, dstZone, fwZone string) string { + if dstZone == fwZone { + return "input" + } + if srcZone == fwZone { + return "output" + } + return "forward" +} + +func (c *Compiler) resolveZoneInterfaces(zone string) []string { + if zone == "all" || zone == "" { + return []string{""} + } + ifaces := c.cfg.ZoneInterfaces(zone) + if len(ifaces) == 0 { + return []string{""} + } + return ifaces +} + +func (c *Compiler) expandZoneRef(ref string) []string { + base := ref + var excluded map[string]bool + + if idx := strings.IndexByte(ref, '!'); idx >= 0 { + base = ref[:idx] + excluded = make(map[string]bool) + for _, z := range strings.Split(ref[idx+1:], ",") { + z = strings.TrimSpace(z) + if z != "" { + excluded[z] = true + } + } + } + + if base == "all" || base == "all+" { + var zones []string + for name := range c.cfg.Zones { + if excluded != nil && excluded[name] { + continue + } + zones = append(zones, name) + } + return zones + } + return []string{base} +} + +func (c *Compiler) buildMatchExprs(srcIface, dstIface, chain, proto string, dports, sports config.PortSpec, srcAddr, dstAddr string) ([]expr.Any, error) { + var exprs []expr.Any + + if srcIface != "" { + exprs = append(exprs, matchIfaceName(true, srcIface)...) + } + if dstIface != "" && chain == "forward" { + exprs = append(exprs, matchIfaceName(false, dstIface)...) + } + + if srcAddr != "" { + src, err := matchSourceCIDR(srcAddr) + if err != nil { + return nil, err + } + exprs = append(exprs, src...) + } + + if dstAddr != "" { + dst, err := matchDestCIDR(dstAddr) + if err != nil { + return nil, err + } + exprs = append(exprs, dst...) + } + + if proto != "" { + exprs = append(exprs, matchProto(proto)...) + } + + isICMP := strings.EqualFold(proto, "icmp") || strings.EqualFold(proto, "icmpv6") || strings.EqualFold(proto, "ipv6-icmp") + + for _, portStr := range dports { + if isICMP { + pe := matchICMPType(portStr) + exprs = append(exprs, pe...) + } else { + pe, err := parsePortOrRange(portStr) + if err != nil { + return nil, err + } + exprs = append(exprs, pe...) + } + } + + for _, portStr := range sports { + pe, err := parseSPortOrRange(portStr) + if err != nil { + return nil, err + } + exprs = append(exprs, pe...) + } + + return exprs, nil +} + +// matchIfaceName matches an interface name, supporting wildcard "+" suffix. +func matchIfaceName(input bool, name string) []expr.Any { + key := expr.MetaKeyOIFNAME + if input { + key = expr.MetaKeyIIFNAME + } + + if strings.HasSuffix(name, "+") { + prefix := strings.TrimSuffix(name, "+") + padded := make([]byte, len(prefix)) + copy(padded, prefix) + return []expr.Any{ + &expr.Meta{Key: key, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: padded}, + } + } + + padded := make([]byte, 16) + copy(padded, name+"\x00") + return []expr.Any{ + &expr.Meta{Key: key, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: padded[:len(name)+1]}, + } +} + +func matchProto(proto string) []expr.Any { + var protoNum byte + switch strings.ToLower(proto) { + case "tcp": + protoNum = unix.IPPROTO_TCP + case "udp": + protoNum = unix.IPPROTO_UDP + case "icmp": + protoNum = unix.IPPROTO_ICMP + case "icmpv6", "ipv6-icmp": + protoNum = unix.IPPROTO_ICMPV6 + case "gre": + protoNum = 47 + case "esp": + protoNum = 50 + case "ah": + protoNum = 51 + case "sctp": + protoNum = unix.IPPROTO_SCTP + default: + n, _ := strconv.Atoi(proto) + protoNum = byte(n) + } + return []expr.Any{ + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{protoNum}}, + } +} + +func matchDPort(port uint16) []expr.Any { + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, port) + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: portBytes}, + } +} + +func matchDPortRange(low, high uint16) []expr.Any { + lowBytes := make([]byte, 2) + highBytes := make([]byte, 2) + binary.BigEndian.PutUint16(lowBytes, low) + binary.BigEndian.PutUint16(highBytes, high) + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2}, + &expr.Cmp{Op: expr.CmpOpGte, Register: 1, Data: lowBytes}, + &expr.Cmp{Op: expr.CmpOpLte, Register: 1, Data: highBytes}, + } +} + +func matchSPort(port uint16) []expr.Any { + portBytes := make([]byte, 2) + binary.BigEndian.PutUint16(portBytes, port) + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 2}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: portBytes}, + } +} + +func matchSPortRange(low, high uint16) []expr.Any { + lowBytes := make([]byte, 2) + highBytes := make([]byte, 2) + binary.BigEndian.PutUint16(lowBytes, low) + binary.BigEndian.PutUint16(highBytes, high) + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 2}, + &expr.Cmp{Op: expr.CmpOpGte, Register: 1, Data: lowBytes}, + &expr.Cmp{Op: expr.CmpOpLte, Register: 1, Data: highBytes}, + } +} + +func matchProtoNum(proto byte) []expr.Any { + return []expr.Any{ + &expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{proto}}, + } +} + +func matchTCPFlags(flags, mask byte) []expr.Any { + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 13, Len: 1}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 1, + Mask: []byte{mask}, + Xor: []byte{0}, + }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{flags}}, + } +} + +func matchSmurfDrop(iface string) []expr.Any { + var exprs []expr.Any + exprs = append(exprs, matchIfaceName(true, iface)...) + exprs = append(exprs, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: []byte{0xf0, 0, 0, 0}, + Xor: []byte{0, 0, 0, 0}, + }, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{0xe0, 0, 0, 0}}, + &expr.Verdict{Kind: expr.VerdictDrop}, + ) + return exprs +} + +func matchTCPFlagsDrop(iface string) []expr.Any { + var exprs []expr.Any + exprs = append(exprs, matchIfaceName(true, iface)...) + exprs = append(exprs, matchProtoNum(unix.IPPROTO_TCP)...) + exprs = append(exprs, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 13, Len: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{0}}, + &expr.Verdict{Kind: expr.VerdictDrop}, + ) + return exprs +} + +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, +} + +func matchICMPType(spec string) []expr.Any { + if strings.Contains(spec, "/") { + parts := strings.SplitN(spec, "/", 2) + typeVal, ok := resolveICMPType(parts[0]) + if !ok { + return nil + } + code, err := strconv.ParseUint(parts[1], 10, 8) + if err != nil { + return nil + } + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{typeVal}}, + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 1, Len: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{byte(code)}}, + } + } + + typeVal, ok := resolveICMPType(spec) + if !ok { + return nil + } + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 0, Len: 1}, + &expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{typeVal}}, + } +} + +func resolveICMPType(s string) (byte, bool) { + if v, ok := icmpTypeNames[strings.ToLower(s)]; ok { + return v, true + } + n, err := strconv.ParseUint(s, 10, 8) + if err != nil { + return 0, false + } + return byte(n), true +} + +// Time matching requires NFT_META_TIME_* keys not exposed in google/nftables v0.2.0. +func matchTime(_ *config.TimeSpec) []expr.Any { + return nil +} + +func matchConnLimit(spec string) []expr.Any { + s := spec + flags := uint32(0) + if strings.HasPrefix(s, "d:") { + flags = 1 + s = s[2:] + } + + var count uint32 + if idx := strings.IndexByte(s, ':'); idx >= 0 { + c, err := strconv.ParseUint(s[:idx], 10, 32) + if err != nil { + return nil + } + count = uint32(c) + } else { + c, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return nil + } + count = uint32(c) + } + + return []expr.Any{ + &expr.Connlimit{ + Count: count, + Flags: flags, + }, + } +} + +func parsePortOrRange(s string) ([]expr.Any, error) { + if strings.Contains(s, "-") { + parts := strings.SplitN(s, "-", 2) + low, err := strconv.ParseUint(parts[0], 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid port range low %q: %w", parts[0], err) + } + high, err := strconv.ParseUint(parts[1], 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid port range high %q: %w", parts[1], err) + } + return matchDPortRange(uint16(low), uint16(high)), nil + } + p, err := parsePort(s) + if err != nil { + return nil, err + } + return matchDPort(p), nil +} + +func parseSPortOrRange(s string) ([]expr.Any, error) { + if strings.Contains(s, "-") { + parts := strings.SplitN(s, "-", 2) + low, err := strconv.ParseUint(parts[0], 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid sport range low %q: %w", parts[0], err) + } + high, err := strconv.ParseUint(parts[1], 10, 16) + if err != nil { + return nil, fmt.Errorf("invalid sport range high %q: %w", parts[1], err) + } + return matchSPortRange(uint16(low), uint16(high)), nil + } + p, err := parsePort(s) + if err != nil { + return nil, err + } + return matchSPort(p), nil +} + +func matchSourceCIDR(cidr string) ([]expr.Any, error) { + return matchAddrCIDR(cidr, true) +} + +func matchDestCIDR(cidr string) ([]expr.Any, error) { + return matchAddrCIDR(cidr, false) +} + +func matchAddrCIDR(cidr string, isSrc bool) ([]expr.Any, error) { + negated := false + if strings.HasPrefix(cidr, "!") { + negated = true + cidr = cidr[1:] + } + + cmpOp := expr.CmpOpEq + if negated { + cmpOp = expr.CmpOpNeq + } + + var offset4, offset6 uint32 + if isSrc { + offset4, offset6 = 12, 8 + } else { + offset4, offset6 = 16, 24 + } + + ip, ipNet, err := net.ParseCIDR(cidr) + if err != nil { + singleIP := net.ParseIP(cidr) + if singleIP == nil { + if isSrc { + return nil, fmt.Errorf("invalid source address %q", cidr) + } + return nil, fmt.Errorf("invalid dest address %q", cidr) + } + if ip4 := singleIP.To4(); ip4 != nil { + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset4, Len: 4}, + &expr.Cmp{Op: cmpOp, Register: 1, Data: ip4}, + }, nil + } + ip6 := singleIP.To16() + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset6, Len: 16}, + &expr.Cmp{Op: cmpOp, Register: 1, Data: ip6}, + }, nil + } + + if ip4 := ip.To4(); ip4 != nil { + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset4, Len: 4}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: ipNet.Mask, + Xor: []byte{0, 0, 0, 0}, + }, + &expr.Cmp{Op: cmpOp, Register: 1, Data: ipNet.IP.To4()}, + }, nil + } + + return []expr.Any{ + &expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: offset6, Len: 16}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 16, + Mask: ipNet.Mask, + Xor: make([]byte, 16), + }, + &expr.Cmp{Op: cmpOp, Register: 1, Data: ipNet.IP.To16()}, + }, nil +} + +const ( + ctStateInvalid = 1 + ctStateEstablished = 2 + ctStateRelated = 4 + ctStateNew = 8 + ctStateUntracked = 64 +) + +func matchCtState(stateMask uint32) []expr.Any { + stateBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(stateBytes, stateMask) + return []expr.Any{ + &expr.Ct{Key: expr.CtKeySTATE, Register: 1}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: stateBytes, + Xor: make([]byte, 4), + }, + &expr.Cmp{Op: expr.CmpOpNeq, Register: 1, Data: make([]byte, 4)}, + } +} + +func matchSection(section config.RuleSection) []expr.Any { + switch section { + case config.SectionEstablished: + return matchCtState(ctStateEstablished) + case config.SectionRelated: + return matchCtState(ctStateRelated) + case config.SectionInvalid: + return matchCtState(ctStateInvalid) + case config.SectionUntracked: + return matchCtState(ctStateUntracked) + case config.SectionNew: + return matchCtState(ctStateNew) + default: + return nil + } +} + +func parseRateLimit(spec string) []expr.Any { + s := spec + if strings.HasPrefix(s, "s:") || strings.HasPrefix(s, "d:") { + s = s[2:] + } + if idx := strings.IndexByte(s, ':'); idx > 0 { + if strings.Contains(s[:idx], "/") { + // name:rate/unit:burst → skip name + } else { + s = s[idx+1:] + } + } + + var burst uint32 + if idx := strings.LastIndexByte(s, ':'); idx > 0 { + b, err := strconv.ParseUint(s[idx+1:], 10, 32) + if err == nil { + burst = uint32(b) + s = s[:idx] + } + } + + parts := strings.SplitN(s, "/", 2) + if len(parts) != 2 { + return nil + } + + rate, err := strconv.ParseUint(parts[0], 10, 64) + if err != nil || rate == 0 { + return nil + } + + var unit expr.LimitTime + switch strings.ToLower(parts[1]) { + case "sec", "second": + unit = expr.LimitTimeSecond + case "min", "minute": + unit = expr.LimitTimeMinute + case "hour": + unit = expr.LimitTimeHour + case "day": + unit = expr.LimitTimeDay + default: + return nil + } + + if burst == 0 { + burst = 5 + } + + return []expr.Any{ + &expr.Limit{ + Type: expr.LimitTypePkts, + Rate: rate, + Unit: unit, + Burst: burst, + }, + } +} + +func matchUID(userSpec string) []expr.Any { + negated := false + s := userSpec + if strings.HasPrefix(s, "!") { + negated = true + s = s[1:] + } + if idx := strings.IndexByte(s, ':'); idx >= 0 { + s = s[:idx] + } + + uid, err := strconv.ParseUint(s, 10, 32) + if err != nil { + return nil + } + + uidBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(uidBytes, uint32(uid)) + + op := expr.CmpOpEq + if negated { + op = expr.CmpOpNeq + } + + return []expr.Any{ + &expr.Meta{Key: expr.MetaKeySKUID, Register: 1}, + &expr.Cmp{Op: op, Register: 1, Data: uidBytes}, + } +} + +func matchMark(markSpec string) []expr.Any { + negated := false + s := markSpec + if strings.HasPrefix(s, "!") { + negated = true + s = s[1:] + } + + connMark := false + if strings.HasSuffix(s, ":C") { + connMark = true + s = strings.TrimSuffix(s, ":C") + } + + var value, mask uint32 + if idx := strings.IndexByte(s, '/'); idx >= 0 { + v, err := strconv.ParseUint(s[:idx], 0, 32) + if err != nil { + return nil + } + m, err := strconv.ParseUint(s[idx+1:], 0, 32) + if err != nil { + return nil + } + value = uint32(v) + mask = uint32(m) + } else { + v, err := strconv.ParseUint(s, 0, 32) + if err != nil { + return nil + } + value = uint32(v) + mask = 0xffffffff + } + + valBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(valBytes, value) + maskBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(maskBytes, mask) + + op := expr.CmpOpEq + if negated { + op = expr.CmpOpNeq + } + + var loadExpr expr.Any + if connMark { + loadExpr = &expr.Ct{Key: expr.CtKeyMARK, Register: 1} + } else { + loadExpr = &expr.Meta{Key: expr.MetaKeyMARK, Register: 1} + } + + if mask != 0xffffffff { + return []expr.Any{ + loadExpr, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: maskBytes, + Xor: make([]byte, 4), + }, + &expr.Cmp{Op: op, Register: 1, Data: valBytes}, + } + } + return []expr.Any{ + loadExpr, + &expr.Cmp{Op: op, Register: 1, Data: valBytes}, + } +} + +func setMarkExprs(markSpec string) []expr.Any { + var value, mask uint32 + if idx := strings.IndexByte(markSpec, '/'); idx >= 0 { + v, err := strconv.ParseUint(markSpec[:idx], 0, 32) + if err != nil { + return nil + } + m, err := strconv.ParseUint(markSpec[idx+1:], 0, 32) + if err != nil { + return nil + } + value = uint32(v) + mask = uint32(m) + } else { + v, err := strconv.ParseUint(markSpec, 0, 32) + if err != nil { + return nil + } + value = uint32(v) + mask = 0xffffffff + } + + valBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(valBytes, value) + + if mask != 0xffffffff { + maskBytes := make([]byte, 4) + binary.NativeEndian.PutUint32(maskBytes, mask) + return []expr.Any{ + &expr.Meta{Key: expr.MetaKeyMARK, Register: 1}, + &expr.Bitwise{ + SourceRegister: 1, + DestRegister: 1, + Len: 4, + Mask: maskBytes, + Xor: valBytes, + }, + &expr.Meta{Key: expr.MetaKeyMARK, SourceRegister: true, Register: 1}, + } + } + return []expr.Any{ + &expr.Immediate{Register: 1, Data: valBytes}, + &expr.Meta{Key: expr.MetaKeyMARK, SourceRegister: true, Register: 1}, + } +} + +func buildLog(level, prefix string) []expr.Any { + nfLevel := logLevelToNF(level) + logPrefix := prefix + if len(logPrefix) > 63 { + logPrefix = logPrefix[:63] + } + return []expr.Any{ + &expr.Log{ + Key: 1<= 4 (iface + log + verdict)", len(r.Exprs)) + } + break + } + } + if !found { + t.Error("policy:0 not found in input chain") + } +} + +func TestCompile_ConntrackNoTrack(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Conntrack: []config.ConntrackRule{ + { + Action: config.ConntrackNoTrack, + Source: "net", + Dest: "fw", + Proto: "udp", + DPort: config.PortSpec{"53"}, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + found := false + for _, r := range state.Rules["prerouting"] { + if r.Tag == "conntrack:0:prerouting" { + found = true + break + } + } + if !found { + t.Error("no notrack rule found in prerouting chain") + } +} + +func TestLogLevelToNF(t *testing.T) { + tests := []struct { + input string + want expr.LogLevel + }{ + {"emerg", expr.LogLevelEmerg}, + {"alert", expr.LogLevelAlert}, + {"crit", expr.LogLevelCrit}, + {"err", expr.LogLevelErr}, + {"error", expr.LogLevelErr}, + {"warn", expr.LogLevelWarning}, + {"warning", expr.LogLevelWarning}, + {"notice", expr.LogLevelNotice}, + {"info", expr.LogLevelInfo}, + {"debug", expr.LogLevelDebug}, + {"unknown", expr.LogLevelWarning}, + } + + for _, tt := range tests { + got := logLevelToNF(tt.input) + if got != tt.want { + t.Errorf("logLevelToNF(%q) = %d, want %d", tt.input, got, tt.want) + } + } +} + +func TestDiffEngine_DetectsModifications(t *testing.T) { + current := &FirewallState{ + Rules: map[string][]ManagedRule{ + "input": { + {Chain: "input", Tag: "rule:0", Exprs: []expr.Any{ + &expr.Verdict{Kind: expr.VerdictAccept}, + }}, + }, + }, + } + + desired := &FirewallState{ + Rules: map[string][]ManagedRule{ + "input": { + {Chain: "input", Tag: "rule:0", Exprs: []expr.Any{ + &expr.Verdict{Kind: expr.VerdictDrop}, + }}, + }, + }, + } + + cs := computeDiff(current, desired) + if len(cs.Remove) != 1 { + t.Errorf("expected 1 removal, got %d", len(cs.Remove)) + } + if len(cs.Add) != 1 { + t.Errorf("expected 1 addition, got %d", len(cs.Add)) + } +} + +func TestDiffEngine_NoChangeWhenIdentical(t *testing.T) { + state := &FirewallState{ + Rules: map[string][]ManagedRule{ + "input": { + {Chain: "input", Tag: "rule:0", Exprs: []expr.Any{ + &expr.Verdict{Kind: expr.VerdictAccept}, + }}, + }, + }, + } + + cs := computeDiff(state, state) + if !cs.Empty() { + t.Errorf("expected empty changeset, got %d adds and %d removes", len(cs.Add), len(cs.Remove)) + } +} + +func TestCompile_SPortMatching(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"22"}, + SPort: config.PortSpec{"1024-65535"}, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + found := false + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + found = true + break + } + } + if !found { + t.Error("rule with sport not found in input chain") + } +} + +func TestCompile_PortRange(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"1024-65535"}, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + found := false + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + found = true + break + } + } + if !found { + t.Error("rule with port range not found") + } +} + +func TestCompile_LogAction(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleLog, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"22"}, + Log: "info", + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + found := false + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + found = true + break + } + } + if !found { + t.Error("log rule not found") + } +} + +func TestMatchSection(t *testing.T) { + tests := []struct { + section config.RuleSection + wantLen int + }{ + {config.SectionEstablished, 3}, + {config.SectionRelated, 3}, + {config.SectionInvalid, 3}, + {config.SectionUntracked, 3}, + {config.SectionNew, 3}, + {config.SectionAll, 0}, + {"", 0}, + } + + for _, tt := range tests { + exprs := matchSection(tt.section) + if len(exprs) != tt.wantLen { + t.Errorf("matchSection(%q) returned %d exprs, want %d", tt.section, len(exprs), tt.wantLen) + } + } +} + +func TestCompile_RuleSection(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"22"}, + Section: config.SectionEstablished, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + if len(r.Exprs) < 6 { + t.Errorf("rule with section should have >= 6 exprs (iface+proto+dport+ctstate+verdict), got %d", len(r.Exprs)) + } + return + } + } + t.Error("rule:0 not found in input chain") +} + +func TestParseRateLimit(t *testing.T) { + tests := []struct { + input string + wantLen int + }{ + {"10/sec", 1}, + {"5/min", 1}, + {"100/hour", 1}, + {"1000/day", 1}, + {"s:10/sec:20", 1}, + {"invalid", 0}, + {"", 0}, + } + + for _, tt := range tests { + exprs := parseRateLimit(tt.input) + if len(exprs) != tt.wantLen { + t.Errorf("parseRateLimit(%q) returned %d exprs, want %d", tt.input, len(exprs), tt.wantLen) + } + } +} + +func TestCompile_RateLimit(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"22"}, + RateLimit: "10/sec:5", + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + hasLimit := false + for _, e := range r.Exprs { + if _, ok := e.(*expr.Limit); ok { + hasLimit = true + } + } + if !hasLimit { + t.Error("rule with rate_limit should have Limit expression") + } + return + } + } + t.Error("rule:0 not found in input chain") +} + +func TestNegatedAddress(t *testing.T) { + exprs, err := matchSourceCIDR("!192.168.1.0/24") + if err != nil { + t.Fatalf("matchSourceCIDR(!192.168.1.0/24) error: %v", err) + } + if len(exprs) != 3 { + t.Fatalf("expected 3 exprs, got %d", len(exprs)) + } + cmp := exprs[2].(*expr.Cmp) + if cmp.Op != expr.CmpOpNeq { + t.Errorf("negated address should use CmpOpNeq, got %v", cmp.Op) + } + + exprs, err = matchDestCIDR("!10.0.0.1") + if err != nil { + t.Fatalf("matchDestCIDR(!10.0.0.1) error: %v", err) + } + if len(exprs) != 2 { + t.Fatalf("expected 2 exprs, got %d", len(exprs)) + } + cmp = exprs[1].(*expr.Cmp) + if cmp.Op != expr.CmpOpNeq { + t.Errorf("negated address should use CmpOpNeq, got %v", cmp.Op) + } +} + +func TestRejectTCPRST(t *testing.T) { + exprs := rejectExprs("tcp", config.FamilyINET) + if len(exprs) != 1 { + t.Fatalf("expected 1 expr, got %d", len(exprs)) + } + rej := exprs[0].(*expr.Reject) + if rej.Type != 1 { + t.Errorf("TCP reject should use NFT_REJECT_TCP_RST (1), got %d", rej.Type) + } + + exprs = rejectExprs("udp", config.FamilyINET) + rej = exprs[0].(*expr.Reject) + if rej.Type != 2 { + t.Errorf("non-TCP reject should use NFT_REJECT_ICMPX_UNREACH (2), got %d", rej.Type) + } +} + +func TestCompile_DHCP(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0", Options: config.InterfaceOptions{DHCP: true}}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + foundIn := false + foundOut := false + for _, r := range state.Rules["input"] { + if r.Tag == "dhcp:in:eth0" || r.Tag == "dhcp:reply:eth0" { + foundIn = true + } + } + for _, r := range state.Rules["output"] { + if r.Tag == "dhcp:out:eth0" { + foundOut = true + } + } + if !foundIn { + t.Error("no DHCP input rule found for eth0") + } + if !foundOut { + t.Error("no DHCP output rule found for eth0") + } +} + +func TestMatchUID(t *testing.T) { + exprs := matchUID("1000") + if len(exprs) != 2 { + t.Fatalf("matchUID(1000) returned %d exprs, want 2", len(exprs)) + } + cmp := exprs[1].(*expr.Cmp) + if cmp.Op != expr.CmpOpEq { + t.Error("non-negated UID should use CmpOpEq") + } + + exprs = matchUID("!0") + if len(exprs) != 2 { + t.Fatalf("matchUID(!0) returned %d exprs, want 2", len(exprs)) + } + cmp = exprs[1].(*expr.Cmp) + if cmp.Op != expr.CmpOpNeq { + t.Error("negated UID should use CmpOpNeq") + } +} + +func TestMatchMark(t *testing.T) { + exprs := matchMark("0x10/0xff") + if len(exprs) != 3 { + t.Fatalf("matchMark(0x10/0xff) returned %d exprs, want 3 (load+bitwise+cmp)", len(exprs)) + } + + exprs = matchMark("42") + if len(exprs) != 2 { + t.Fatalf("matchMark(42) returned %d exprs, want 2 (load+cmp)", len(exprs)) + } + + exprs = matchMark("!5") + cmp := exprs[1].(*expr.Cmp) + if cmp.Op != expr.CmpOpNeq { + t.Error("negated mark should use CmpOpNeq") + } +} + +func TestSetMarkExprs(t *testing.T) { + exprs := setMarkExprs("0x10") + if len(exprs) != 2 { + t.Fatalf("setMarkExprs(0x10) returned %d exprs, want 2", len(exprs)) + } + + exprs = setMarkExprs("0x10/0xff00") + if len(exprs) != 3 { + t.Fatalf("setMarkExprs(0x10/0xff00) returned %d exprs, want 3", len(exprs)) + } +} + +func TestCompile_Loopback(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + foundInput := false + foundOutput := false + for _, r := range state.Rules["input"] { + if r.Tag == "loopback:input" { + foundInput = true + } + } + for _, r := range state.Rules["output"] { + if r.Tag == "loopback:output" { + foundOutput = true + } + } + if !foundInput { + t.Error("loopback:input rule not found") + } + if !foundOutput { + t.Error("loopback:output rule not found") + } +} + +func TestCompile_AntiSpoof(t *testing.T) { + tcpflags := true + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0", Options: config.InterfaceOptions{ + NoSmurfs: true, + TCPFlags: &tcpflags, + }}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + foundSmurf := false + foundFlags := false + for _, r := range state.Rules["input"] { + if r.Tag == "antismurf:eth0" { + foundSmurf = true + } + if r.Tag == "tcpflags:eth0" { + foundFlags = true + } + } + if !foundSmurf { + t.Error("antismurf rule not found") + } + if !foundFlags { + t.Error("tcpflags rule not found") + } +} + +func TestCompile_MSSClamp(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "loc": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "loc", Interface: "eth1", Options: config.InterfaceOptions{MSS: 1400}}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + found := false + for _, r := range state.Rules["forward"] { + if r.Tag == "mss:eth1" { + found = true + } + } + if !found { + t.Error("MSS clamp rule not found in forward chain") + } +} + +func TestCompile_PolicyExclusion(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + "loc": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + {Zone: "loc", Interface: "eth1"}, + }, + Policy: []config.Policy{ + {Source: "all!net", Dest: "all", Action: config.PolicyAccept}, + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["forward"] { + if r.Tag == "policy:0" { + return + } + } + for _, r := range state.Rules["input"] { + if r.Tag == "policy:0" { + return + } + } + for _, r := range state.Rules["output"] { + if r.Tag == "policy:0" { + return + } + } + t.Error("policy:0 (all!net exclusion) not found in any chain") +} + +func TestMatchICMPType(t *testing.T) { + tests := []struct { + input string + wantLen int + }{ + {"echo-request", 2}, + {"8", 2}, + {"3/4", 4}, + {"destination-unreachable", 2}, + } + + for _, tt := range tests { + exprs := matchICMPType(tt.input) + if len(exprs) != tt.wantLen { + t.Errorf("matchICMPType(%q) returned %d exprs, want %d", tt.input, len(exprs), tt.wantLen) + } + } +} + +func TestCompile_ICMPRule(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "icmp", + DPort: config.PortSpec{"echo-request"}, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + if len(r.Exprs) < 5 { + t.Errorf("ICMP rule should have >= 5 exprs (iface+proto+icmptype+verdict), got %d", len(r.Exprs)) + } + return + } + } + t.Error("rule:0 not found in input chain") +} + +func TestMatchConnLimit(t *testing.T) { + exprs := matchConnLimit("20") + if len(exprs) != 1 { + t.Fatalf("matchConnLimit(20) returned %d exprs, want 1", len(exprs)) + } + cl := exprs[0].(*expr.Connlimit) + if cl.Count != 20 { + t.Errorf("Connlimit.Count = %d, want 20", cl.Count) + } + + exprs = matchConnLimit("d:10") + cl = exprs[0].(*expr.Connlimit) + if cl.Flags != 1 { + t.Errorf("d: prefix should set Flags=1, got %d", cl.Flags) + } +} + +func TestCompile_ConnLimit(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleAccept, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"22"}, + ConnLimit: "20", + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + hasConnLimit := false + for _, e := range r.Exprs { + if _, ok := e.(*expr.Connlimit); ok { + hasConnLimit = true + } + } + if !hasConnLimit { + t.Error("rule with conn_limit should have Connlimit expression") + } + return + } + } + t.Error("rule:0 not found in input chain") +} + +func TestCompile_NFQUEUE(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleNFQueue, + Source: "net", + Dest: "fw", + Proto: "tcp", + DPort: config.PortSpec{"80"}, + NFQueue: 1, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "rule:0" { + hasQueue := false + for _, e := range r.Exprs { + if q, ok := e.(*expr.Queue); ok { + hasQueue = true + if q.Num != 1 { + t.Errorf("Queue.Num = %d, want 1", q.Num) + } + } + } + if !hasQueue { + t.Error("NFQUEUE rule should have Queue expression") + } + return + } + } + t.Error("rule:0 not found in input chain") +} + +func TestCompile_NONAT(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + "loc": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + {Zone: "loc", Interface: "eth1"}, + }, + Policy: []config.Policy{ + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + Rules: []config.Rule{ + { + Action: config.RuleNoNAT, + Source: "net", + Dest: "loc", + Proto: "tcp", + DPort: config.PortSpec{"80"}, + }, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["forward"] { + if r.Tag == "rule:0" { + hasReturn := false + for _, e := range r.Exprs { + if v, ok := e.(*expr.Verdict); ok && v.Kind == expr.VerdictReturn { + hasReturn = true + } + } + if !hasReturn { + t.Error("NONAT rule should have RETURN verdict") + } + return + } + } + t.Error("rule:0 not found in forward chain") +} + +func TestCompile_PolicyRateLimit(t *testing.T) { + cfg := &config.Config{ + Settings: config.Settings{ + TableName: "test", + AddressFamily: config.FamilyINET, + }, + Zones: map[string]config.Zone{ + "fw": {Type: config.ZoneFirewall}, + "net": {Type: config.ZoneIP}, + }, + Interfaces: []config.Interface{ + {Zone: "net", Interface: "eth0"}, + }, + Policy: []config.Policy{ + {Source: "net", Dest: "fw", Action: config.PolicyDrop, RateLimit: "5/sec"}, + {Source: "all", Dest: "all", Action: config.PolicyDrop}, + }, + PortGroups: make(map[string]config.PortGroup), + } + c := NewCompiler(cfg) + state, err := c.Compile() + if err != nil { + t.Fatalf("Compile() error: %v", err) + } + + for _, r := range state.Rules["input"] { + if r.Tag == "policy:0" { + hasLimit := false + for _, e := range r.Exprs { + if _, ok := e.(*expr.Limit); ok { + hasLimit = true + } + } + if !hasLimit { + t.Error("policy with rate_limit should have Limit expression") + } + return + } + } + t.Error("policy:0 not found in input chain") +} diff --git a/internal/nftables/diff.go b/internal/nftables/diff.go new file mode 100644 index 0000000..82cfa30 --- /dev/null +++ b/internal/nftables/diff.go @@ -0,0 +1,109 @@ +package nftables + +import ( + "fmt" + "strings" + + "github.com/google/nftables/expr" +) + +type ManagedRule struct { + Chain string + Handle uint64 + Exprs []expr.Any + Tag string +} + +type FirewallState struct { + Rules map[string][]ManagedRule +} + +type ChangeSet struct { + Add []ManagedRule + Remove []ManagedRule +} + +func (cs *ChangeSet) Empty() bool { + return len(cs.Add) == 0 && len(cs.Remove) == 0 +} + +func (cs *ChangeSet) Summary() string { + var b strings.Builder + if len(cs.Add) > 0 { + fmt.Fprintf(&b, " + %d rule(s) to add\n", len(cs.Add)) + for _, r := range cs.Add { + fmt.Fprintf(&b, " + [%s] %s\n", r.Chain, r.Tag) + } + } + if len(cs.Remove) > 0 { + fmt.Fprintf(&b, " - %d rule(s) to remove\n", len(cs.Remove)) + for _, r := range cs.Remove { + fmt.Fprintf(&b, " - [%s] %s (handle %d)\n", r.Chain, r.Tag, r.Handle) + } + } + return b.String() +} + +func computeDiff(current, desired *FirewallState) *ChangeSet { + cs := &ChangeSet{} + + currentByTag := make(map[string][]ManagedRule) + for _, rules := range current.Rules { + for _, r := range rules { + if r.Tag != "" { + currentByTag[r.Tag] = append(currentByTag[r.Tag], r) + } + } + } + + desiredByTag := make(map[string][]ManagedRule) + for _, rules := range desired.Rules { + for _, r := range rules { + desiredByTag[r.Tag] = append(desiredByTag[r.Tag], r) + } + } + + for tag, desiredRules := range desiredByTag { + currentRules, exists := currentByTag[tag] + if !exists { + cs.Add = append(cs.Add, desiredRules...) + continue + } + if !rulesMatch(currentRules, desiredRules) { + cs.Remove = append(cs.Remove, currentRules...) + cs.Add = append(cs.Add, desiredRules...) + } + } + + for tag, currentRules := range currentByTag { + if _, exists := desiredByTag[tag]; !exists { + cs.Remove = append(cs.Remove, currentRules...) + } + } + + return cs +} + +func rulesMatch(a, b []ManagedRule) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].Chain != b[i].Chain { + return false + } + if !exprsEqual(a[i].Exprs, b[i].Exprs) { + return false + } + } + return true +} + +func exprsEqual(a, b []expr.Any) bool { + if len(a) != len(b) { + return false + } + as := fmt.Sprintf("%v", a) + bs := fmt.Sprintf("%v", b) + return as == bs +} diff --git a/internal/nftables/engine.go b/internal/nftables/engine.go new file mode 100644 index 0000000..0618a6f --- /dev/null +++ b/internal/nftables/engine.go @@ -0,0 +1,188 @@ +package nftables + +import ( + "fmt" + + "github.com/google/nftables" + + "git.unkin.net/unkin/tomswall/internal/config" +) + +type Engine struct { + cfg *config.Config + conn *nftables.Conn +} + +func NewEngine(cfg *config.Config) (*Engine, error) { + conn, err := nftables.New() + if err != nil { + return nil, fmt.Errorf("connecting to nftables: %w", err) + } + return &Engine{cfg: cfg, conn: conn}, nil +} + +func (e *Engine) ensureTable() *nftables.Table { + return e.conn.AddTable(&nftables.Table{ + Family: nftables.TableFamilyINet, + Name: e.cfg.Settings.TableName, + }) +} + +func (e *Engine) ensureChains(table *nftables.Table) map[string]*nftables.Chain { + chains := map[string]*nftables.Chain{ + "input": { + Name: "input", + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookInput, + Priority: nftables.ChainPriorityFilter, + Policy: policyPtr(nftables.ChainPolicyDrop), + }, + "forward": { + Name: "forward", + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookForward, + Priority: nftables.ChainPriorityFilter, + Policy: policyPtr(nftables.ChainPolicyDrop), + }, + "output": { + Name: "output", + Table: table, + Type: nftables.ChainTypeFilter, + Hooknum: nftables.ChainHookOutput, + Priority: nftables.ChainPriorityFilter, + Policy: policyPtr(nftables.ChainPolicyAccept), + }, + "postrouting": { + Name: "postrouting", + Table: table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPostrouting, + Priority: nftables.ChainPriorityNATSource, + }, + "prerouting": { + Name: "prerouting", + Table: table, + Type: nftables.ChainTypeNAT, + Hooknum: nftables.ChainHookPrerouting, + Priority: nftables.ChainPriorityNATDest, + }, + } + + for name, chain := range chains { + chains[name] = e.conn.AddChain(chain) + } + return chains +} + +func (e *Engine) Plan() (*ChangeSet, error) { + compiler := NewCompiler(e.cfg) + + desired, err := compiler.Compile() + if err != nil { + return nil, fmt.Errorf("compiling config: %w", err) + } + + current, err := e.readCurrentState() + if err != nil { + return nil, fmt.Errorf("reading current state: %w", err) + } + + return computeDiff(current, desired), nil +} + +func (e *Engine) Apply(changes *ChangeSet) error { + table := e.ensureTable() + chains := e.ensureChains(table) + + for _, r := range changes.Remove { + e.conn.DelRule(&nftables.Rule{ + Table: table, + Chain: chains[r.Chain], + Handle: r.Handle, + }) + } + + for _, r := range changes.Add { + chain, ok := chains[r.Chain] + if !ok { + return fmt.Errorf("unknown chain %q", r.Chain) + } + e.conn.AddRule(&nftables.Rule{ + Table: table, + Chain: chain, + Exprs: r.Exprs, + UserData: []byte(r.Tag), + }) + } + + return e.conn.Flush() +} + +func (e *Engine) Flush() error { + tables, err := e.conn.ListTables() + if err != nil { + return fmt.Errorf("listing tables: %w", err) + } + + for _, t := range tables { + if t.Name == e.cfg.Settings.TableName { + e.conn.DelTable(t) + return e.conn.Flush() + } + } + return nil +} + +func (e *Engine) readCurrentState() (*FirewallState, error) { + state := &FirewallState{ + Rules: make(map[string][]ManagedRule), + } + + tables, err := e.conn.ListTables() + if err != nil { + return state, nil + } + + var ourTable *nftables.Table + for _, t := range tables { + if t.Name == e.cfg.Settings.TableName && t.Family == nftables.TableFamilyINet { + ourTable = t + break + } + } + + if ourTable == nil { + return state, nil + } + + chains, err := e.conn.ListChainsOfTableFamily(nftables.TableFamilyINet) + if err != nil { + return state, nil + } + + for _, chain := range chains { + if chain.Table.Name != e.cfg.Settings.TableName { + continue + } + rules, err := e.conn.GetRules(ourTable, chain) + if err != nil { + continue + } + for _, rule := range rules { + state.Rules[chain.Name] = append(state.Rules[chain.Name], ManagedRule{ + Chain: chain.Name, + Handle: rule.Handle, + Exprs: rule.Exprs, + Tag: string(rule.UserData), + }) + } + } + + return state, nil +} + +func policyPtr(p nftables.ChainPolicy) *nftables.ChainPolicy { + return &p +} diff --git a/internal/shorewall/convert.go b/internal/shorewall/convert.go new file mode 100644 index 0000000..7ce0f74 --- /dev/null +++ b/internal/shorewall/convert.go @@ -0,0 +1,1445 @@ +package shorewall + +import ( + "fmt" + "strconv" + "strings" + + "git.unkin.net/unkin/tomswall/internal/config" +) + +// Convert reads a shorewall or shorewall6 config directory and returns a tomswall Config. +// Auto-detects IPv6 mode when shorewall6.conf is present. +func Convert(dir string) (*config.Config, error) { + ipv6 := IsIPv6Dir(dir) + return convertDir(dir, ipv6) +} + +func convertDir(dir string, ipv6 bool) (*config.Config, error) { + cfg := &config.Config{ + Zones: make(map[string]config.Zone), + PortGroups: make(map[string]config.PortGroup), + } + + params, _ := ParseParams(dir + "/params") + if params == nil { + params = make(map[string]string) + } + + if err := convertConf(dir, cfg, params, ipv6); err != nil { + confName := "shorewall.conf" + if ipv6 { + confName = "shorewall6.conf" + } + return nil, fmt.Errorf("%s: %w", confName, err) + } + if err := convertZones(dir, cfg, params); err != nil { + return nil, fmt.Errorf("zones: %w", err) + } + + // $FW is a shorewall built-in that refers to the firewall zone + if _, ok := params["FW"]; !ok { + for name, z := range cfg.Zones { + if z.Type == config.ZoneFirewall { + params["FW"] = name + break + } + } + } + if len(params) > 0 { + cfg.Vars = params + } + + // Shared config files (identical format for shorewall and shorewall6) + converters := []struct { + name string + fn func(string, *config.Config, map[string]string) error + }{ + {"interfaces", convertInterfaces}, + {"hosts", convertHosts}, + {"policy", convertPolicy}, + {"rules", convertRules}, + {"snat", convertSNAT}, + {"nat", convertNAT}, + {"netmap", convertNetmap}, + {"providers", convertProviders}, + {"conntrack", convertConntrack}, + {"blrules", convertBlrules}, + {"tunnels", convertTunnels}, + {"rtrules", convertRtrules}, + {"stoppedrules", convertStoppedRules}, + {"mangle", convertMangle}, + {"accounting", convertAccounting}, + {"maclist", convertMaclist}, + {"routes", convertRoutes}, + {"tcdevices", convertTCDevices}, + {"tcclasses", convertTCClasses}, + {"tcinterfaces", convertTCInterfaces}, + {"tcpri", convertTCPri}, + {"secmarks", convertSecmarks}, + } + + for _, c := range converters { + if err := c.fn(dir, cfg, params); err != nil { + return nil, fmt.Errorf("%s: %w", c.name, err) + } + } + + // Family-specific converters + if ipv6 { + if err := convertProxyNDP(dir, cfg, params); err != nil { + return nil, fmt.Errorf("proxyndp: %w", err) + } + } else { + if err := convertProxyARP(dir, cfg, params); err != nil { + return nil, fmt.Errorf("proxyarp: %w", err) + } + } + + return cfg, nil +} + +func subst(s string, params map[string]string) string { + if !strings.Contains(s, "$") { + return s + } + return config.SubstituteVars(s, params) +} + +func convertConf(dir string, cfg *config.Config, params map[string]string, ipv6 bool) error { + confFile := dir + "/shorewall.conf" + if ipv6 { + confFile = dir + "/shorewall6.conf" + } + conf, err := ParseConf(confFile) + if err != nil { + return err + } + if conf == nil { + return nil + } + + cfg.Settings.TableName = "tomswall" + if ipv6 { + cfg.Settings.AddressFamily = config.FamilyIP6 + } else { + cfg.Settings.AddressFamily = config.FamilyIP + } + if v, ok := conf["LOG_LEVEL"]; ok && v != "" { + cfg.Settings.LogLevel = strings.ToLower(v) + } else { + cfg.Settings.LogLevel = "info" + } + if v, ok := conf["IP_FORWARDING"]; ok { + cfg.Settings.IPForwarding = v == "Yes" || v == "On" || v == "on" || v == "Keep" + } + if v, ok := conf["IMPLICIT_CONTINUE"]; ok { + cfg.Settings.ImplicitContinue = v == "Yes" + } + + return nil +} + +func convertZones(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/zones") + if err != nil { + return err + } + + for _, row := range rows { + name := subst(field(row, 0), params) + typeStr := subst(field(row, 1), params) + + var parents []string + if idx := strings.IndexByte(name, ':'); idx > 0 { + parentStr := name[idx+1:] + name = name[:idx] + for _, p := range strings.Split(parentStr, ",") { + p = strings.TrimSpace(p) + if p != "" { + parents = append(parents, p) + } + } + } + + zone := config.Zone{ + Type: convertZoneType(typeStr), + } + if len(parents) > 0 { + zone.Parents = parents + } + + opts := subst(field(row, 2), params) + if !isDash(opts) { + zone.Options = splitOptions(opts) + } + inOpts := subst(field(row, 3), params) + if !isDash(inOpts) { + zone.InOptions = splitOptions(inOpts) + } + outOpts := subst(field(row, 4), params) + if !isDash(outOpts) { + zone.OutOptions = splitOptions(outOpts) + } + + cfg.Zones[name] = zone + } + return nil +} + +func convertZoneType(t string) config.ZoneType { + switch strings.ToLower(strings.TrimSpace(t)) { + case "firewall", "fw": + return config.ZoneFirewall + case "ipv4", "ip", "ipv6": + return config.ZoneIP + case "ipsec", "ipsec4", "ipsec6": + return config.ZoneIPSec + case "bport", "bport4", "bport6": + return config.ZoneBPort + case "loopback": + return config.ZoneLoopback + case "local": + return config.ZoneLocal + default: + return config.ZoneIP + } +} + +func splitOptions(s string) []string { + var opts []string + for _, o := range strings.Split(s, ",") { + o = strings.TrimSpace(o) + if o != "" { + opts = append(opts, o) + } + } + return opts +} + +func convertInterfaces(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/interfaces") + if err != nil { + return err + } + + for _, row := range rows { + zone := subst(field(row, 0), params) + iface := subst(field(row, 1), params) + + intf := config.Interface{ + Zone: zone, + Interface: iface, + } + + optsStr := subst(field(row, 2), params) + if !isDash(optsStr) { + intf.Options = parseInterfaceOptions(optsStr) + } + + cfg.Interfaces = append(cfg.Interfaces, intf) + } + return nil +} + +func parseInterfaceOptions(s string) config.InterfaceOptions { + var opts config.InterfaceOptions + for _, o := range strings.Split(s, ",") { + o = strings.TrimSpace(o) + if o == "" { + continue + } + key, val := splitKV(o) + switch key { + case "dhcp": + opts.DHCP = true + case "tcpflags": + b := true + opts.TCPFlags = &b + case "nosmurfs": + opts.NoSmurfs = true + case "routeback": + b := true + opts.RouteBack = &b + case "bridge": + opts.Bridge = true + case "destonly": + opts.DestOnly = true + case "optional": + opts.Optional = true + case "required": + opts.Required = true + case "physical": + opts.Physical = val + case "routefilter": + if val != "" { + n, _ := strconv.Atoi(val) + opts.RouteFilter = &n + } else { + n := 1 + opts.RouteFilter = &n + } + case "logmartians": + b := true + opts.LogMartians = &b + case "arp_filter": + b := true + opts.ArpFilter = &b + case "arp_ignore": + if val != "" { + n, _ := strconv.Atoi(val) + opts.ArpIgnore = &n + } + case "proxyarp": + b := true + opts.ProxyArp = &b + case "sourceroute": + b := true + opts.SourceRoute = &b + case "upnp": + opts.Upnp = true + case "wait": + if val != "" { + n, _ := strconv.Atoi(val) + opts.Wait = n + } else { + opts.Wait = 1 + } + case "mss": + if val != "" { + n, _ := strconv.Atoi(val) + opts.MSS = n + } + case "nets": + if val != "" { + for _, n := range strings.Split(val, ",") { + n = strings.TrimSpace(n) + if n != "" { + opts.Nets = append(opts.Nets, n) + } + } + } + case "unmanaged": + opts.Unmanaged = true + case "accept_ra": + if val != "" { + n, _ := strconv.Atoi(val) + opts.AcceptRA = &n + } else { + n := 1 + opts.AcceptRA = &n + } + } + } + return opts +} + +func splitHelperChain(s string) (string, string) { + idx := strings.LastIndexByte(s, ':') + if idx < 0 { + return s, "" + } + suffix := s[idx+1:] + suffixLower := strings.ToLower(suffix) + switch suffixLower { + case "p": + return s[:idx], "prerouting" + case "o": + return s[:idx], "output" + case "po", "op": + return s[:idx], "both" + } + return s, "" +} + +func splitKV(s string) (string, string) { + idx := strings.IndexByte(s, '=') + if idx < 0 { + return strings.ToLower(s), "" + } + return strings.ToLower(s[:idx]), s[idx+1:] +} + +func convertHosts(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/hosts") + if err != nil { + return err + } + + for _, row := range rows { + zone := subst(field(row, 0), params) + hostDef := subst(field(row, 1), params) + + iface, addrs := splitHostDef(hostDef) + + host := config.Host{ + Zone: zone, + Interface: iface, + Addresses: addrs, + } + + optsStr := subst(field(row, 2), params) + if !isDash(optsStr) { + host.Options = parseHostOptions(optsStr) + } + + cfg.Hosts = append(cfg.Hosts, host) + } + return nil +} + +func splitHostDef(s string) (string, []string) { + idx := strings.IndexByte(s, ':') + if idx < 0 { + return s, nil + } + iface := s[:idx] + addrPart := s[idx+1:] + var addrs []string + for _, a := range strings.Split(addrPart, ",") { + a = strings.TrimSpace(a) + if a != "" { + addrs = append(addrs, a) + } + } + return iface, addrs +} + +func parseHostOptions(s string) config.HostOptions { + var opts config.HostOptions + for _, o := range strings.Split(s, ",") { + o = strings.TrimSpace(o) + switch strings.ToLower(o) { + case "broadcast": + opts.Broadcast = true + case "destonly": + opts.DestOnly = true + case "ipsec": + opts.IPSec = true + case "nosmurfs": + opts.NoSmurfs = true + case "routeback": + opts.RouteBack = true + case "tcpflags": + opts.TCPFlags = true + } + key, val := splitKV(o) + if key == "mss" && val != "" { + n, _ := strconv.Atoi(val) + opts.MSS = n + } + } + return opts +} + +func convertPolicy(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/policy") + if err != nil { + return err + } + + for _, row := range rows { + source := subst(field(row, 0), params) + dest := subst(field(row, 1), params) + action := subst(field(row, 2), params) + logLevel := subst(field(row, 3), params) + + pol := config.Policy{ + Source: strings.ToLower(source), + Dest: strings.ToLower(dest), + Action: config.PolicyAction(strings.ToLower(action)), + } + if !isDash(logLevel) { + pol.Log = logLevel + } + + burstLimit := subst(field(row, 4), params) + if !isDash(burstLimit) { + pol.RateLimit = burstLimit + } + connLimit := subst(field(row, 5), params) + if !isDash(connLimit) { + pol.ConnLimit = connLimit + } + + cfg.Policy = append(cfg.Policy, pol) + } + return nil +} + +func convertRules(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/rules") + if err != nil { + return err + } + + currentSection := "" + for _, row := range rows { + if row[0] == "?SECTION" { + currentSection = strings.ToLower(field(row, 1)) + continue + } + + actionStr := subst(field(row, 0), params) + source := subst(field(row, 1), params) + dest := subst(field(row, 2), params) + proto := subst(field(row, 3), params) + + action, logLevel := splitActionLog(actionStr) + + rule := config.Rule{ + Action: config.RuleAction(strings.ToLower(action)), + Source: source, + Dest: dest, + } + if currentSection != "" && currentSection != "all" { + rule.Section = config.RuleSection(currentSection) + } + if logLevel != "" { + rule.Log = logLevel + } + if !isDash(proto) { + rule.Proto = strings.ToLower(proto) + } + + dport := subst(field(row, 4), params) + if !isDash(dport) { + rule.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 5), params) + if !isDash(sport) { + rule.SPort = parsePortSpec(sport) + } + origdest := subst(field(row, 6), params) + if !isDash(origdest) { + rule.OrigDest = origdest + } + rate := subst(field(row, 7), params) + if !isDash(rate) { + rule.RateLimit = rate + } + user := subst(field(row, 8), params) + if !isDash(user) { + rule.User = user + } + mark := subst(field(row, 9), params) + if !isDash(mark) { + rule.Mark = mark + } + connLimit := subst(field(row, 10), params) + if !isDash(connLimit) { + rule.ConnLimit = connLimit + } + helper := subst(field(row, 14), params) + if !isDash(helper) { + rule.Helper = helper + } + + cfg.Rules = append(cfg.Rules, rule) + } + return nil +} + +func splitActionLog(s string) (action, logLevel string) { + idx := strings.IndexByte(s, ':') + if idx < 0 { + return s, "" + } + return s[:idx], s[idx+1:] +} + +func parsePortSpec(s string) config.PortSpec { + var ports config.PortSpec + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + if p != "" { + ports = append(ports, p) + } + } + return ports +} + +func convertSNAT(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/snat") + if err != nil { + return err + } + + for _, row := range rows { + actionStr := subst(field(row, 0), params) + action, logLevel := splitActionLog(actionStr) + + snat := config.SNATRule{ + Action: config.SNATAction(strings.ToLower(action)), + } + if logLevel != "" { + snat.Log = logLevel + } + + source := subst(field(row, 1), params) + if !isDash(source) { + snat.Source = source + } + dest := subst(field(row, 2), params) + if !isDash(dest) { + snat.Dest = dest + } + address := subst(field(row, 3), params) + if !isDash(address) { + snat.Address = address + } + proto := subst(field(row, 4), params) + if !isDash(proto) { + snat.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 5), params) + if !isDash(dport) { + snat.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 6), params) + if !isDash(sport) { + snat.SPort = parsePortSpec(sport) + } + mark := subst(field(row, 7), params) + if !isDash(mark) { + snat.Mark = mark + } + + cfg.SNAT = append(cfg.SNAT, snat) + } + return nil +} + +func convertNAT(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/nat") + if err != nil { + return err + } + + for _, row := range rows { + nat := config.StaticNAT{ + External: subst(field(row, 0), params), + Interface: subst(field(row, 1), params), + Internal: subst(field(row, 2), params), + } + allIfaces := subst(field(row, 3), params) + if strings.EqualFold(allIfaces, "yes") { + nat.AllInterfaces = true + } + local := subst(field(row, 4), params) + if strings.EqualFold(local, "yes") { + nat.Local = true + } + + cfg.StaticNAT = append(cfg.StaticNAT, nat) + } + return nil +} + +func convertNetmap(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/netmap") + if err != nil { + return err + } + + for _, row := range rows { + nm := config.Netmap{ + Type: config.NetmapType(strings.ToLower(subst(field(row, 0), params))), + Net1: subst(field(row, 1), params), + Interface: subst(field(row, 2), params), + Net2: subst(field(row, 3), params), + } + net3 := subst(field(row, 4), params) + if !isDash(net3) { + nm.Net3 = net3 + } + proto := subst(field(row, 5), params) + if !isDash(proto) { + nm.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 6), params) + if !isDash(dport) { + nm.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 7), params) + if !isDash(sport) { + nm.SPort = parsePortSpec(sport) + } + + cfg.Netmap = append(cfg.Netmap, nm) + } + return nil +} + +func convertProviders(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/providers") + if err != nil { + return err + } + + for _, row := range rows { + name := subst(field(row, 0), params) + numberStr := subst(field(row, 1), params) + number, _ := strconv.Atoi(numberStr) + + prov := config.Provider{ + Name: name, + Number: number, + } + + markStr := subst(field(row, 2), params) + if !isDash(markStr) { + mark, _ := strconv.ParseInt(markStr, 0, 64) + prov.Mark = int(mark) + } + dup := subst(field(row, 3), params) + if !isDash(dup) { + prov.Duplicate = dup + } + iface := subst(field(row, 4), params) + if !isDash(iface) { + prov.Interface = iface + } + gw := subst(field(row, 5), params) + if !isDash(gw) { + prov.Gateway = gw + } + optsStr := subst(field(row, 6), params) + if !isDash(optsStr) { + prov.Options = parseProviderOptions(optsStr) + } + copyStr := subst(field(row, 7), params) + if !isDash(copyStr) { + for _, c := range strings.Split(copyStr, ",") { + c = strings.TrimSpace(c) + if c != "" { + prov.Copy = append(prov.Copy, c) + } + } + } + + cfg.Providers = append(cfg.Providers, prov) + } + return nil +} + +func parseProviderOptions(s string) config.ProviderOptions { + var opts config.ProviderOptions + for _, o := range strings.Split(s, ",") { + o = strings.TrimSpace(o) + key, val := splitKV(o) + switch key { + case "track": + opts.Track = true + case "balance": + if val != "" { + n, _ := strconv.Atoi(val) + opts.Balance = n + } else { + opts.Balance = 1 + } + case "load": + if val != "" { + f, _ := strconv.ParseFloat(val, 64) + opts.Load = f + } + case "loose": + opts.Loose = true + case "fallback": + if val != "" { + n, _ := strconv.Atoi(val) + opts.Fallback = n + } else { + opts.Fallback = 1 + } + case "primary": + opts.Primary = true + case "src": + opts.Src = val + case "mtu": + if val != "" { + n, _ := strconv.Atoi(val) + opts.MTU = n + } + case "tproxy": + opts.TProxy = true + case "optional": + opts.Optional = true + case "persistent": + opts.Persistent = true + } + } + return opts +} + +func convertConntrack(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/conntrack") + if err != nil { + return err + } + + for _, row := range rows { + actionStr := subst(field(row, 0), params) + + ct := config.ConntrackRule{} + + actionLower := strings.ToLower(actionStr) + if strings.HasPrefix(actionLower, "ct:helper:") { + ct.Action = config.ConntrackHelper + helper := actionStr[10:] + helper, chain := splitHelperChain(helper) + ct.Helper = helper + if chain != "" { + ct.Chain = config.ConntrackChain(chain) + } + } else if strings.HasPrefix(actionLower, "ct:") { + ct.Action = config.ConntrackHelper + helper := actionStr[3:] + helper, chain := splitHelperChain(helper) + ct.Helper = helper + if chain != "" { + ct.Chain = config.ConntrackChain(chain) + } + } else { + ct.Action = config.ConntrackAction(actionLower) + } + + source := subst(field(row, 1), params) + if !isDash(source) { + ct.Source = source + } + dest := subst(field(row, 2), params) + if !isDash(dest) { + ct.Dest = dest + } + proto := subst(field(row, 3), params) + if !isDash(proto) { + ct.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 4), params) + if !isDash(dport) { + ct.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 5), params) + if !isDash(sport) { + ct.SPort = parsePortSpec(sport) + } + user := subst(field(row, 6), params) + if !isDash(user) { + ct.User = user + } + + cfg.Conntrack = append(cfg.Conntrack, ct) + } + return nil +} + +func convertBlrules(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/blrules") + if err != nil { + return err + } + + for _, row := range rows { + actionStr := subst(field(row, 0), params) + action, logLevel := splitActionLog(actionStr) + + rule := config.BlruleRule{ + Action: config.BlruleAction(strings.ToLower(action)), + Source: subst(field(row, 1), params), + Dest: subst(field(row, 2), params), + } + if logLevel != "" { + rule.Log = logLevel + } + proto := subst(field(row, 3), params) + if !isDash(proto) { + rule.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 4), params) + if !isDash(dport) { + rule.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 5), params) + if !isDash(sport) { + rule.SPort = parsePortSpec(sport) + } + + cfg.Blrules = append(cfg.Blrules, rule) + } + return nil +} + +func convertTunnels(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/tunnels") + if err != nil { + return err + } + + for _, row := range rows { + typeStr := subst(field(row, 0), params) + zone := subst(field(row, 1), params) + gwStr := subst(field(row, 2), params) + + var gateways []string + for _, gw := range strings.Split(gwStr, ",") { + gw = strings.TrimSpace(gw) + if gw != "" { + gateways = append(gateways, gw) + } + } + + tunnel := config.Tunnel{ + Type: typeStr, + Zone: zone, + Gateways: gateways, + } + + gwZones := subst(field(row, 3), params) + if !isDash(gwZones) { + for _, gz := range strings.Split(gwZones, ",") { + gz = strings.TrimSpace(gz) + if gz != "" { + tunnel.GatewayZones = append(tunnel.GatewayZones, gz) + } + } + } + + cfg.Tunnels = append(cfg.Tunnels, tunnel) + } + return nil +} + +func convertRtrules(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/rtrules") + if err != nil { + return err + } + + for _, row := range rows { + source := subst(field(row, 0), params) + dest := subst(field(row, 1), params) + provider := subst(field(row, 2), params) + priorityStr := subst(field(row, 3), params) + priority, _ := strconv.Atoi(strings.TrimSuffix(priorityStr, "!")) + + rule := config.RoutingRule{ + Provider: provider, + Priority: priority, + } + if !isDash(source) { + rule.Source = source + } + if !isDash(dest) { + rule.Dest = dest + } + if strings.HasSuffix(priorityStr, "!") { + rule.Persistent = true + } + + mark := subst(field(row, 4), params) + if !isDash(mark) { + rule.Mark = mark + } + + cfg.RoutingRules = append(cfg.RoutingRules, rule) + } + return nil +} + +func convertStoppedRules(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/stoppedrules") + if err != nil { + return err + } + + for _, row := range rows { + action := subst(field(row, 0), params) + rule := config.StoppedRule{ + Action: config.StoppedAction(strings.ToLower(action)), + } + source := subst(field(row, 1), params) + if !isDash(source) { + rule.Source = source + } + dest := subst(field(row, 2), params) + if !isDash(dest) { + rule.Dest = dest + } + proto := subst(field(row, 3), params) + if !isDash(proto) { + rule.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 4), params) + if !isDash(dport) { + rule.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 5), params) + if !isDash(sport) { + rule.SPort = parsePortSpec(sport) + } + + cfg.StoppedRules = append(cfg.StoppedRules, rule) + } + return nil +} + +func convertMangle(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/mangle") + if err != nil { + return err + } + + for _, row := range rows { + actionStr := subst(field(row, 0), params) + + rule := config.MangleRule{} + + actionParts := strings.SplitN(actionStr, "(", 2) + base := actionParts[0] + + var chain string + if idx := strings.LastIndexByte(base, ':'); idx > 0 { + chain = strings.ToLower(base[idx+1:]) + base = base[:idx] + } + + markVal := "" + if len(actionParts) > 1 { + markVal = strings.TrimSuffix(actionParts[1], ")") + } + + rule.Action = config.MangleAction(strings.ToLower(base)) + if chain != "" { + rule.Chain = config.MangleChain(chain) + } + if markVal != "" { + rule.MarkValue = markVal + } + + source := subst(field(row, 1), params) + if !isDash(source) { + rule.Source = source + } + dest := subst(field(row, 2), params) + if !isDash(dest) { + rule.Dest = dest + } + proto := subst(field(row, 3), params) + if !isDash(proto) { + rule.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 4), params) + if !isDash(dport) { + rule.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 5), params) + if !isDash(sport) { + rule.SPort = parsePortSpec(sport) + } + user := subst(field(row, 6), params) + if !isDash(user) { + rule.User = user + } + test := subst(field(row, 7), params) + if !isDash(test) { + rule.Mark = test + } + length := subst(field(row, 8), params) + if !isDash(length) { + rule.Length = length + } + tos := subst(field(row, 9), params) + if !isDash(tos) { + rule.TOS = tos + } + helper := subst(field(row, 11), params) + if !isDash(helper) { + rule.Helper = helper + } + prob := subst(field(row, 12), params) + if !isDash(prob) { + f, _ := strconv.ParseFloat(prob, 64) + rule.Probability = f + } + dscp := subst(field(row, 13), params) + if !isDash(dscp) { + rule.DSCP = dscp + } + + cfg.Mangle = append(cfg.Mangle, rule) + } + return nil +} + +func convertAccounting(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/accounting") + if err != nil { + return err + } + + currentSection := "" + for _, row := range rows { + if row[0] == "?SECTION" { + currentSection = strings.ToLower(field(row, 1)) + continue + } + + actionStr := subst(field(row, 0), params) + + rule := config.AccountingRule{ + Action: config.AccountingAction(strings.ToLower(actionStr)), + } + if currentSection != "" { + rule.Section = config.AccountingSection(currentSection) + } + + chain := subst(field(row, 1), params) + if !isDash(chain) { + rule.Chain = chain + } + source := subst(field(row, 2), params) + if !isDash(source) { + rule.Source = source + } + dest := subst(field(row, 3), params) + if !isDash(dest) { + rule.Dest = dest + } + proto := subst(field(row, 4), params) + if !isDash(proto) { + rule.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 5), params) + if !isDash(dport) { + rule.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 6), params) + if !isDash(sport) { + rule.SPort = parsePortSpec(sport) + } + mark := subst(field(row, 8), params) + if !isDash(mark) { + rule.Mark = mark + } + + cfg.Accounting = append(cfg.Accounting, rule) + } + return nil +} + +func convertMaclist(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/maclist") + if err != nil { + return err + } + + for _, row := range rows { + actionStr := subst(field(row, 0), params) + action, logLevel := splitActionLog(actionStr) + + entry := config.MaclistEntry{ + Action: config.MaclistAction(strings.ToLower(action)), + Interface: subst(field(row, 1), params), + MAC: subst(field(row, 2), params), + } + if logLevel != "" { + entry.Log = logLevel + } + + addrs := subst(field(row, 3), params) + if !isDash(addrs) { + for _, a := range strings.Split(addrs, ",") { + a = strings.TrimSpace(a) + if a != "" { + entry.Addresses = append(entry.Addresses, a) + } + } + } + + cfg.Maclist = append(cfg.Maclist, entry) + } + return nil +} + +func convertProxyARP(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/proxyarp") + if err != nil { + return err + } + + for _, row := range rows { + entry := config.ProxyARP{ + Address: subst(field(row, 0), params), + Interface: subst(field(row, 1), params), + External: subst(field(row, 2), params), + } + haveRoute := subst(field(row, 3), params) + if strings.EqualFold(haveRoute, "yes") { + entry.HaveRoute = true + } + persistent := subst(field(row, 4), params) + if strings.EqualFold(persistent, "yes") { + entry.Persistent = true + } + + cfg.ProxyARP = append(cfg.ProxyARP, entry) + } + return nil +} + +func convertRoutes(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/routes") + if err != nil { + return err + } + + for _, row := range rows { + route := config.StaticRoute{ + Provider: subst(field(row, 0), params), + Dest: subst(field(row, 1), params), + } + gw := subst(field(row, 2), params) + if !isDash(gw) { + route.Gateway = gw + } + dev := subst(field(row, 3), params) + if !isDash(dev) { + route.Device = dev + } + + cfg.Routes = append(cfg.Routes, route) + } + return nil +} + +func convertTCDevices(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/tcdevices") + if err != nil { + return err + } + + for _, row := range rows { + dev := config.TCDevice{ + Interface: subst(field(row, 0), params), + InBandwidth: subst(field(row, 1), params), + OutBandwidth: subst(field(row, 2), params), + } + if isDash(dev.InBandwidth) { + dev.InBandwidth = "" + } + + opts := subst(field(row, 3), params) + if !isDash(opts) { + dev.Options = parseTCDeviceOptions(opts) + } + + cfg.TCDevices = append(cfg.TCDevices, dev) + } + return nil +} + +func parseTCDeviceOptions(s string) config.TCDeviceOptions { + var opts config.TCDeviceOptions + for _, o := range strings.Split(s, ",") { + o = strings.TrimSpace(o) + switch strings.ToLower(o) { + case "classify": + opts.Classify = true + case "htb": + opts.HTB = true + case "hfsc": + opts.HFSC = true + default: + key, val := splitKV(o) + if key == "linklayer" { + opts.Linklayer = val + } + } + } + return opts +} + +func convertTCClasses(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/tcclasses") + if err != nil { + return err + } + + for _, row := range rows { + cls := config.TCClass{ + Interface: subst(field(row, 0), params), + Rate: subst(field(row, 2), params), + } + markStr := subst(field(row, 1), params) + if !isDash(markStr) { + mark, _ := strconv.Atoi(markStr) + cls.Mark = mark + } + ceil := subst(field(row, 3), params) + if !isDash(ceil) { + cls.Ceil = ceil + } + priStr := subst(field(row, 4), params) + if !isDash(priStr) { + pri, _ := strconv.Atoi(priStr) + cls.Priority = pri + } + opts := subst(field(row, 5), params) + if !isDash(opts) { + cls.Options = parseTCClassOptions(opts) + } + + cfg.TCClasses = append(cfg.TCClasses, cls) + } + return nil +} + +func parseTCClassOptions(s string) config.TCClassOptions { + var opts config.TCClassOptions + for _, o := range strings.Split(s, ",") { + switch strings.ToLower(strings.TrimSpace(o)) { + case "default": + opts.Default = true + case "tcp-ack": + opts.TCPAck = true + case "pfifo": + opts.Pfifo = true + } + } + return opts +} + +func convertTCInterfaces(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/tcinterfaces") + if err != nil { + return err + } + + for _, row := range rows { + tci := config.TCInterface{ + Interface: subst(field(row, 0), params), + } + typeStr := subst(field(row, 1), params) + if !isDash(typeStr) { + tci.Type = strings.ToLower(typeStr) + } + inBw := subst(field(row, 2), params) + if !isDash(inBw) { + tci.InBandwidth = inBw + } + outBw := subst(field(row, 3), params) + if !isDash(outBw) { + tci.OutBandwidth = outBw + } + + cfg.TCInterfaces = append(cfg.TCInterfaces, tci) + } + return nil +} + +func convertTCPri(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/tcpri") + if err != nil { + return err + } + + for _, row := range rows { + bandStr := subst(field(row, 0), params) + band, _ := strconv.Atoi(bandStr) + + tp := config.TCPriority{ + Band: band, + } + proto := subst(field(row, 1), params) + if !isDash(proto) { + tp.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 2), params) + if !isDash(dport) { + tp.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 3), params) + if !isDash(sport) { + tp.SPort = parsePortSpec(sport) + } + addr := subst(field(row, 4), params) + if !isDash(addr) { + tp.Address = addr + } + iface := subst(field(row, 5), params) + if !isDash(iface) { + tp.Interface = iface + } + helper := subst(field(row, 6), params) + if !isDash(helper) { + tp.Helper = helper + } + + cfg.TCPriorities = append(cfg.TCPriorities, tp) + } + return nil +} + +func convertSecmarks(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/secmarks") + if err != nil { + return err + } + + for _, row := range rows { + sm := config.SecmarkRule{ + Secmark: subst(field(row, 0), params), + Chain: subst(field(row, 1), params), + } + source := subst(field(row, 2), params) + if !isDash(source) { + sm.Source = source + } + dest := subst(field(row, 3), params) + if !isDash(dest) { + sm.Dest = dest + } + proto := subst(field(row, 4), params) + if !isDash(proto) { + sm.Proto = strings.ToLower(proto) + } + dport := subst(field(row, 5), params) + if !isDash(dport) { + sm.DPort = parsePortSpec(dport) + } + sport := subst(field(row, 6), params) + if !isDash(sport) { + sm.SPort = parsePortSpec(sport) + } + + cfg.Secmarks = append(cfg.Secmarks, sm) + } + return nil +} + +func convertProxyNDP(dir string, cfg *config.Config, params map[string]string) error { + rows, err := ParseFile(dir + "/proxyndp") + if err != nil { + return err + } + + for _, row := range rows { + entry := config.ProxyNDP{ + Address: subst(field(row, 0), params), + Interface: subst(field(row, 1), params), + External: subst(field(row, 2), params), + } + haveRoute := subst(field(row, 3), params) + if strings.EqualFold(haveRoute, "yes") { + entry.HaveRoute = true + } + persistent := subst(field(row, 4), params) + if strings.EqualFold(persistent, "yes") { + entry.Persistent = true + } + + cfg.ProxyNDP = append(cfg.ProxyNDP, entry) + } + return nil +} diff --git a/internal/shorewall/convert_test.go b/internal/shorewall/convert_test.go new file mode 100644 index 0000000..5b4b49c --- /dev/null +++ b/internal/shorewall/convert_test.go @@ -0,0 +1,727 @@ +package shorewall + +import ( + "os" + "path/filepath" + "testing" + + "git.unkin.net/unkin/tomswall/internal/config" +) + +// writeFile is a helper to create a file in the temp dir. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0644); err != nil { + t.Fatal(err) + } +} + +// minimalShorewallDir creates a temp directory with the minimum shorewall config +// files needed for Convert() to succeed. +func minimalShorewallDir(t *testing.T) string { + t.Helper() + dir := t.TempDir() + + writeFile(t, dir, "shorewall.conf", ` +IP_FORWARDING=Yes +LOG_LEVEL=info +`) + + writeFile(t, dir, "zones", ` +fw firewall +net ipv4 +loc ipv4 +`) + + writeFile(t, dir, "interfaces", ` +net eth0 dhcp,tcpflags,routefilter,nosmurfs +loc eth1 tcpflags,nosmurfs +`) + + writeFile(t, dir, "policy", ` +loc net ACCEPT +net all DROP info +all all REJECT info +`) + + // Create empty files for optional configs so ParseFile returns nil, nil + // (they would return nil,nil on os.IsNotExist anyway). + + return dir +} + +func TestConvert_MinimalConfig(t *testing.T) { + dir := minimalShorewallDir(t) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + // Check zones + if len(cfg.Zones) != 3 { + t.Fatalf("expected 3 zones, got %d", len(cfg.Zones)) + } + + fwZone, ok := cfg.Zones["fw"] + if !ok { + t.Fatal("expected fw zone") + } + if fwZone.Type != config.ZoneFirewall { + t.Errorf("fw zone type = %q, want %q", fwZone.Type, config.ZoneFirewall) + } + + netZone, ok := cfg.Zones["net"] + if !ok { + t.Fatal("expected net zone") + } + if netZone.Type != config.ZoneIP { + t.Errorf("net zone type = %q, want %q", netZone.Type, config.ZoneIP) + } + + locZone, ok := cfg.Zones["loc"] + if !ok { + t.Fatal("expected loc zone") + } + if locZone.Type != config.ZoneIP { + t.Errorf("loc zone type = %q, want %q", locZone.Type, config.ZoneIP) + } +} + +func TestConvert_Interfaces(t *testing.T) { + dir := minimalShorewallDir(t) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if len(cfg.Interfaces) != 2 { + t.Fatalf("expected 2 interfaces, got %d", len(cfg.Interfaces)) + } + + // Check first interface (net eth0) + netIface := cfg.Interfaces[0] + if netIface.Zone != "net" { + t.Errorf("interface 0 zone = %q, want %q", netIface.Zone, "net") + } + if netIface.Interface != "eth0" { + t.Errorf("interface 0 name = %q, want %q", netIface.Interface, "eth0") + } + if !netIface.Options.DHCP { + t.Error("interface 0 should have DHCP enabled") + } + if netIface.Options.TCPFlags == nil || !*netIface.Options.TCPFlags { + t.Error("interface 0 should have tcpflags enabled") + } + if netIface.Options.RouteFilter == nil || *netIface.Options.RouteFilter != 1 { + t.Error("interface 0 should have routefilter=1") + } + if !netIface.Options.NoSmurfs { + t.Error("interface 0 should have nosmurfs enabled") + } + + // Check second interface (loc eth1) + locIface := cfg.Interfaces[1] + if locIface.Zone != "loc" { + t.Errorf("interface 1 zone = %q, want %q", locIface.Zone, "loc") + } + if locIface.Interface != "eth1" { + t.Errorf("interface 1 name = %q, want %q", locIface.Interface, "eth1") + } +} + +func TestConvert_Policy(t *testing.T) { + dir := minimalShorewallDir(t) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if len(cfg.Policy) != 3 { + t.Fatalf("expected 3 policies, got %d", len(cfg.Policy)) + } + + // loc -> net ACCEPT + p0 := cfg.Policy[0] + if p0.Source != "loc" || p0.Dest != "net" { + t.Errorf("policy 0: source=%q dest=%q, want loc/net", p0.Source, p0.Dest) + } + if p0.Action != config.PolicyAccept { + t.Errorf("policy 0 action = %q, want %q", p0.Action, config.PolicyAccept) + } + + // net -> all DROP info + p1 := cfg.Policy[1] + if p1.Source != "net" || p1.Dest != "all" { + t.Errorf("policy 1: source=%q dest=%q, want net/all", p1.Source, p1.Dest) + } + if p1.Action != config.PolicyDrop { + t.Errorf("policy 1 action = %q, want %q", p1.Action, config.PolicyDrop) + } + if p1.Log != "info" { + t.Errorf("policy 1 log = %q, want %q", p1.Log, "info") + } + + // all -> all REJECT info + p2 := cfg.Policy[2] + if p2.Action != config.PolicyReject { + t.Errorf("policy 2 action = %q, want %q", p2.Action, config.PolicyReject) + } +} + +func TestConvert_Settings(t *testing.T) { + dir := minimalShorewallDir(t) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if cfg.Settings.TableName != "tomswall" { + t.Errorf("table name = %q, want %q", cfg.Settings.TableName, "tomswall") + } + if cfg.Settings.LogLevel != "info" { + t.Errorf("log level = %q, want %q", cfg.Settings.LogLevel, "info") + } + if !cfg.Settings.IPForwarding { + t.Error("IP forwarding should be enabled") + } +} + +func TestConvert_ParamsSubstitution(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "params", ` +NET_IF=eth0 +LOC_IF=eth1 +`) + writeFile(t, dir, "shorewall.conf", ` +IP_FORWARDING=Yes +`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv4 +loc ipv4 +`) + writeFile(t, dir, "interfaces", ` +net $NET_IF dhcp +loc $LOC_IF - +`) + writeFile(t, dir, "policy", ` +loc net ACCEPT +all all DROP +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + // Verify params were stored + if cfg.Vars["NET_IF"] != "eth0" { + t.Errorf("Vars[NET_IF] = %q, want %q", cfg.Vars["NET_IF"], "eth0") + } + if cfg.Vars["LOC_IF"] != "eth1" { + t.Errorf("Vars[LOC_IF] = %q, want %q", cfg.Vars["LOC_IF"], "eth1") + } + + // Verify substitution worked in interfaces + if len(cfg.Interfaces) != 2 { + t.Fatalf("expected 2 interfaces, got %d", len(cfg.Interfaces)) + } + if cfg.Interfaces[0].Interface != "eth0" { + t.Errorf("interface 0 = %q, want %q (after param subst)", cfg.Interfaces[0].Interface, "eth0") + } + if cfg.Interfaces[1].Interface != "eth1" { + t.Errorf("interface 1 = %q, want %q (after param subst)", cfg.Interfaces[1].Interface, "eth1") + } +} + +func TestConvert_ZonesWithParents(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv4 +loc ipv4 +dmz:net ipv4 +`) + writeFile(t, dir, "interfaces", ` +net eth0 - +loc eth1 - +`) + writeFile(t, dir, "policy", ` +all all DROP +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + dmz, ok := cfg.Zones["dmz"] + if !ok { + t.Fatal("expected dmz zone") + } + if len(dmz.Parents) != 1 || dmz.Parents[0] != "net" { + t.Errorf("dmz parents = %v, want [net]", dmz.Parents) + } +} + +func TestConvert_Rules(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv4 +loc ipv4 +`) + writeFile(t, dir, "interfaces", ` +net eth0 - +loc eth1 - +`) + writeFile(t, dir, "policy", ` +all all DROP +`) + writeFile(t, dir, "rules", ` +SECTION NEW +ACCEPT net fw tcp 22 +DROP net fw udp 53 +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if len(cfg.Rules) != 2 { + t.Fatalf("expected 2 rules, got %d", len(cfg.Rules)) + } + + r0 := cfg.Rules[0] + if r0.Action != config.RuleAccept { + t.Errorf("rule 0 action = %q, want %q", r0.Action, config.RuleAccept) + } + if r0.Source != "net" { + t.Errorf("rule 0 source = %q, want %q", r0.Source, "net") + } + if r0.Dest != "fw" { + t.Errorf("rule 0 dest = %q, want %q", r0.Dest, "fw") + } + if r0.Proto != "tcp" { + t.Errorf("rule 0 proto = %q, want %q", r0.Proto, "tcp") + } + if len(r0.DPort) != 1 || r0.DPort[0] != "22" { + t.Errorf("rule 0 dport = %v, want [22]", r0.DPort) + } + if r0.Section != "new" { + t.Errorf("rule 0 section = %q, want %q", r0.Section, "new") + } + + r1 := cfg.Rules[1] + if r1.Action != config.RuleDrop { + t.Errorf("rule 1 action = %q, want %q", r1.Action, config.RuleDrop) + } +} + +func TestConvert_FWBuiltinVariable(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv4 +`) + writeFile(t, dir, "interfaces", ` +net eth0 - +`) + writeFile(t, dir, "policy", ` +$FW all ACCEPT +net all DROP +all all REJECT +`) + writeFile(t, dir, "rules", ` +SECTION NEW +ACCEPT net $FW tcp 22 +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if cfg.Vars["FW"] != "fw" { + t.Errorf("Vars[FW] = %q, want %q", cfg.Vars["FW"], "fw") + } + if cfg.Policy[0].Source != "fw" { + t.Errorf("policy 0 source = %q, want %q (after $FW substitution)", cfg.Policy[0].Source, "fw") + } + if cfg.Rules[0].Dest != "fw" { + t.Errorf("rule 0 dest = %q, want %q (after $FW substitution)", cfg.Rules[0].Dest, "fw") + } +} + +func TestConvert_ConntrackHelperChain(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv4 +`) + writeFile(t, dir, "interfaces", ` +net eth0 - +`) + writeFile(t, dir, "policy", ` +all all DROP +`) + writeFile(t, dir, "conntrack", ` +CT:helper:ftp:PO - - tcp 21 +CT:helper:sip:P - - udp 5060 +CT:helper:tftp:O - - udp 69 +CT:helper:irc - - tcp 6667 +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if len(cfg.Conntrack) != 4 { + t.Fatalf("expected 4 conntrack rules, got %d", len(cfg.Conntrack)) + } + + tests := []struct { + helper string + chain string + }{ + {"ftp", "both"}, + {"sip", "prerouting"}, + {"tftp", "output"}, + {"irc", ""}, + } + for i, tt := range tests { + if cfg.Conntrack[i].Helper != tt.helper { + t.Errorf("conntrack[%d] helper = %q, want %q", i, cfg.Conntrack[i].Helper, tt.helper) + } + if string(cfg.Conntrack[i].Chain) != tt.chain { + t.Errorf("conntrack[%d] chain = %q, want %q", i, cfg.Conntrack[i].Chain, tt.chain) + } + } +} + +func TestSplitHelperChain(t *testing.T) { + tests := []struct { + input string + wantName string + wantChain string + }{ + {"ftp:PO", "ftp", "both"}, + {"sip:P", "sip", "prerouting"}, + {"tftp:O", "tftp", "output"}, + {"irc", "irc", ""}, + {"Q.931:PO", "Q.931", "both"}, + {"netbios-ns:PO", "netbios-ns", "both"}, + } + + for _, tt := range tests { + name, chain := splitHelperChain(tt.input) + if name != tt.wantName { + t.Errorf("splitHelperChain(%q) name = %q, want %q", tt.input, name, tt.wantName) + } + if chain != tt.wantChain { + t.Errorf("splitHelperChain(%q) chain = %q, want %q", tt.input, chain, tt.wantChain) + } + } +} + +func TestConvert_MultiZoneRules(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv4 +loc ipv4 +`) + writeFile(t, dir, "interfaces", ` +net eth0 - +loc eth1 - +`) + writeFile(t, dir, "policy", ` +all all DROP +`) + writeFile(t, dir, "rules", ` +SECTION NEW +ACCEPT net,loc fw tcp 8080,8501 +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if len(cfg.Rules) != 1 { + t.Fatalf("expected 1 rule, got %d", len(cfg.Rules)) + } + if cfg.Rules[0].Source != "net,loc" { + t.Errorf("rule 0 source = %q, want %q", cfg.Rules[0].Source, "net,loc") + } + + if err := cfg.Validate(); err != nil { + t.Errorf("multi-zone rule should validate: %v", err) + } +} + +func TestConvert_ImplicitContinue(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall.conf", ` +IP_FORWARDING=Yes +IMPLICIT_CONTINUE=Yes +`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv4 +`) + writeFile(t, dir, "interfaces", ` +net eth0 - +`) + writeFile(t, dir, "policy", ` +all all DROP +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + if !cfg.Settings.ImplicitContinue { + t.Error("ImplicitContinue should be true") + } +} + +// --- shorewall6 tests --- + +func TestConvert_IPv6Detection(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall6.conf", ` +IP_FORWARDING=On +IMPLICIT_CONTINUE=Yes +`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv6 +`) + writeFile(t, dir, "interfaces", ` +net eth0 dhcp,accept_ra +`) + writeFile(t, dir, "policy", ` +all all DROP +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if cfg.Settings.AddressFamily != config.FamilyIP6 { + t.Errorf("AddressFamily = %q, want %q", cfg.Settings.AddressFamily, config.FamilyIP6) + } +} + +func TestConvert_IPv4Detection(t *testing.T) { + dir := minimalShorewallDir(t) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if cfg.Settings.AddressFamily != config.FamilyIP { + t.Errorf("AddressFamily = %q, want %q", cfg.Settings.AddressFamily, config.FamilyIP) + } +} + +func TestConvert_IPv6ProxyNDP(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall6.conf", `IP_FORWARDING=On`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv6 +`) + writeFile(t, dir, "interfaces", ` +net eth0 - +`) + writeFile(t, dir, "policy", ` +all all DROP +`) + writeFile(t, dir, "proxyndp", ` +fd10::100 eth0 eth1 No Yes +2001:db8::1 eth2 eth3 Yes No +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if len(cfg.ProxyNDP) != 2 { + t.Fatalf("expected 2 proxyndp entries, got %d", len(cfg.ProxyNDP)) + } + + p0 := cfg.ProxyNDP[0] + if p0.Address != "fd10::100" { + t.Errorf("proxyndp[0] address = %q, want %q", p0.Address, "fd10::100") + } + if p0.Interface != "eth0" { + t.Errorf("proxyndp[0] interface = %q, want %q", p0.Interface, "eth0") + } + if p0.External != "eth1" { + t.Errorf("proxyndp[0] external = %q, want %q", p0.External, "eth1") + } + if p0.HaveRoute { + t.Error("proxyndp[0] haveroute should be false") + } + if !p0.Persistent { + t.Error("proxyndp[0] persistent should be true") + } + + p1 := cfg.ProxyNDP[1] + if !p1.HaveRoute { + t.Error("proxyndp[1] haveroute should be true") + } + if p1.Persistent { + t.Error("proxyndp[1] persistent should be false") + } + + // IPv6 config should NOT have proxyarp entries + if len(cfg.ProxyARP) != 0 { + t.Errorf("IPv6 config should have 0 proxyarp entries, got %d", len(cfg.ProxyARP)) + } +} + +func TestConvert_IPv6AcceptRA(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall6.conf", `IP_FORWARDING=On`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv6 +`) + writeFile(t, dir, "interfaces", ` +net eth0 dhcp,accept_ra=2 +`) + writeFile(t, dir, "policy", ` +all all DROP +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if len(cfg.Interfaces) != 1 { + t.Fatalf("expected 1 interface, got %d", len(cfg.Interfaces)) + } + + opts := cfg.Interfaces[0].Options + if opts.AcceptRA == nil { + t.Fatal("accept_ra should be set") + } + if *opts.AcceptRA != 2 { + t.Errorf("accept_ra = %d, want 2", *opts.AcceptRA) + } +} + +func TestConvert_IPv6ZoneTypes(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall6.conf", `IP_FORWARDING=On`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv6 +vpn ipsec6 +dmz bport6 +`) + writeFile(t, dir, "interfaces", ` +net eth0 - +vpn ipsec0 - +dmz br0 - +`) + writeFile(t, dir, "policy", ` +all all DROP +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if cfg.Zones["net"].Type != config.ZoneIP { + t.Errorf("net zone type = %q, want %q", cfg.Zones["net"].Type, config.ZoneIP) + } + if cfg.Zones["vpn"].Type != config.ZoneIPSec { + t.Errorf("vpn zone type = %q, want %q", cfg.Zones["vpn"].Type, config.ZoneIPSec) + } + if cfg.Zones["dmz"].Type != config.ZoneBPort { + t.Errorf("dmz zone type = %q, want %q", cfg.Zones["dmz"].Type, config.ZoneBPort) + } +} + +func TestConvert_SecmarksConverter(t *testing.T) { + dir := t.TempDir() + + writeFile(t, dir, "shorewall.conf", `IP_FORWARDING=Yes`) + writeFile(t, dir, "zones", ` +fw firewall +net ipv4 +`) + writeFile(t, dir, "interfaces", ` +net eth0 - +`) + writeFile(t, dir, "policy", ` +all all DROP +`) + writeFile(t, dir, "secmarks", ` +system_u:object_r:http_t:s0 P net fw tcp 80 +`) + + cfg, err := Convert(dir) + if err != nil { + t.Fatalf("Convert: %v", err) + } + + if len(cfg.Secmarks) != 1 { + t.Fatalf("expected 1 secmark, got %d", len(cfg.Secmarks)) + } + if cfg.Secmarks[0].Secmark != "system_u:object_r:http_t:s0" { + t.Errorf("secmark = %q", cfg.Secmarks[0].Secmark) + } + if cfg.Secmarks[0].Proto != "tcp" { + t.Errorf("proto = %q, want tcp", cfg.Secmarks[0].Proto) + } +} + +func TestIsIPv6Dir(t *testing.T) { + t.Run("shorewall6 dir", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "shorewall6.conf", "IP_FORWARDING=On\n") + if !IsIPv6Dir(dir) { + t.Error("should detect IPv6 dir") + } + }) + + t.Run("shorewall dir", func(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, "shorewall.conf", "IP_FORWARDING=Yes\n") + if IsIPv6Dir(dir) { + t.Error("should not detect IPv6 for shorewall dir") + } + }) +} diff --git a/internal/shorewall/parser.go b/internal/shorewall/parser.go new file mode 100644 index 0000000..cb8242b --- /dev/null +++ b/internal/shorewall/parser.go @@ -0,0 +1,172 @@ +package shorewall + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// ParseFile reads a shorewall columnar config file and returns rows of fields. +// Handles comments (#), blank lines, line continuation (\), and ?COMMENT directives. +func ParseFile(path string) ([][]string, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("opening %s: %w", path, err) + } + defer f.Close() + + var rows [][]string + var continuation string + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := scanner.Text() + + if strings.HasSuffix(line, "\\") { + continuation += strings.TrimSuffix(line, "\\") + " " + continue + } + if continuation != "" { + line = continuation + line + continuation = "" + } + + line = strings.TrimSpace(line) + + if line == "" || strings.HasPrefix(line, "#") { + continue + } + + if strings.HasPrefix(line, "SECTION") || strings.HasPrefix(line, "?SECTION") { + raw := strings.TrimPrefix(line, "?") + rows = append(rows, []string{"?SECTION", strings.TrimSpace(strings.TrimPrefix(raw, "SECTION"))}) + continue + } + + if strings.HasPrefix(line, "?") { + continue + } + + fields := splitFields(line) + if len(fields) > 0 { + rows = append(rows, fields) + } + } + + return rows, scanner.Err() +} + +// splitFields splits a shorewall config line into fields. +// Fields are whitespace-separated, but supports the { key=value ... } alternate syntax. +func splitFields(line string) []string { + var fields []string + for _, f := range strings.Fields(line) { + if f == "#" { + break + } + if strings.HasPrefix(f, "#") { + break + } + fields = append(fields, f) + } + return fields +} + +// ParseConf reads a shorewall.conf (key=value) file into a map. +func ParseConf(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + return nil, err + } + defer f.Close() + + conf := make(map[string]string) + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + val = strings.Trim(val, "\"'") + if key != "" { + conf[key] = val + } + } + return conf, scanner.Err() +} + +// ParseParams reads a shorewall params file and extracts variable assignments. +// This is simplified — it handles VAR=value lines but not full shell evaluation. +func ParseParams(path string) (map[string]string, error) { + f, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer f.Close() + + params := make(map[string]string) + scanner := bufio.NewScanner(f) + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + idx := strings.IndexByte(line, '=') + if idx < 0 { + continue + } + key := strings.TrimSpace(line[:idx]) + val := strings.TrimSpace(line[idx+1:]) + val = strings.Trim(val, "\"'") + if key != "" && !strings.ContainsAny(key, " \t$(){}") { + params[key] = val + } + } + return params, scanner.Err() +} + +// field returns the nth field (0-based) from a row, or "-" if missing. +func field(row []string, n int) string { + if n >= len(row) { + return "-" + } + return row[n] +} + +// isDash returns true if the field is empty or a dash. +func isDash(s string) bool { + return s == "" || s == "-" +} + +// DirExists checks if a shorewall or shorewall6 config directory looks valid. +func DirExists(dir string) bool { + for _, name := range []string{"zones", "shorewall.conf", "shorewall6.conf"} { + info, err := os.Stat(filepath.Join(dir, name)) + if err == nil && !info.IsDir() { + return true + } + } + return false +} + +// IsIPv6Dir returns true if the directory contains a shorewall6 config. +func IsIPv6Dir(dir string) bool { + info, err := os.Stat(filepath.Join(dir, "shorewall6.conf")) + return err == nil && !info.IsDir() +} diff --git a/internal/shorewall/parser_test.go b/internal/shorewall/parser_test.go new file mode 100644 index 0000000..424ebe5 --- /dev/null +++ b/internal/shorewall/parser_test.go @@ -0,0 +1,413 @@ +package shorewall + +import ( + "os" + "path/filepath" + "testing" +) + +func TestParseFile_Basic(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "zones") + + content := `# This is a comment +fw firewall + +net ipv4 +loc ipv4 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + rows, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if len(rows) != 3 { + t.Fatalf("expected 3 rows, got %d", len(rows)) + } + if rows[0][0] != "fw" || rows[0][1] != "firewall" { + t.Errorf("row 0 = %v, want [fw firewall]", rows[0]) + } + if rows[1][0] != "net" || rows[1][1] != "ipv4" { + t.Errorf("row 1 = %v, want [net ipv4]", rows[1]) + } +} + +func TestParseFile_BlankLinesAndComments(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test") + + content := ` +# full line comment + # indented comment + +field1 field2 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + rows, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if rows[0][0] != "field1" || rows[0][1] != "field2" { + t.Errorf("row 0 = %v, want [field1 field2]", rows[0]) + } +} + +func TestParseFile_Continuation(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test") + + content := `first \ +second third +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + rows, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(rows)) + } + if len(rows[0]) != 3 { + t.Fatalf("expected 3 fields, got %d: %v", len(rows[0]), rows[0]) + } + if rows[0][0] != "first" || rows[0][1] != "second" || rows[0][2] != "third" { + t.Errorf("row 0 = %v, want [first second third]", rows[0]) + } +} + +func TestParseFile_QuestionDirective(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test") + + content := `?COMMENT this is a comment directive +field1 field2 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + rows, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + // ?COMMENT lines should be skipped (starts with ?) + if len(rows) != 1 { + t.Fatalf("expected 1 row, got %d: %v", len(rows), rows) + } + if rows[0][0] != "field1" { + t.Errorf("expected field1, got %s", rows[0][0]) + } +} + +func TestParseFile_SectionMarker(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "test") + + // Note: ?SECTION lines are caught by the generic "?" prefix handler + // before the SECTION check, so only bare SECTION lines produce markers. + content := `SECTION NEW +ACCEPT net fw tcp 22 +SECTION ESTABLISHED +ACCEPT all all +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + rows, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile: %v", err) + } + + if len(rows) != 4 { + t.Fatalf("expected 4 rows, got %d: %v", len(rows), rows) + } + + // First row should be the SECTION marker + if rows[0][0] != "?SECTION" || rows[0][1] != "NEW" { + t.Errorf("row 0 = %v, want [?SECTION NEW]", rows[0]) + } + + // Second row is a regular rule + if rows[1][0] != "ACCEPT" { + t.Errorf("row 1[0] = %s, want ACCEPT", rows[1][0]) + } + + // Third row is SECTION ESTABLISHED + if rows[2][0] != "?SECTION" || rows[2][1] != "ESTABLISHED" { + t.Errorf("row 2 = %v, want [?SECTION ESTABLISHED]", rows[2]) + } + + // Fourth row is the rule + if rows[3][0] != "ACCEPT" { + t.Errorf("row 3[0] = %s, want ACCEPT", rows[3][0]) + } +} + +func TestParseFile_NotExist(t *testing.T) { + rows, err := ParseFile("/nonexistent/path/zones") + if err != nil { + t.Fatalf("expected nil error for nonexistent file, got %v", err) + } + if rows != nil { + t.Fatalf("expected nil rows, got %v", rows) + } +} + +func TestParseConf(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "shorewall.conf") + + content := `# Shorewall config +IP_FORWARDING=Yes +LOG_LEVEL=info +STARTUP_ENABLED=Yes +QUOTED_VALUE="some value" +SINGLE_QUOTED='another' +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + conf, err := ParseConf(path) + if err != nil { + t.Fatalf("ParseConf: %v", err) + } + + tests := map[string]string{ + "IP_FORWARDING": "Yes", + "LOG_LEVEL": "info", + "STARTUP_ENABLED": "Yes", + "QUOTED_VALUE": "some value", + "SINGLE_QUOTED": "another", + } + for k, want := range tests { + got, ok := conf[k] + if !ok { + t.Errorf("key %q not found in conf", k) + continue + } + if got != want { + t.Errorf("conf[%q] = %q, want %q", k, got, want) + } + } +} + +func TestParseConf_CommentsAndBlanks(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "conf") + + content := ` +# comment +KEY1=val1 + +# another comment +KEY2=val2 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + conf, err := ParseConf(path) + if err != nil { + t.Fatalf("ParseConf: %v", err) + } + if len(conf) != 2 { + t.Fatalf("expected 2 entries, got %d", len(conf)) + } +} + +func TestParseConf_NoEquals(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "conf") + + content := `NOEQUALS +KEY=val +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + conf, err := ParseConf(path) + if err != nil { + t.Fatalf("ParseConf: %v", err) + } + if len(conf) != 1 { + t.Fatalf("expected 1 entry (lines without = skipped), got %d", len(conf)) + } + if conf["KEY"] != "val" { + t.Errorf("conf[KEY] = %q, want %q", conf["KEY"], "val") + } +} + +func TestParseParams(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "params") + + content := `# params file +NET_IF=eth0 +LOC_IF=eth1 +NET_ADDR=192.168.1.0/24 +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + params, err := ParseParams(path) + if err != nil { + t.Fatalf("ParseParams: %v", err) + } + if params["NET_IF"] != "eth0" { + t.Errorf("NET_IF = %q, want %q", params["NET_IF"], "eth0") + } + if params["LOC_IF"] != "eth1" { + t.Errorf("LOC_IF = %q, want %q", params["LOC_IF"], "eth1") + } + if params["NET_ADDR"] != "192.168.1.0/24" { + t.Errorf("NET_ADDR = %q, want %q", params["NET_ADDR"], "192.168.1.0/24") + } +} + +func TestParseParams_NotExist(t *testing.T) { + params, err := ParseParams("/nonexistent/params") + if err != nil { + t.Fatalf("expected nil error for nonexistent file, got %v", err) + } + if params != nil { + t.Fatalf("expected nil params, got %v", params) + } +} + +func TestParseParams_SkipsShellSyntax(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "params") + + content := `GOOD_VAR=value +$(bad)=nope +KEY WITH SPACES=no +ALSO_GOOD=yes +` + if err := os.WriteFile(path, []byte(content), 0644); err != nil { + t.Fatal(err) + } + + params, err := ParseParams(path) + if err != nil { + t.Fatalf("ParseParams: %v", err) + } + if _, ok := params["$(bad)"]; ok { + t.Error("should skip key with shell metacharacters") + } + if params["GOOD_VAR"] != "value" { + t.Errorf("GOOD_VAR = %q, want %q", params["GOOD_VAR"], "value") + } + if params["ALSO_GOOD"] != "yes" { + t.Errorf("ALSO_GOOD = %q, want %q", params["ALSO_GOOD"], "yes") + } +} + +func TestSplitFields(t *testing.T) { + tests := []struct { + input string + want []string + }{ + {"ACCEPT net fw tcp 22", []string{"ACCEPT", "net", "fw", "tcp", "22"}}, + {"ACCEPT net fw # inline comment", []string{"ACCEPT", "net", "fw"}}, + {"ACCEPT net fw #comment", []string{"ACCEPT", "net", "fw"}}, + {"single", []string{"single"}}, + {" spaced out ", []string{"spaced", "out"}}, + } + + for _, tt := range tests { + got := splitFields(tt.input) + if len(got) != len(tt.want) { + t.Errorf("splitFields(%q) = %v (len %d), want %v (len %d)", + tt.input, got, len(got), tt.want, len(tt.want)) + continue + } + for i := range got { + if got[i] != tt.want[i] { + t.Errorf("splitFields(%q)[%d] = %q, want %q", tt.input, i, got[i], tt.want[i]) + } + } + } +} + +func TestIsDash(t *testing.T) { + tests := []struct { + input string + want bool + }{ + {"-", true}, + {"", true}, + {"eth0", false}, + {"tcp", false}, + {"--", false}, + } + + for _, tt := range tests { + got := isDash(tt.input) + if got != tt.want { + t.Errorf("isDash(%q) = %v, want %v", tt.input, got, tt.want) + } + } +} + +func TestDirExists(t *testing.T) { + t.Run("with zones file", func(t *testing.T) { + dir := t.TempDir() + zonesPath := filepath.Join(dir, "zones") + if err := os.WriteFile(zonesPath, []byte("fw firewall\n"), 0644); err != nil { + t.Fatal(err) + } + + if !DirExists(dir) { + t.Error("DirExists should return true when zones file exists") + } + }) + + t.Run("with shorewall.conf only", func(t *testing.T) { + dir := t.TempDir() + confPath := filepath.Join(dir, "shorewall.conf") + if err := os.WriteFile(confPath, []byte("IP_FORWARDING=Yes\n"), 0644); err != nil { + t.Fatal(err) + } + + if !DirExists(dir) { + t.Error("DirExists should return true when shorewall.conf exists") + } + }) + + t.Run("empty directory", func(t *testing.T) { + dir := t.TempDir() + + if DirExists(dir) { + t.Error("DirExists should return false for empty directory") + } + }) + + t.Run("zones is a directory not a file", func(t *testing.T) { + dir := t.TempDir() + zonesDir := filepath.Join(dir, "zones") + if err := os.Mkdir(zonesDir, 0755); err != nil { + t.Fatal(err) + } + + // zones exists but is a directory, and no shorewall.conf + if DirExists(dir) { + t.Error("DirExists should return false when zones is a directory and no shorewall.conf") + } + }) +} diff --git a/scripts/test-migration.sh b/scripts/test-migration.sh new file mode 100755 index 0000000..1f49f82 --- /dev/null +++ b/scripts/test-migration.sh @@ -0,0 +1,70 @@ +#!/bin/bash +set -euo pipefail + +TOMSWALL="$(dirname "$0")/../tomswall" +TMPDIR="$(mktemp -d /tmp/tomswall-test.XXXXXX)" +trap "rm -rf $TMPDIR" EXIT + +echo "=== tomswall migration test ===" +echo "Temp directory: $TMPDIR" +echo + +# Step 1: Save current iptables state +echo "--- Step 1: Saving current iptables/nftables state ---" +if command -v iptables-save &>/dev/null; then + iptables-save > "$TMPDIR/iptables-current.txt" 2>/dev/null || true +fi +if command -v nft &>/dev/null; then + nft list ruleset > "$TMPDIR/nft-current.txt" 2>/dev/null || true +fi +echo "Saved to $TMPDIR/iptables-current.txt and $TMPDIR/nft-current.txt" +echo + +# Step 2: Migrate shorewall config to YAML +echo "--- Step 2: Migrating /etc/shorewall to YAML ---" +"$TOMSWALL" migrate /etc/shorewall -o "$TMPDIR/migrated.yaml" 2>&1 +echo "Migrated config written to $TMPDIR/migrated.yaml" +echo + +# Step 3: Also output JSON for comparison +echo "--- Step 3: Migrating /etc/shorewall to JSON ---" +"$TOMSWALL" migrate /etc/shorewall -f json -o "$TMPDIR/migrated.json" 2>&1 +echo "JSON config written to $TMPDIR/migrated.json" +echo + +# Step 4: Validate the migrated config +echo "--- Step 4: Validating migrated YAML config ---" +"$TOMSWALL" validate -c "$TMPDIR/migrated.yaml" 2>&1 || true +echo + +# Step 5: Validate reading from shorewall directory directly +echo "--- Step 5: Validating shorewall directory directly ---" +"$TOMSWALL" validate -c /etc/shorewall 2>&1 || true +echo + +# Step 6: Plan against migrated config (shows what tomswall would do) +echo "--- Step 6: Planning changes from migrated config ---" +"$TOMSWALL" plan -c "$TMPDIR/migrated.yaml" 2>&1 || true +echo + +# Step 7: Plan against shorewall directory +echo "--- Step 7: Planning changes from shorewall directory ---" +"$TOMSWALL" plan -c /etc/shorewall 2>&1 || true +echo + +# Step 8: Show the migrated YAML +echo "--- Step 8: Migrated YAML (first 100 lines) ---" +head -100 "$TMPDIR/migrated.yaml" +echo +echo "..." +echo + +echo "=== Test complete ===" +echo "Files saved in $TMPDIR:" +ls -la "$TMPDIR/" +echo +echo "To keep files, copy from: $TMPDIR" +echo "(Directory will be cleaned up on script exit)" +echo +echo "Press Enter to clean up, or Ctrl-C to keep files." +read -r diff --git a/tomswall.example.yaml b/tomswall.example.yaml new file mode 100644 index 0000000..452f0d1 --- /dev/null +++ b/tomswall.example.yaml @@ -0,0 +1,285 @@ +# tomswall configuration +# Spiritual successor to shorewall — manages nftables directly + +settings: + # address_family: inet (default), ip (IPv4 only), ip6 (IPv6 only) + address_family: inet + ip_forwarding: true + log_level: info + table_name: tomswall + implicit_continue: false + +# Named port groups — reusable port+protocol combos referenced in rules +portgroups: + web: + proto: tcp + ports: [80, 443] + dns_udp: + proto: udp + ports: [53] + dns_tcp: + proto: tcp + ports: [53] + ssh: + proto: tcp + ports: [22] + mail: + proto: tcp + ports: [25, 465, 587, 993, 995] + high_ports: + proto: tcp + ports: ["1024-65535"] + +# Security zones (replaces /etc/shorewall/zones) +# Child zones are listed before parents; nesting via parents field. +zones: + fw: + type: firewall + net: + type: ip + loc: + type: ip + dmz: + type: ip + # Example nested zone: sam is a sub-zone of net + # sam: + # type: ip + # parents: [net] + +# Interface-to-zone mappings (replaces /etc/shorewall/interfaces) +interfaces: + - zone: net + interface: eth0 + options: + dhcp: true + nosmurfs: true + routefilter: 1 + logmartians: true + - zone: loc + interface: eth1 + options: + mss: 1400 + - zone: dmz + interface: eth2 + +# Host definitions (replaces /etc/shorewall/hosts) +# Only needed when multiple zones share an interface. +hosts: + - zone: loc + interface: eth1 + addresses: + - 192.168.1.0/24 + +# Default zone-to-zone policies (replaces /etc/shorewall/policy) +# Evaluated in order; first match wins. +# Intra-zone traffic is implicitly ACCEPTed unless overridden with all+. +policy: + - source: fw + dest: all + action: accept + - source: loc + dest: net + action: accept + - source: loc + dest: fw + action: accept + - source: net + dest: all + action: drop + log: info + - source: all + dest: all + action: reject + log: info + +# Specific traffic rules (replaces /etc/shorewall/rules) +rules: + - action: accept + source: loc + dest: fw + portgroup: ssh + + - action: accept + source: loc + dest: net + portgroup: dns_udp + - action: accept + source: loc + dest: net + portgroup: dns_tcp + + - action: accept + source: net + dest: dmz + portgroup: web + + - action: accept + source: loc + dest: fw + proto: icmp + + - action: drop + source: net + dest: all + proto: icmp + + # DNAT: forward port 2222 from net to loc host on port 22 + # - action: dnat + # source: net + # dest: loc:192.168.1.3:22 + # proto: tcp + # dport: [2222] + + # Time-restricted rule example + # - action: accept + # source: loc + # dest: net + # portgroup: web + # time: + # weekdays: [Mon, Tue, Wed, Thu, Fri] + # start: "08:00" + # stop: "18:00" + +# Source NAT rules (replaces /etc/shorewall/snat) +# First match wins. +snat: + - action: masquerade + source: 192.168.1.0/24 + dest: eth0 + + # Load-balanced SNAT across multiple addresses + # - action: snat + # address: 1.1.1.1 + # source: 192.168.1.0/24 + # dest: eth0 + # probability: 0.5 + # - action: snat + # address: 1.1.1.2 + # source: 192.168.1.0/24 + # dest: eth0 + +# One-to-one static NAT (replaces /etc/shorewall/nat) +# Maps an external IP to an internal IP bidirectionally. +# DNAT rules take precedence over static NAT. +# nat: +# - external: 203.0.113.10 +# interface: eth0 +# internal: 192.168.1.10 +# all_interfaces: false +# local: true + +# Network-to-network address mapping (replaces /etc/shorewall/netmap) +# Maps one subnet to another at the IP header level. +# netmap: +# - type: dnat +# net1: 10.0.0.0/24 +# interface: eth0 +# net2: 192.168.1.0/24 +# - type: snat +# net1: 192.168.1.0/24 +# interface: eth0 +# net2: 10.0.0.0/24 + +# Variables (replaces /etc/shorewall/params) +# Simple key-value substitution for reuse across config. +# vars: +# NET_IF: eth0 +# DMZ_NET: 10.0.0.0/24 + +# Connection tracking control (replaces /etc/shorewall/conntrack) +# Bypass conntrack for high-volume traffic or assign CT helpers. +# conntrack: +# - action: notrack +# source: net +# dest: fw +# proto: udp +# dport: [53] +# comment: "Skip conntrack for DNS" +# - action: helper +# source: loc +# dest: net +# proto: tcp +# dport: [21] +# helper: ftp +# comment: "FTP conntrack helper" + +# Blacklist/whitelist rules (replaces /etc/shorewall/blrules) +# Processed before normal rules. ACCEPT/WHITELIST exempt from remaining blrules. +# blrules: +# - action: drop +# source: net:192.88.99.1 +# dest: all +# comment: "Block known bad host" +# - action: whitelist +# source: net:70.90.191.120/29 +# dest: all +# comment: "Trusted range" + +# VPN tunnels (replaces /etc/shorewall/tunnels) +# Allows encapsulated traffic to pass; actual tunnel traffic uses normal rules. +# tunnels: +# - type: ipsec +# zone: net +# gateways: [4.33.99.124] +# - type: openvpn:udp +# zone: net +# gateways: [0.0.0.0/0] +# gateway_zones: [vpn] +# port: 1194 + +# Routing rules (replaces /etc/shorewall/rtrules) +# Directs traffic to specific provider routing tables. +# rtrules: +# - source: eth1 +# provider: ISP1 +# priority: 1000 +# - dest: 10.8.0.0/24 +# provider: main +# priority: 1000 +# comment: "OpenVPN traffic stays in main table" + +# Stopped rules (replaces /etc/shorewall/stoppedrules) +# Traffic permitted when the firewall is stopped. +# stoppedrules: +# - action: accept +# source: eth1 +# dest: $FW +# comment: "Allow local access when stopped" +# - action: accept +# source: $FW +# dest: eth1 +# comment: "Allow firewall to reach LAN when stopped" + +# Multi-ISP / policy routing (replaces /etc/shorewall/providers) +# providers: +# - name: ISP1 +# number: 1 +# mark: 0x10000 +# duplicate: main +# interface: eth0 +# gateway: 206.124.146.254 +# options: +# track: true +# balance: 1 +# copy: [eth2] +# - name: ISP2 +# number: 2 +# mark: 0x20000 +# duplicate: main +# interface: eth3 +# gateway: 130.252.99.254 +# options: +# track: true +# balance: 1 +# copy: [eth2] + +# Proxy NDP (replaces /etc/shorewall6/proxyndp) +# IPv6 equivalent of Proxy ARP — answers NDP queries on behalf of another host. +# proxyndp: +# - address: "2001:db8::100" +# interface: eth1 +# external: eth0 +# persistent: true +# - address: "fd10::1" +# external: eth0 +# haveroute: true