Establish tomswall codebase and add the control-plane agent #1

Merged
benvin merged 6 commits from benvin/agent into main 2026-07-20 22:24:44 +10:00
60 changed files with 13505 additions and 1 deletions
+2
View File
@@ -0,0 +1,2 @@
/tomswall
*.test
+24
View File
@@ -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
+18
View File
@@ -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
+18
View File
@@ -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
+19
View File
@@ -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
+457
View File
@@ -0,0 +1,457 @@
# tomswall control plane — design
Status: draft / theory-craft. No implementation yet.
This document specifies a **fleet control plane** for tomswall: a central,
Terraform-managed API that lets you declare zones, address groups, and firewall
policy **once** and have every device in a routed estate enforce a provably
consistent posture. tomswall itself remains the per-host nftables engine; this
adds an orchestration layer above the fleet.
---
## 1. Motivation
Today each tomswall host owns a complete, independent `tomswall.yaml` — its own
zones, interfaces, policy, and rules. In an estate of many firewalls and routers
that is unmanageable: a single logical intent ("hosts in zone A may reach zone B
on tcp/22") has to be hand-translated into per-hop rules on every device along
every possible path.
The control plane inverts this. You declare the **intent once**; the API compiles
it into the concrete rules each device needs and serves each device its rendered
config. A connection that crosses several firewalls —
`src → rt1 → rt2 → rt3 → dest` — is expressed as a single rule.
Key environment facts this design is built around:
- Internal routing is **dynamic (FRR)** with **ECMP** — paths are not stable and
cannot be pinned.
- **NAT happens only at the edge**; the internal core is purely routed.
- Devices fall into two classes: **routers** (FRR core) and **firewalls** (zone
boundary enforcement).
---
## 2. Terminology
| term | meaning |
|---|---|
| **zone** | A fleet-global named network segment (a set of subnets). Replaces per-host zones. |
| **subzone** | A zone nested inside a parent zone; its subnets must be ⊂ the parent's. |
| **address group** | A fleet-global named set of addresses → materialized as an nftables named set (ipset). Population source is `static`, `dns`, or `asn`. |
| **fabric** | A routing domain: a group of routers (and the firewall edges attached to it) that share an FRR routing fabric. |
| **binding** | A per-device mapping of a global zone to that device's local interface(s). The only legitimately host-specific object. |
| **intent / rule** | A fleet-global `source → dest` policy statement, matched on zones and/or address groups. |
| **generation** | A monotonic version stamp on rendered config; devices report the generation they have applied. |
---
## 3. Architecture
```
terraform ──▶ tomswallapi ◀── peers with FRR (BGP-LS / BMP)
│ · inventory + topology (devices, zones, fabrics, bindings)
│ · match/address-group model (static/dns/asn ipsets)
│ · compiler: intents → per-device tomswall.yaml
│ · reachability validation from routing state
│ · per-device config + set-delta feed (authenticated)
fw-a rt1 rt2 rt3 fw-b ── each runs `tomswall agent`:
pull config → differential apply → report generation
+ on-device resolver maintains dns ipsets
```
Three moving parts:
1. **`tomswallapi`** — the control plane. Stores the model (Postgres, per the
house stack), peers with FRR for routing/reachability, compiles intents into
per-device configs, and serves them.
2. **`tomswall agent`** — a new pull mode on the existing binary. Periodically
fetches its rendered config, runs the existing differential `apply`, maintains
dns-backed ipsets via an on-device resolver, and reports its applied generation.
3. **FRR peering** — the API consumes routing state (BGP-LS for topology, BMP /
route monitoring for prefix reachability) so it can validate that a zone's
subnet really originates where its firewall claims, and scope which routers an
intent must touch.
---
## 4. Data model: two layers
The founding constraint:
> Every host may have a different zone→interface mapping, but all share the same
> zones, address groups, policies, and rules.
That splits the model into two layers with a hard wall between them.
### 4.1 Global layer (defined once, byte-identical everywhere)
`zones` · `subzones` · `address_groups` · `portgroups` · `policies` · `rules` ·
`blrules` · `conntrack` · `secmarks` · `vars` · `fabrics`.
**None of these ever names an interface.** The rule `zone-a → zone-b tcp/22` is
the same object on every device.
### 4.2 Device layer (the only per-host freedom)
`class` (router/firewall) · `fabric` membership · per-device `settings` overrides ·
`resolver` config · and the **binding table** (`zone → interface(s)`).
The rendered per-device `tomswall.yaml` = **global rules projected through that
device's binding table**. Same intent, different local interface resolution:
```
GLOBAL (shared): rule zone-a → zone-b tcp/22 accept
zones zone-a=10.1.0.0/24 zone-b=10.4.0.0/24
fw-a bindings: zone-a → eth1 ; <core> → bond0 (fabric "core")
fw-b bindings: zone-b → ens5 ; <core> → ens4 (fabric "core")
rt3: no zone bindings ; fabric "core"
```
`zone-a` is *attached* on fw-a (resolved via its eth1 binding) but *remote* on
fw-b and rt3 (resolved purely by subnet match on the core side). That asymmetry is
fine because the compiled rule is interface-agnostic (§7).
---
## 5. Device classes and fabrics
- **firewall** — a zone boundary. Zones "live" on firewall interfaces; a firewall
enforces with default-drop between zones and is where the real policy edge sits.
- **router** — an FRR core member belonging to a **fabric**. Enforces
interface-agnostically (any interface, address-matched) because ECMP means the
path is not pinnable.
A **fabric** is a routing domain with an `enforce_on_routers` flag:
- `enforce_on_routers = false` (transparent transit) — routers route freely for
internal ranges and rely on conntrack; only boundary firewalls enforce the
intent. Fewest rules; no in-core containment.
- `enforce_on_routers = true` (defense-in-depth) — every router in the fabric also
carries the intent as an interface-agnostic permit with default-drop transit.
Every hop is a checkpoint; contains a compromised core node.
Per-fabric choice lets a small trusted core run transparent while a larger or
multi-tenant core runs defense-in-depth.
---
## 6. The match model
Source and dest use a **shorewall-style** grammar. Each direction is a comma-list
of elements; **within an element a zone gates the selector (AND); across elements
the list is a union (OR)**.
```
element := zone # bare zone — the zone-to-zone base case
| zone ":" selector # zone AND selector (selector must be paired)
selector := "+" ipset # named address group (static / dns / asn)
| "&" fqdn-group # (fqdn groups are just dns-typed ipsets; "+" also accepted)
source = "loc, net:+asn_cloudflare, dmz:+partner_api"
# loc OR (net AND asn_cloudflare) OR (dmz AND partner_api)
```
Rules:
- **Bare zone → legal.** `loc → net`, the zone-to-zone base case.
- **`zone:+selector` → legal.** The selector must always be paired with a zone.
- **Bare selector → rejected at plan time.** No floating `+ipset` / `asn:` without
a zone.
Why the pairing is structural, not cosmetic: an internet-facing zone like `net`
has no finite subnet — it is "everything else" — so it can never stand as a clean
address match on its own. Pairing supplies the missing halves: **the zone gives
direction/interface, the selector gives concrete addresses.**
```
rule: loc → net:+asn_cloudflare tcp/443
edge fw render: oif=<net-iface> daddr @asn_cloudflare tcp dport 443 accept
# zone `net` → the internet-facing binding;
# asn_cloudflare → the actual prefixes to match
```
Internal zones (which have subnets) may stand bare; internet/edge zones
effectively require a selector.
---
## 7. Compilation
### 7.1 Interface-agnostic, address-matched rules (the ECMP unlock)
Because FRR picks paths dynamically and load-balances across ECMP, rules must
**not** be compiled to per-hop `iif/oif`. Every device carries the same rule
matched on `saddr ∈ source, daddr ∈ dest, proto, port` in the forward chain, on
**any interface**:
```
rule: zone-a (10.1.0.0/24 @ fw-a) → zone-b (10.4.0.0/24 @ fw-b) tcp/22
fw-a (firewall): forward saddr 10.1.0.0/24 daddr 10.4.0.0/24 tcp dport 22 accept
rt1…N (routers): forward saddr 10.1.0.0/24 daddr 10.4.0.0/24 tcp dport 22 accept
fw-b (firewall): forward saddr 10.1.0.0/24 daddr 10.4.0.0/24 tcp dport 22 accept
```
- **Return traffic needs no rules.** Each device is independently stateful, so
every hop's own `ct state established,related accept` handles the reply. Only the
forward direction is emitted.
- **ECMP and asymmetric routing just work** — flow #1 may go rt1→rt3, flow #2
rt1→rt2→rt3, and the return may take a third path; every device it touches
already permits the 5-tuple and holds its own conntrack state.
### 7.2 Over-approximation is safe → no exact path computation
Because rules are interface-agnostic and stateful, programming a permit on a
router the traffic never crosses just leaves an unused rule. So the compiler never
needs FRR's *actual* chosen path — only **which fabric(s) could carry A↔B**, which
is coarse and static-friendly. FRR peering (§3) is used to *narrow* the router set
(and to validate zone origins), not to pin a path.
### 7.3 nftables set / map form for scale
Every zone and address group is a **named nft set** (`flags interval` for CIDRs).
Rules reference sets by name. Where many intents land on the same core routers,
compile into **sets / verdict maps** keyed on `(saddr, daddr, proto, dport)` rather
than long linear chains, so match cost stays sub-linear. `zone:+ipset` compiles to
a conjunction of two set lookups (`ip saddr @z_zone` **and** `ip saddr @g_ipset`);
a multi-element list becomes multiple rule lines sharing a verdict.
The critical property: **membership is the only thing that churns; rules are
stable.** Adding/removing an address is a set-element op — no rule reload, no chain
rebuild, existing connections preserved.
---
## 8. Address groups (ipsets)
An address group is always an nftables named set. What differs is only how its
members are populated:
| type | member source | resolved where | refresh |
|---|---|---|---|
| `static` | explicit CIDRs/IPs | API (constant) | n/a |
| `dns` | FQDNs → A/AAAA | **on-device resolver** | per record TTL |
| `asn` | ASN(s) → prefixes via iplocate | **central API** | configurable, default 24h |
### 8.1 ASN groups
An ASN group is defined **once, globally**, and materializes a set named by
convention `asn_<isp>` (friendly) or `asn_<number>`. An ISP may own several ASNs;
one group merges them into one set. Rules reference it like any ipset —
`net:+asn_cloudflare` — there is **no** special `asn:` selector token.
```hcl
resource "tomswall_address_group" "asn_cloudflare" {
type = "asn"
asns = ["13335", "209242"]
refresh = "24h" # default; configurable per group / globally
} # → materializes nft set asn_cloudflare
```
Expansion is **centralized**: the API holds a single iplocate key (in Vault),
calls the ASN data-type endpoint, caches prefixes per ASN, refreshes on the TTL,
and ships prefix deltas as set-element ops. Devices never call iplocate. ASN
membership is therefore **fleet-uniform**.
### 8.2 DNS groups and the per-host resolver
DNS groups resolve **on-device**, so each site honors its own split-horizon /
geo-DNS view. The resolver used follows a precedence chain:
```
per-device resolver override → fleet default_resolver → system /etc/resolv.conf
```
```hcl
resource "tomswall_device" "fw_a" { resolver = ["10.1.0.53", "10.1.0.54"] }
# global: default_resolver = ["10.0.0.53"] (or "system")
```
A device in site A resolves `vpn.corp` against site A's resolver and populates
*its own* set from that view; site B may legitimately differ. DNS membership is
**not** guaranteed fleet-uniform — only the rules and set names are. Record TTL is
used as the nft **element timeout**, so stale entries self-evict and the resolver
refreshes before expiry.
### 8.3 Set lifecycle notes
- v4 and v6 members are held in parallel family sets (or inet-family sets).
- Element deltas (`nft add/delete element`) are a separate, lighter feed than rule
rollout; they never bump the rule generation.
---
## 9. NAT / masquerade / netmap / policy — full parity
The control plane is a **superset** of the tomswall config model, never a lossy
subset: the rendered per-device output *is* a full `tomswall.yaml`, so anything
tomswall can express is reachable. **Every section is a typed, first-class
resource — there is no raw-YAML escape hatch.** Sections are handled in one of
three tiers:
- **Global-compiled** — defined once, projected identically to every relevant
device: `policy` (default posture), `rules`, `portgroups`, `zones`/subzones,
`blrules`, `conntrack`, `secmarks`, `vars`.
- **Global-intent, device-resolved** — defined once against zones; each device
resolves to its own interfaces via its binding table: `snat`/**masquerade**,
`netmap`, `nat` (1:1). Example: `masquerade source=loc egress=net` lands only on
devices that bind **both** `loc` and `net` (i.e. edge firewalls); interior
routers have no `net` binding and skip it automatically.
- **Per-device** — declared against a device (or a selector): `host`, `provider`,
`rtrules`, `route`, `tunnel`, `stopped_rule`, `proxy_arp`/`ndp`, `arp_rule`,
`maclist`, `accounting`, `mangle`, `tc_*`.
---
## 10. Terraform resource catalog
### Fleet / topology
- `tomswall_device` — name, `class` (router/firewall), `fabric`, per-device
`settings` overrides, `resolver`
- `tomswall_fabric` — routing domain, `enforce_on_routers`
- `tomswall_binding` — zone→interface map (per device+zone)
- `tomswall_settings` — global defaults (address_family, log_level, ip_forwarding,
table_name)
### Global-compiled
- `tomswall_zone` — subnets, `parent` (subzones)
- `tomswall_address_group` — ipset; `type` = static/dns/asn
- `tomswall_portgroup`
- `tomswall_policy` — default zone→zone posture, `priority`
- `tomswall_rule` — the `zone:+ipset` / `&fqdn` intents
- `tomswall_blrule`
- `tomswall_conntrack`
- `tomswall_secmark`
- `tomswall_var`
### Global-intent, device-resolved
- `tomswall_snat` — masquerade/SNAT (auto-scopes to devices binding both zones)
- `tomswall_netmap` — anchored subnet↔subnet
- `tomswall_nat` — 1:1 static, bound to the device holding the public IP
### Per-device (`device` reference or a `class`/`fabric`/`all` selector)
- `tomswall_host`
- `tomswall_provider`
- `tomswall_routing_rule` (rtrules)
- `tomswall_route`
- `tomswall_tunnel`
- `tomswall_stopped_rule`
- `tomswall_proxy_arp` / `tomswall_proxy_ndp`
- `tomswall_arp_rule`
- `tomswall_maclist`
- `tomswall_accounting`
- `tomswall_mangle`
- `tomswall_tc_device` / `tomswall_tc_class` / `tomswall_tc_filter` /
`tomswall_tc_interface` / `tomswall_tc_priority`
### Data sources
- `tomswall_device_config` — rendered `tomswall.yaml` preview for a device
- rule fanout preview — which devices an intent will touch (surfaced in `plan`)
Per-device resources accept **either a single `device` or a selector** so common
objects (a shared static route, a provider) are declared once, preserving the
define-once ethos even in the local tier.
---
## 11. Agent protocol
`tomswall agent` (or a systemd timer invoking a pull) does:
1. **Pull** its rendered config by `device_id` from the API (authenticated).
2. Write it to a **local cache file**.
3. Run the existing **differential `apply`** (compute diff vs live nftables, apply
only the delta atomically; never tears the firewall down).
4. Maintain **dns ipsets** via the on-device resolver (add/delete elements on TTL).
5. **Report** the applied `generation` back to the API.
### 11.1 Do not fail closed
On API-unreachable: **keep the cache, re-apply it (idempotent no-op), never flush
to deny.** Existing rules ride through control-plane outages untouched; only
*changes* require the API. This is a deliberate availability choice.
### 11.2 Rollout & convergence
A rule spanning several devices rolls out as each device pulls independently.
Mid-rollout the connection is blocked at whichever hop has not yet pulled — i.e.
**fail-closed for adds** (safe). Config is **generation-stamped** and devices
report the generation applied, giving a fleet-wide "converged / N behind" view.
### 11.3 tomswall engine changes required
- A **pull/agent mode** (`tomswall agent`, or `source: http` in the config loader).
- The **interface-agnostic rule form** (match `saddr`/`daddr` with no `iif/oif`).
- **Resolver config** and on-device dns-set maintenance.
- **Ownership tags** (resource-id + generation as nft comments) so `purge` /
foreign-rule detection never fights control-plane-managed content.
---
## 12. Invariants (validated at plan time)
1. **Zones/policies/rules/groups are global-only.** A device may *bind* a zone to
an interface; it may never *define* one. No local zone namespace.
2. **Subnet→zone is globally unique.** Every subnet belongs to exactly one zone
fleet-wide; no overlaps. (Ambiguous saddr/daddr matching otherwise.)
3. **Every zone in a rule must be resolvable on every enforcing device** — either
locally bound (attached) or remote-reachable via its fabric. Unresolvable →
rejection, not a silent gap. FRR reachability proves this.
4. **Each zone is anchored at ≥1 firewall interface** — the API knows where every
zone physically enters the fabric, cross-checked against FRR-advertised origins.
5. **A firewall must bind every directly-connected zone.** A connected subnet with
no zone identity is rejected/flagged (checked against FRR-reported prefixes).
Zones the device does not attach are implicitly remote — no verbose "not here"
declarations needed.
6. **Subzones nest by containment** — a subzone's subnet ⊂ its parent's; hierarchy
is global.
7. **Interface names appear only in the binding table.** An interface name leaking
into any global object → rejection.
8. **A selector never appears without a zone.** `zone:+ipset` / `zone:&fqdn` only;
bare `+ipset` / `asn:` → rejection. Bare zones remain legal.
9. **The zone in a pair supplies direction/interface; the selector supplies
addresses** — this is what lets no-subnet zones (`net`, edge) participate.
10. **Every tomswall section is a typed resource.** No raw-YAML passthrough;
nothing bypasses validation.
11. **Config is generation-stamped**; devices report the generation applied.
---
## 13. Fail-safe semantics
- **Resolution failure keeps last-good membership.** An iplocate outage or DNS
SERVFAIL must never empty a set. (Matches the agent's don't-fail-closed stance.)
- **A genuinely-empty group (NXDOMAIN, ASN with no prefixes) makes its rule inert
and is logged/flagged** — never "match everything." An unresolvable
source/dest disables its rule loudly, never opens it.
- **Adds fail closed, the control plane fails open.** Partial rollout blocks new
flows until every hop converges; a dead API leaves the last-good posture running.
---
## 14. Security / auth
- Agents authenticate to the API (mTLS or Vault-issued per-device tokens, per the
house pattern).
- The iplocate API key and any resolver credentials live in Vault.
- The API is the single source of truth; state in Postgres.
- Compiled objects carry ownership tags so the on-device engine can distinguish
control-plane content from local/foreign rules.
---
## 15. Open questions / future work
- **FRR integration depth** — BGP-LS (topology) vs BMP / route monitoring (prefix
reachability) vs a lighter agent-reported FIB. Start with what proves zone
origin and fabric membership; deepen as needed.
- **Set-union match ergonomics** — whether multi-element source/dest compiles to
multiple rule lines or a merged interval set; membership churn is handled at the
member-set level regardless.
- **NAT along non-edge paths** — out of scope by assumption (routed core, edge-only
NAT). Revisit only if internal translation is ever introduced.
- **Multi-tenancy** — whether fabrics/zones need tenant scoping for RBAC.
+25
View File
@@ -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 ./...
+1 -1
View File
@@ -1,3 +1,3 @@
# tomswall
Spiritual successor to shorewall — nftables firewall manager using google/nftables
Spiritual successor to shorewall — nftables firewall manager using google/nftables
+77
View File
@@ -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
}
+430
View File
@@ -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
}
+21
View File
@@ -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
)
+30
View File
@@ -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=
+114
View File
@@ -0,0 +1,114 @@
package agent
import (
"context"
"fmt"
"log/slog"
"time"
"git.unkin.net/unkin/tomswall/internal/config"
"git.unkin.net/unkin/tomswall/internal/nftables"
)
// Applier applies a translated config to the firewall. Abstracted so the run
// loop is testable without touching the kernel.
type Applier interface {
Apply(ctx context.Context, cfg *config.Config) error
}
// Agent runs the pull-apply-report loop for one device.
type Agent struct {
Client *Client
Cache Cache
Interval time.Duration
Applier Applier
// Resolver overrides the DNS resolver (tests); nil derives it per-config.
Resolver *Resolver
}
// Run loops until ctx is cancelled, applying one cycle per Interval (and once
// immediately). A failed cycle is logged and retried on the next tick — the loop
// never exits on transient errors.
func (a *Agent) Run(ctx context.Context) error {
if a.Interval <= 0 {
a.Interval = time.Minute
}
t := time.NewTicker(a.Interval)
defer t.Stop()
for {
if err := a.RunOnce(ctx); err != nil {
slog.Error("agent: apply cycle failed", "err", err)
}
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C:
}
}
}
// RunOnce performs a single pull-apply-report cycle. On a fetch failure it falls
// back to the on-disk cache and re-applies it — it never fails closed.
func (a *Agent) RunOnce(ctx context.Context) error {
rc, raw, err := a.Client.FetchConfig(ctx)
if err != nil {
slog.Warn("agent: control plane unreachable, using cached config", "err", err)
cached, cerr := a.Cache.Read()
if cerr != nil {
return fmt.Errorf("read cache: %w", cerr)
}
if cached == nil {
return fmt.Errorf("control plane unreachable and no cached config: %w", err)
}
// Re-apply last known-good; do not report a generation we didn't fetch.
return a.applyConfig(ctx, cached, false)
}
if err := a.Cache.Write(raw); err != nil {
slog.Warn("agent: caching config failed", "err", err)
}
return a.applyConfig(ctx, rc, true)
}
func (a *Agent) applyConfig(ctx context.Context, rc *RenderedConfig, report bool) error {
resolver := a.Resolver
if resolver == nil {
resolver = NewResolver(rc.Resolver)
}
resolver.ExpandDNSSets(ctx, rc)
cfg, err := Translate(rc)
if err != nil {
return fmt.Errorf("translate: %w", err)
}
if err := a.Applier.Apply(ctx, cfg); err != nil {
return fmt.Errorf("apply: %w", err)
}
slog.Info("agent: applied config", "generation", rc.Generation, "rules", len(cfg.Rules))
if report {
if err := a.Client.ReportStatus(ctx, rc.Generation); err != nil {
slog.Warn("agent: reporting status failed", "err", err)
}
}
return nil
}
// EngineApplier applies via the real nftables differential engine.
type EngineApplier struct{}
// Apply computes and applies the differential change set for cfg.
func (EngineApplier) Apply(_ context.Context, cfg *config.Config) error {
engine, err := nftables.NewEngine(cfg)
if err != nil {
return fmt.Errorf("initializing nftables: %w", err)
}
changes, err := engine.Plan()
if err != nil {
return fmt.Errorf("computing changes: %w", err)
}
if changes.Empty() {
return nil
}
return engine.Apply(changes)
}
+205
View File
@@ -0,0 +1,205 @@
package agent
import (
"context"
"net/http"
"net/http/httptest"
"path/filepath"
"sync/atomic"
"testing"
"git.unkin.net/unkin/tomswall/internal/config"
)
func TestTranslateInterfaceAgnosticRule(t *testing.T) {
rc := &RenderedConfig{
Generation: 5,
Device: "fw-a",
Enforcing: true,
Settings: RenderedSettings{AddressFamily: "inet", LogLevel: "info", TableName: "tomswall"},
Bindings: map[string][]string{"zone-a": {"eth1"}},
Sets: []RenderedSet{
{Name: "asn_cloudflare", Kind: "asn", Members: []string{"104.16.0.0/13", "1.1.1.0/24"}},
},
Rules: []RenderedRule{
{
Action: "accept",
Source: []RenderedMatch{{Zone: "zone-a", Subnets: []string{"10.1.0.0/24"}}},
Dest: []RenderedMatch{{Zone: "net", Set: "asn_cloudflare"}},
Proto: "tcp",
Ports: []string{"443"},
},
},
}
cfg, err := Translate(rc)
if err != nil {
t.Fatalf("Translate: %v", err)
}
// fw zone + zone-a.
if _, ok := cfg.Zones["fw"]; !ok {
t.Error("missing firewall zone")
}
if _, ok := cfg.Zones["zone-a"]; !ok {
t.Error("missing zone-a")
}
// binding -> interface.
if len(cfg.Interfaces) != 1 || cfg.Interfaces[0].Interface != "eth1" {
t.Errorf("expected one eth1 interface, got %+v", cfg.Interfaces)
}
// 1 source addr x 2 dest addrs (asn set members) = 2 rules.
if len(cfg.Rules) != 2 {
t.Fatalf("expected 2 expanded rules, got %d: %+v", len(cfg.Rules), cfg.Rules)
}
for _, r := range cfg.Rules {
if r.Source != "all:10.1.0.0/24" {
t.Errorf("source not interface-agnostic saddr match: %q", r.Source)
}
if r.Action != config.RuleAccept || r.Proto != "tcp" || len(r.DPort) != 1 || r.DPort[0] != "443" {
t.Errorf("unexpected rule: %+v", r)
}
}
dests := map[string]bool{cfg.Rules[0].Dest: true, cfg.Rules[1].Dest: true}
if !dests["all:104.16.0.0/13"] || !dests["all:1.1.1.0/24"] {
t.Errorf("dest set members not inlined: %v", dests)
}
}
func TestTranslateBareZone(t *testing.T) {
rc := &RenderedConfig{
Enforcing: true,
Rules: []RenderedRule{{
Action: "accept",
Source: []RenderedMatch{{Zone: "zone-a", Subnets: []string{"10.1.0.0/24"}}},
Dest: []RenderedMatch{{Zone: "zone-b", Subnets: []string{"10.4.0.0/24"}}},
Proto: "tcp", Ports: []string{"22"},
}},
}
cfg, err := Translate(rc)
if err != nil {
t.Fatalf("Translate: %v", err)
}
if len(cfg.Rules) != 1 || cfg.Rules[0].Source != "all:10.1.0.0/24" || cfg.Rules[0].Dest != "all:10.4.0.0/24" {
t.Errorf("unexpected zone-to-zone rule: %+v", cfg.Rules)
}
}
func TestTranslateRejectsUnknownAction(t *testing.T) {
rc := &RenderedConfig{Enforcing: true, Rules: []RenderedRule{{Action: "bogus"}}}
if _, err := Translate(rc); err == nil {
t.Fatal("expected error for unknown action")
}
}
// fakeApplier records applied configs.
type fakeApplier struct {
count int32
lastGen int
}
func (f *fakeApplier) Apply(_ context.Context, cfg *config.Config) error {
atomic.AddInt32(&f.count, 1)
f.lastGen = len(cfg.Rules)
return nil
}
const renderedYAML = `generation: 7
device: fw-a
enforcing: true
settings:
address_family: inet
log_level: info
table_name: tomswall
bindings:
zone-a: [eth1]
rules:
- action: accept
source:
- zone: zone-a
subnets: ["10.1.0.0/24"]
dest:
- zone: zone-b
subnets: ["10.4.0.0/24"]
proto: tcp
ports: ["22"]
`
func TestRunOnceAppliesAndReports(t *testing.T) {
var reported int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == http.MethodGet && r.URL.Path == "/api/v1/devices/fw-a/config":
w.Header().Set("Content-Type", "application/yaml")
_, _ = w.Write([]byte(renderedYAML))
case r.Method == http.MethodPost && r.URL.Path == "/api/v1/devices/fw-a/status":
atomic.StoreInt64(&reported, 1)
w.WriteHeader(http.StatusNoContent)
default:
w.WriteHeader(http.StatusNotFound)
}
}))
defer srv.Close()
applier := &fakeApplier{}
a := &Agent{
Client: NewClient(srv.URL, "fw-a", "tok"),
Cache: Cache{Path: filepath.Join(t.TempDir(), "cache.yaml")},
Applier: applier,
}
if err := a.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce: %v", err)
}
if atomic.LoadInt32(&applier.count) != 1 {
t.Errorf("expected 1 apply, got %d", applier.count)
}
if atomic.LoadInt64(&reported) != 1 {
t.Error("expected status to be reported")
}
// Cache should now be populated.
if cached, err := a.Cache.Read(); err != nil || cached == nil || cached.Generation != 7 {
t.Errorf("cache not written correctly: %+v (err %v)", cached, err)
}
}
func TestRunOnceFallsBackToCacheNeverFailsClosed(t *testing.T) {
// Server always errors — the control plane is "unreachable".
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
cachePath := filepath.Join(t.TempDir(), "cache.yaml")
if err := (Cache{Path: cachePath}).Write([]byte(renderedYAML)); err != nil {
t.Fatalf("seed cache: %v", err)
}
applier := &fakeApplier{}
a := &Agent{
Client: NewClient(srv.URL, "fw-a", "tok"),
Cache: Cache{Path: cachePath},
Applier: applier,
}
// Fetch fails, but the cached config must still be applied (fail-safe).
if err := a.RunOnce(context.Background()); err != nil {
t.Fatalf("RunOnce should not error when a cache exists: %v", err)
}
if atomic.LoadInt32(&applier.count) != 1 {
t.Errorf("expected cached config to be applied, got %d applies", applier.count)
}
}
func TestRunOnceNoCacheReturnsError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
}))
defer srv.Close()
a := &Agent{
Client: NewClient(srv.URL, "fw-a", "tok"),
Cache: Cache{Path: filepath.Join(t.TempDir(), "absent.yaml")},
Applier: &fakeApplier{},
}
if err := a.RunOnce(context.Background()); err == nil {
t.Fatal("expected error when unreachable and no cache exists")
}
}
+36
View File
@@ -0,0 +1,36 @@
package agent
import (
"os"
"path/filepath"
)
// Cache persists the last known-good rendered config to disk so the agent can
// keep applying it when the control plane is unreachable (never fail closed).
type Cache struct {
Path string
}
// Write atomically stores the raw config bytes.
func (c Cache) Write(raw []byte) error {
if err := os.MkdirAll(filepath.Dir(c.Path), 0o755); err != nil {
return err
}
tmp := c.Path + ".tmp"
if err := os.WriteFile(tmp, raw, 0o600); err != nil {
return err
}
return os.Rename(tmp, c.Path)
}
// Read returns the cached config, or (nil, nil) when no cache exists yet.
func (c Cache) Read() (*RenderedConfig, error) {
raw, err := os.ReadFile(c.Path)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, err
}
return ParseRendered(raw)
}
+94
View File
@@ -0,0 +1,94 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"gopkg.in/yaml.v3"
)
// Client talks to the tomswallapi control plane for one device.
type Client struct {
BaseURL string
Device string
Token string
HTTP *http.Client
}
// NewClient builds a Client with a sane default timeout.
func NewClient(baseURL, device, token string) *Client {
return &Client{
BaseURL: baseURL,
Device: device,
Token: token,
HTTP: &http.Client{Timeout: 30 * time.Second},
}
}
// FetchConfig retrieves the device's rendered config. It returns both the parsed
// document and the raw bytes (so callers can cache exactly what was served).
func (c *Client) FetchConfig(ctx context.Context) (*RenderedConfig, []byte, error) {
url := fmt.Sprintf("%s/api/v1/devices/%s/config", c.BaseURL, c.Device)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, nil, err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Accept", "application/yaml")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, nil, err
}
if resp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("fetch config: status %d: %s", resp.StatusCode, bytes.TrimSpace(body))
}
cfg, err := ParseRendered(body)
if err != nil {
return nil, nil, err
}
return cfg, body, nil
}
// ParseRendered decodes a rendered config document (YAML, JSON is a subset).
func ParseRendered(body []byte) (*RenderedConfig, error) {
var cfg RenderedConfig
if err := yaml.Unmarshal(body, &cfg); err != nil {
return nil, fmt.Errorf("parsing rendered config: %w", err)
}
return &cfg, nil
}
// ReportStatus tells the control plane which generation this device has applied.
func (c *Client) ReportStatus(ctx context.Context, generation int64) error {
url := fmt.Sprintf("%s/api/v1/devices/%s/status", c.BaseURL, c.Device)
payload, _ := json.Marshal(map[string]int64{"generation": generation})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+c.Token)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<16))
if resp.StatusCode >= 400 {
return fmt.Errorf("report status: %d", resp.StatusCode)
}
return nil
}
+70
View File
@@ -0,0 +1,70 @@
// Package agent implements `tomswall agent`: it pulls a device's compiled config
// from tomswallapi, differentially applies it, and reports the applied generation.
// It never fails closed — if the control plane is unreachable it keeps the last
// known-good config running.
package agent
// RenderedConfig is the per-device document served by tomswallapi at
// GET /api/v1/devices/{name}/config. It mirrors the control plane's compiler
// output: interface-agnostic, address-matched rules plus named sets.
type RenderedConfig struct {
Generation int64 `yaml:"generation" json:"generation"`
Device string `yaml:"device" json:"device"`
Class string `yaml:"class" json:"class"`
Enforcing bool `yaml:"enforcing" json:"enforcing"`
Settings RenderedSettings `yaml:"settings" json:"settings"`
Resolver []string `yaml:"resolver,omitempty" json:"resolver,omitempty"`
Bindings map[string][]string `yaml:"bindings,omitempty" json:"bindings,omitempty"` // zone -> interfaces
Sets []RenderedSet `yaml:"sets,omitempty" json:"sets,omitempty"`
Rules []RenderedRule `yaml:"rules,omitempty" json:"rules,omitempty"`
Policies []RenderedPolicy `yaml:"policies,omitempty" json:"policies,omitempty"`
}
type RenderedSettings struct {
AddressFamily string `yaml:"address_family" json:"address_family"`
LogLevel string `yaml:"log_level" json:"log_level"`
IPForwarding bool `yaml:"ip_forwarding" json:"ip_forwarding"`
TableName string `yaml:"table_name" json:"table_name"`
}
// RenderedSet is an address group's nftables set. Members carries the concrete
// elements the control plane knows (static CIDRs, expanded ASN prefixes); FQDNs
// are resolved on-device; ASNs are informational (already expanded into Members).
type RenderedSet struct {
Name string `yaml:"name" json:"name"`
Kind string `yaml:"kind" json:"kind"` // static | dns | asn
Members []string `yaml:"members,omitempty" json:"members,omitempty"`
FQDNs []string `yaml:"fqdns,omitempty" json:"fqdns,omitempty"`
ASNs []string `yaml:"asns,omitempty" json:"asns,omitempty"`
Refresh string `yaml:"refresh,omitempty" json:"refresh,omitempty"`
}
// RenderedMatch is one OR'd element of a rule direction: a zone's subnets AND,
// optionally, a named set to intersect with.
type RenderedMatch struct {
Zone string `yaml:"zone" json:"zone"`
Subnets []string `yaml:"subnets,omitempty" json:"subnets,omitempty"`
Set string `yaml:"set,omitempty" json:"set,omitempty"`
}
type RenderedRule struct {
Action string `yaml:"action" json:"action"`
Source []RenderedMatch `yaml:"source" json:"source"`
Dest []RenderedMatch `yaml:"dest" json:"dest"`
Proto string `yaml:"proto,omitempty" json:"proto,omitempty"`
Ports []string `yaml:"ports,omitempty" json:"ports,omitempty"`
Log string `yaml:"log,omitempty" json:"log,omitempty"`
Comment string `yaml:"comment,omitempty" json:"comment,omitempty"`
}
type RenderedPolicy struct {
Priority int `yaml:"priority" json:"priority"`
Source string `yaml:"source" json:"source"`
Dest string `yaml:"dest" json:"dest"`
Action string `yaml:"action" json:"action"`
Log string `yaml:"log,omitempty" json:"log,omitempty"`
}
// setMembers returns the concrete address elements for a set: static/asn use
// Members; dns is resolved separately and merged in before translation.
func (s RenderedSet) staticMembers() []string { return s.Members }
+114
View File
@@ -0,0 +1,114 @@
package agent
import (
"context"
"fmt"
"log/slog"
"net"
"time"
)
// Resolver resolves dns-set FQDNs to host CIDRs on-device, honoring the
// device's configured resolver (falling back to the system resolver).
type Resolver struct {
// Servers are resolver addresses (host or host:port); empty uses the system
// resolver. The literal "system" is treated the same as empty.
Servers []string
}
// NewResolver builds a Resolver for the given server list.
func NewResolver(servers []string) *Resolver {
if len(servers) == 1 && servers[0] == "system" {
servers = nil
}
return &Resolver{Servers: servers}
}
func (r *Resolver) netResolver() *net.Resolver {
if len(r.Servers) == 0 {
return net.DefaultResolver
}
servers := r.Servers
dialer := &net.Dialer{Timeout: 5 * time.Second}
var idx int
return &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, _ string) (net.Conn, error) {
// Round-robin across configured servers for resilience.
addr := servers[idx%len(servers)]
idx++
if _, _, err := net.SplitHostPort(addr); err != nil {
addr = net.JoinHostPort(addr, "53")
}
return dialer.DialContext(ctx, network, addr)
},
}
}
// Resolve returns host CIDRs (/32 or /128) for a FQDN's A and AAAA records.
func (r *Resolver) Resolve(ctx context.Context, fqdn string) ([]string, error) {
ips, err := r.netResolver().LookupIP(ctx, "ip", fqdn)
if err != nil {
return nil, err
}
out := make([]string, 0, len(ips))
for _, ip := range ips {
if ip4 := ip.To4(); ip4 != nil {
out = append(out, ip4.String()+"/32")
} else {
out = append(out, ip.String()+"/128")
}
}
return out, nil
}
// ExpandDNSSets resolves every dns set's FQDNs and populates its Members in
// place. Resolution failures are logged and leave the prior Members untouched
// (fail-safe): a resolver outage must never empty a set.
func (r *Resolver) ExpandDNSSets(ctx context.Context, cfg *RenderedConfig) {
for i := range cfg.Sets {
set := &cfg.Sets[i]
if set.Kind != "dns" {
continue
}
var members []string
var anyErr bool
for _, fqdn := range set.FQDNs {
cidrs, err := r.Resolve(ctx, fqdn)
if err != nil {
slog.Warn("agent: dns resolution failed, keeping last-good", "set", set.Name, "fqdn", fqdn, "err", err)
anyErr = true
continue
}
members = append(members, cidrs...)
}
// Only replace membership when we resolved something; never empty a set
// on total failure.
if len(members) > 0 {
set.Members = dedup(members)
} else if anyErr {
slog.Warn("agent: dns set kept last-good members", "set", set.Name)
}
}
}
func dedup(in []string) []string {
seen := make(map[string]struct{}, len(in))
out := in[:0]
for _, s := range in {
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// validateCIDR is a small guard used by translation to skip malformed members.
func validateCIDR(s string) error {
if _, _, err := net.ParseCIDR(s); err != nil {
return fmt.Errorf("invalid CIDR %q: %w", s, err)
}
return nil
}
+166
View File
@@ -0,0 +1,166 @@
package agent
import (
"fmt"
"sort"
"git.unkin.net/unkin/tomswall/internal/config"
)
// Translate converts a control-plane RenderedConfig into a native tomswall
// config.Config that the existing differential engine can apply.
//
// The rendered model is interface-agnostic and address-matched; tomswall
// expresses that with the "all:<cidr>" source/dest form (zone "all" imposes no
// interface constraint, the CIDR is matched on saddr/daddr). Named sets are
// inlined as their concrete members: a rule element matching N source addresses
// against M dest addresses expands to N*M address-matched rules. This is a
// correct v1; native nftables set references (so membership churns without a
// rule rebuild) are a tracked follow-up.
func Translate(rc *RenderedConfig) (*config.Config, error) {
cfg := &config.Config{
Settings: config.Settings{
AddressFamily: config.AddressFamily(orDefault(rc.Settings.AddressFamily, "inet")),
IPForwarding: rc.Settings.IPForwarding,
LogLevel: orDefault(rc.Settings.LogLevel, "info"),
TableName: orDefault(rc.Settings.TableName, "tomswall"),
},
Zones: map[string]config.Zone{},
PortGroups: map[string]config.PortGroup{},
}
// The firewall zone is required; bound zones map to their local interfaces.
cfg.Zones["fw"] = config.Zone{Type: config.ZoneFirewall}
for zone, ifaces := range rc.Bindings {
cfg.Zones[zone] = config.Zone{Type: config.ZoneIP}
for _, iface := range ifaces {
cfg.Interfaces = append(cfg.Interfaces, config.Interface{Zone: zone, Interface: iface})
}
}
sort.Slice(cfg.Interfaces, func(i, j int) bool {
return cfg.Interfaces[i].Interface < cfg.Interfaces[j].Interface
})
setMembers := indexSets(rc.Sets)
for i, rr := range rc.Rules {
rules, err := translateRule(rr, setMembers)
if err != nil {
return nil, fmt.Errorf("rule %d: %w", i, err)
}
cfg.Rules = append(cfg.Rules, rules...)
}
for _, p := range rc.Policies {
cfg.Policy = append(cfg.Policy, config.Policy{
Source: orDefault(p.Source, "all"),
Dest: orDefault(p.Dest, "all"),
Action: config.PolicyAction(p.Action),
Log: p.Log,
})
}
return cfg, nil
}
// indexSets maps set name -> concrete member CIDRs (invalid members skipped).
func indexSets(sets []RenderedSet) map[string][]string {
m := make(map[string][]string, len(sets))
for _, s := range sets {
var members []string
for _, cidr := range s.staticMembers() {
if validateCIDR(cidr) == nil {
members = append(members, cidr)
}
}
m[s.Name] = members
}
return m
}
// addressesFor returns the union of concrete source/dest addresses for a
// direction's OR'd match elements. A match's addresses are its set members when
// a set is referenced, otherwise its zone subnets.
func addressesFor(matches []RenderedMatch, setMembers map[string][]string) []string {
seen := map[string]struct{}{}
var out []string
add := func(cidrs []string) {
for _, c := range cidrs {
if _, ok := seen[c]; ok {
continue
}
if validateCIDR(c) != nil {
continue
}
seen[c] = struct{}{}
out = append(out, c)
}
}
for _, m := range matches {
if m.Set != "" {
add(setMembers[m.Set])
continue
}
add(m.Subnets)
}
return out
}
// translateRule expands one rendered rule into address-matched tomswall rules.
func translateRule(rr RenderedRule, setMembers map[string][]string) ([]config.Rule, error) {
action, err := translateAction(rr.Action)
if err != nil {
return nil, err
}
srcAddrs := addressesFor(rr.Source, setMembers)
dstAddrs := addressesFor(rr.Dest, setMembers)
// A direction with no concrete addresses matches "any" for that side.
if len(srcAddrs) == 0 {
srcAddrs = []string{""}
}
if len(dstAddrs) == 0 {
dstAddrs = []string{""}
}
var out []config.Rule
for _, s := range srcAddrs {
for _, d := range dstAddrs {
out = append(out, config.Rule{
Action: action,
Source: anySpec(s),
Dest: anySpec(d),
Proto: rr.Proto,
DPort: config.PortSpec(rr.Ports),
Log: rr.Log,
Comment: rr.Comment,
})
}
}
return out, nil
}
// anySpec renders an interface-agnostic source/dest spec: "all" with an optional
// CIDR constraint.
func anySpec(cidr string) string {
if cidr == "" {
return "all"
}
return "all:" + cidr
}
func translateAction(a string) (config.RuleAction, error) {
switch config.RuleAction(a) {
case config.RuleAccept, config.RuleDrop, config.RuleReject,
config.RuleLog, config.RuleContinue, config.RuleCount:
return config.RuleAction(a), nil
default:
return "", fmt.Errorf("unsupported action %q", a)
}
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
+67
View File
@@ -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
}
+63
View File
@@ -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
}
+75
View File
@@ -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
}
+240
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+86
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+57
View File
@@ -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
}
+98
View File
@@ -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
}
+51
View File
@@ -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
}
+115
View File
@@ -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
}
+55
View File
@@ -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
}
+62
View File
@@ -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
}
+102
View File
@@ -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
}
+83
View File
@@ -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
}
+32
View File
@@ -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
}
+143
View File
@@ -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
}
+58
View File
@@ -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
}
+117
View File
@@ -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
}
+38
View File
@@ -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
}
+37
View File
@@ -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
}
+43
View File
@@ -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
}
+62
View File
@@ -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
}
+238
View File
@@ -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
}
+27
View File
@@ -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
}
+97
View File
@@ -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
}
+58
View File
@@ -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
}
+128
View File
@@ -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
}
+89
View File
@@ -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
}
+88
View File
@@ -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
}
+95
View File
@@ -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()
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+109
View File
@@ -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
}
+188
View File
@@ -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
}
File diff suppressed because it is too large Load Diff
+727
View File
@@ -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")
}
})
}
+172
View File
@@ -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()
}
+413
View File
@@ -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")
}
})
}
+70
View File
@@ -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
+285
View File
@@ -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