first commit
This commit is contained in:
commit
98aa68f7ed
63 changed files with 33761 additions and 0 deletions
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
# Integration-test VM cache (base cloud image, overlay disk, cloud-init seed,
|
||||||
|
# serial console log). Created by the test/integration/host-*-vm.sh scripts; removed by `make clean`.
|
||||||
|
/.cache/
|
||||||
|
|
||||||
|
# Build output for the go-firewall CLI (see `make cli`). Removed by `make clean`.
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# Stray CLI binary from a bare `go build` inside the cmd module (the binary name
|
||||||
|
# matches the directory). The canonical build target is /build/ above.
|
||||||
|
/cmd/go-firewall/go-firewall
|
||||||
|
|
||||||
31
.golangci.yml
Normal file
31
.golangci.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
version: "2"
|
||||||
|
|
||||||
|
# go-firewall is a multi-platform library: each backend lives behind a GOOS
|
||||||
|
# build tag (iptables/nft/ufw/firewalld/csf/apf on linux, pf on darwin+freebsd,
|
||||||
|
# wf on windows). golangci-lint only analyzes one GOOS per run, so the firewall
|
||||||
|
# is linted once per platform — see the `lint` target in the Makefile, which is
|
||||||
|
# the canonical entry point.
|
||||||
|
#
|
||||||
|
# The linux run is authoritative for the `unused` linter: linux compiles every
|
||||||
|
# backend plus all shared helpers, so it alone can tell dead code from a helper
|
||||||
|
# that only a subset of backends use. The cross-compiled runs disable `unused`
|
||||||
|
# (a linux-only helper unavoidably reads as dead code under another GOOS) but
|
||||||
|
# keep errcheck/govet/ineffassign/staticcheck.
|
||||||
|
|
||||||
|
run:
|
||||||
|
# Analyze test files too, so the integration suites are held to the same bar.
|
||||||
|
tests: true
|
||||||
|
|
||||||
|
linters:
|
||||||
|
# The conservative standard set: errcheck, govet, ineffassign, staticcheck, unused.
|
||||||
|
default: standard
|
||||||
|
|
||||||
|
issues:
|
||||||
|
# Report every occurrence; the defaults cap repeats and hide real work.
|
||||||
|
max-issues-per-linter: 0
|
||||||
|
max-same-issues: 0
|
||||||
|
|
||||||
|
formatters:
|
||||||
|
# Enforce canonical gofmt formatting as part of the same run.
|
||||||
|
enable:
|
||||||
|
- gofmt
|
||||||
19
LICENSE
Normal file
19
LICENSE
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
Copyright (c) 2026 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
91
Makefile
Normal file
91
Makefile
Normal file
|
|
@ -0,0 +1,91 @@
|
||||||
|
# go-firewall test targets.
|
||||||
|
#
|
||||||
|
# make lint golangci-lint across every target GOOS
|
||||||
|
# make test-general unit / parser tests — fast, no root, no VM
|
||||||
|
# make test-integration Linux backends end-to-end in a throwaway QEMU VM
|
||||||
|
# make test-integration-freebsd pf backend end-to-end in a throwaway FreeBSD VM
|
||||||
|
# make test-integration-windows Windows Firewall backend in a throwaway Windows VM
|
||||||
|
# make test general + Linux integration
|
||||||
|
# make test-all general + every OS's integration (slow; big downloads)
|
||||||
|
# make build the go-firewall CLI (separate module in ./cmd/go-firewall)
|
||||||
|
# make install install the go-firewall CLI into $GOBIN
|
||||||
|
#
|
||||||
|
# test-general runs the plain `go test` suite (rule parsing/marshalling, capability
|
||||||
|
# and helper logic) — it never touches a live firewall.
|
||||||
|
#
|
||||||
|
# The test-integration* targets boot disposable QEMU VMs and run the capability-
|
||||||
|
# driven suite against the real backends there, so nothing touches the host:
|
||||||
|
# * Linux — nft/firewalld/ufw/iptables/apf/csf natively in an Ubuntu VM.
|
||||||
|
# Limit backends with BACKENDS, e.g. `make test-integration BACKENDS=nft`.
|
||||||
|
# * FreeBSD — pf in a FreeBSD VM (pf runs natively; also covers macOS's pf backend).
|
||||||
|
# * Windows — the Windows Firewall backend in a Windows VM (heaviest; large image).
|
||||||
|
# They need qemu-system-x86_64, KVM (/dev/kvm), genisoimage (and python3 for FreeBSD).
|
||||||
|
#
|
||||||
|
# macOS is not automatable in a VM (Apple hardware); run its pf backend manually on a
|
||||||
|
# Mac: `sudo go test -tags integration -run TestIntegration`.
|
||||||
|
#
|
||||||
|
# VM artifacts (cloud images, overlay disks, seeds) are cached under ./.cache
|
||||||
|
# (git-ignored). `make clean` removes that cache and the compiled test binaries.
|
||||||
|
|
||||||
|
BACKENDS ?=
|
||||||
|
CLI_DIR := ./cmd/go-firewall
|
||||||
|
BUILD_DIR := ./build
|
||||||
|
CLI_BIN := $(BUILD_DIR)/go-firewall
|
||||||
|
|
||||||
|
# Sources the CLI is built from: its own package plus the library it imports.
|
||||||
|
CLI_SRC := $(shell find $(CLI_DIR) . -maxdepth 1 -name '*.go' -not -name '*_test.go') \
|
||||||
|
$(CLI_DIR)/go.mod $(CLI_DIR)/go.sum go.mod go.sum
|
||||||
|
|
||||||
|
.PHONY: all lint test test-general test-integration For test-integration-linux test-integration-freebsd test-integration-windows cli install clean
|
||||||
|
|
||||||
|
# Bare `make` builds the CLI.
|
||||||
|
.DEFAULT_GOAL := all
|
||||||
|
all: cli
|
||||||
|
|
||||||
|
test: test-general test-integration
|
||||||
|
|
||||||
|
# Lint every target GOOS. Each backend is behind a build tag, so a single run
|
||||||
|
# only sees one platform's code. The linux run is authoritative (it compiles all
|
||||||
|
# backends and shared helpers, so `unused` is meaningful); the cross-compiled
|
||||||
|
# runs disable `unused` because a linux-only helper unavoidably reads as dead
|
||||||
|
# code under another GOOS. Requires golangci-lint v2 (see .golangci.yml).
|
||||||
|
lint:
|
||||||
|
golangci-lint run ./...
|
||||||
|
GOOS=darwin golangci-lint run --disable=unused ./...
|
||||||
|
GOOS=freebsd golangci-lint run --disable=unused ./...
|
||||||
|
GOOS=windows golangci-lint run --disable=unused ./...
|
||||||
|
cd $(CLI_DIR) && golangci-lint run ./...
|
||||||
|
|
||||||
|
test-general:
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
test-integration: test-integration-linux test-integration-freebsd test-integration-windows
|
||||||
|
|
||||||
|
test-integration-linux:
|
||||||
|
./test/integration/host-linux-vm.sh $(BACKENDS)
|
||||||
|
|
||||||
|
test-integration-freebsd:
|
||||||
|
./test/integration/host-freebsd-vm.sh
|
||||||
|
|
||||||
|
test-integration-windows:
|
||||||
|
./test/integration/host-windows-vm.sh
|
||||||
|
|
||||||
|
# Build the go-firewall CLI into $(BUILD_DIR) (./build by default, git-ignored).
|
||||||
|
# The CLI lives in a separate Go module (so the kong dependency is not imposed
|
||||||
|
# on library users) and is built from $(CLI_DIR). `cli` is a convenience alias
|
||||||
|
# for the real binary target below, which only relinks when a source changes.
|
||||||
|
# Override the version with: make cli VERSION=v1.2.3
|
||||||
|
cli: $(CLI_BIN)
|
||||||
|
|
||||||
|
$(CLI_BIN): $(CLI_SRC) | $(BUILD_DIR)
|
||||||
|
go build -C $(CLI_DIR) -ldflags "-X main.version=$(VERSION)" -o $(abspath $(CLI_BIN)) .
|
||||||
|
|
||||||
|
$(BUILD_DIR):
|
||||||
|
mkdir -p $(BUILD_DIR)
|
||||||
|
|
||||||
|
# Install the go-firewall CLI into $GOBIN (or $GOPATH/bin). Requires Go 1.26+.
|
||||||
|
install:
|
||||||
|
go install -C $(CLI_DIR) -ldflags "-X main.version=$(VERSION)"
|
||||||
|
|
||||||
|
clean:
|
||||||
|
rm -rf .cache $(BUILD_DIR) test/integration/firewall.test test/integration/firewall.test.*
|
||||||
308
README.md
Normal file
308
README.md
Normal file
|
|
@ -0,0 +1,308 @@
|
||||||
|
# go-firewall
|
||||||
|
|
||||||
|
[](https://pkg.go.dev/github.com/grmrgecko/firewall)
|
||||||
|
|
||||||
|
A Go module that presents a single, uniform interface over the many
|
||||||
|
firewall managers found across operating systems. You describe rules with one
|
||||||
|
platform‑agnostic `Rule` struct and the module translates them to whatever
|
||||||
|
backend is actually running on the host.
|
||||||
|
|
||||||
|
Reference documentation: <https://pkg.go.dev/github.com/grmrgecko/firewall>
|
||||||
|
|
||||||
|
```go
|
||||||
|
import "github.com/grmrgecko/firewall"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Supported backends
|
||||||
|
|
||||||
|
| Platform | Backends |
|
||||||
|
| -------- | --------------------------------------------------------------- |
|
||||||
|
| Linux | firewalld → ufw → CSF → APF → iptables → nftables |
|
||||||
|
| macOS | pf (Packet Filter) |
|
||||||
|
| FreeBSD | pf (Packet Filter) |
|
||||||
|
| Windows | Windows Filtering Platform (WFP) |
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
```go
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Detect and connect to the host's firewall. The rule prefix tags/namespaces
|
||||||
|
// rules this module creates.
|
||||||
|
mgr, err := firewall.NewManager(ctx, "myapp")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
defer mgr.Close(ctx)
|
||||||
|
|
||||||
|
// Resolve the zone for an interface (empty for backends without zones).
|
||||||
|
zone, err := mgr.GetZone(ctx, "eth0")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow inbound TCP 443 from a subnet, logged and rate-limited.
|
||||||
|
rule := &firewall.Rule{
|
||||||
|
Family: firewall.IPv4,
|
||||||
|
Source: "192.168.0.0/24",
|
||||||
|
Port: 443,
|
||||||
|
Proto: firewall.TCP,
|
||||||
|
Action: firewall.Accept,
|
||||||
|
Log: true,
|
||||||
|
LogPrefix: "https",
|
||||||
|
RateLimit: &firewall.RateLimit{Rate: 20, Unit: firewall.PerSecond, Burst: 10},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mgr.AddRule(ctx, zone, rule); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward inbound TCP 8080 to an internal host (a NAT rule).
|
||||||
|
nat := &firewall.NATRule{
|
||||||
|
Kind: firewall.DNAT,
|
||||||
|
Family: firewall.IPv4,
|
||||||
|
Proto: firewall.TCP,
|
||||||
|
Port: 8080,
|
||||||
|
ToAddress: "10.0.0.5",
|
||||||
|
ToPort: 80,
|
||||||
|
}
|
||||||
|
if err := mgr.AddNATRule(ctx, zone, nat); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Some backends stage changes; Reload activates them (a no-op where
|
||||||
|
// changes apply immediately).
|
||||||
|
if err := mgr.Reload(ctx); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CLI
|
||||||
|
|
||||||
|
`cmd/go-firewall` is a unified firewall management CLI and implementation demo
|
||||||
|
for the library. It auto-detects the host's active backend and exposes the same
|
||||||
|
surface across all of them. Build it from the repo root:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make cli # builds ./build/go-firewall
|
||||||
|
make install # installs into $GOBIN
|
||||||
|
```
|
||||||
|
|
||||||
|
Managing rules needs appropriate privileges (root/Administrator), and the CLI
|
||||||
|
never modifies the host unless you run a mutating subcommand.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go-firewall status # backend + capabilities
|
||||||
|
go-firewall rule list # all filter rules (PREFIX column flags ours)
|
||||||
|
go-firewall rule add --proto tcp --port 443 --source 192.168.0.0/24 --log
|
||||||
|
go-firewall rule add --proto tcp --ports 80,443,1000-2000 --comment "web"
|
||||||
|
go-firewall rule remove --proto tcp --port 443
|
||||||
|
go-firewall rule insert 1 --proto tcp --port 22 # 1-based position
|
||||||
|
go-firewall nat add --kind dnat --proto tcp --port 8080 --to-address 10.0.0.5 --to-port 80
|
||||||
|
go-firewall nat insert 1 --kind dnat --proto tcp --port 8080 --to-address 10.0.0.5 --to-port 80
|
||||||
|
go-firewall nat add --kind masquerade
|
||||||
|
go-firewall policy get
|
||||||
|
go-firewall policy set --input drop --forward drop
|
||||||
|
go-firewall set create blocklist --family ipv4 --type hash:net
|
||||||
|
go-firewall set add-entry blocklist 203.0.113.0/24
|
||||||
|
go-firewall set show blocklist # metadata + every entry
|
||||||
|
go-firewall backup -o snapshot.json # portable JSON snapshot (rules, NAT, policy, sets)
|
||||||
|
go-firewall restore -f snapshot.json # replay the snapshot
|
||||||
|
go-firewall zone eth0
|
||||||
|
go-firewall reload
|
||||||
|
go-firewall install-completions # bash/zsh/fish completion
|
||||||
|
```
|
||||||
|
|
||||||
|
Global flags: `--prefix` (rule namespace; default `go_firewall`), `--no-reload`
|
||||||
|
(skip the automatic reload after a mutation), `-j/--json` (machine-readable
|
||||||
|
output — list, status, and a `{"status":...}` object on mutations), `--version`.
|
||||||
|
A rule's flags are identical across `add`,
|
||||||
|
`remove`, `insert` and `move`, so the flag set that creates a rule is also its
|
||||||
|
match key for removal. Run `go-firewall <command> --help` for the full flag
|
||||||
|
reference.
|
||||||
|
|
||||||
|
## The `Rule` type
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| -------------- | -------------------------------------------------------------------------------- |
|
||||||
|
| `Direction` | `DirInput` (default), `DirOutput`, `DirForward`, or `DirAny` — the input, output, forward (routing) chain, or both input and output. See `Capabilities().Forward` and the `DirAny` note below. |
|
||||||
|
| `Priority` | Rule priority, where the backend supports it (e.g. firewalld rich rules). |
|
||||||
|
| `Family` | `FamilyAny`, `IPv4`, or `IPv6`. |
|
||||||
|
| `Source` | Source address/CIDR. Prefix with `!` to negate, where supported. |
|
||||||
|
| `Destination` | Destination address/CIDR. Prefix with `!` to negate, where supported. |
|
||||||
|
| `Port` | Single destination port. A non-zero port requires a concrete `TCP`/`UDP` proto. |
|
||||||
|
| `Ports` | Destination port list/ranges (`[]PortRange`). Overrides `Port` when non-empty. |
|
||||||
|
| `Proto` | `ProtocolAny`, `TCP`, `UDP`, `ICMP`, `ICMPv6`, `SCTP`, `GRE`, `ESP`, or `AH`. |
|
||||||
|
| `ICMPType` | Optional single ICMP type for an `ICMP`/`ICMPv6` rule (`*uint8`, nil = any type). |
|
||||||
|
| `State` | Connection-tracking states to match, OR-combined (e.g. `StateEstablished\|StateRelated`). |
|
||||||
|
| `InInterface` | Inbound interface to match. Empty means any interface. A forward rule may match this alongside `OutInterface`. |
|
||||||
|
| `OutInterface` | Outbound interface to match. Empty means any interface. A forward rule may match this alongside `InInterface`. |
|
||||||
|
| `Action` | `Accept`, `Reject`, or `Drop`. |
|
||||||
|
| `Log` | Log each matched packet before applying `Action`. |
|
||||||
|
| `LogPrefix` | Optional label on the log line (not all backends carry a prefix; pf ignores it). |
|
||||||
|
| `RateLimit` | `*RateLimit` (`Rate`/`Unit`/`Burst`) — cap the packet rate the rule matches. `nil` = unlimited. |
|
||||||
|
| `ConnLimit` | `*ConnLimit` (`Count`/`PerSource`) — cap concurrent connections. `nil` = unlimited. |
|
||||||
|
| `Packets` | Per-rule packet counter, populated by `GetRules` on backends that read them (nftables, iptables, pf). Zero elsewhere and ignored when adding a rule. |
|
||||||
|
| `Bytes` | Per-rule byte counter, populated alongside `Packets`. Not part of rule identity. |
|
||||||
|
| `Comment` | Optional human-readable label carried where the backend can store one. Informational: not part of rule identity, ignored where unsupported. See `Capabilities().Comments`. |
|
||||||
|
| `HasPrefix` | Informational flag reporting whether the rule carries the configured prefix. |
|
||||||
|
|
||||||
|
A `FamilyAny` rule that resolves to an identical IPv4 and IPv6 pair is collapsed
|
||||||
|
into a single rule when reading rules back. `Capabilities().Output` reports whether
|
||||||
|
a backend distinguishes input from output (firewalld, for example, does not), and
|
||||||
|
`Capabilities().Forward` reports whether it can express a forward-chain (routing)
|
||||||
|
rule. A `DirForward` rule on a backend without forward support is rejected with
|
||||||
|
`ErrUnsupportedForward`.
|
||||||
|
|
||||||
|
### `DirAny` — both directions
|
||||||
|
|
||||||
|
`DirAny` is the direction analog of `FamilyAny`: it describes a rule that applies
|
||||||
|
to **both** the input and output directions (never forward). A `DirAny` rule is
|
||||||
|
authored in the inbound frame — its `Source`/destination ports/`InInterface` are
|
||||||
|
the input-chain meaning — and its outbound half is the **role swap**:
|
||||||
|
`Source`↔`Destination`, source↔destination ports, and `InInterface`↔`OutInterface`.
|
||||||
|
So `DirAny` with `Source: X` matches inbound traffic *from* `X` and outbound
|
||||||
|
traffic *to* `X`.
|
||||||
|
|
||||||
|
- **On add**, a `DirAny` rule fans out into a concrete input row plus its swapped
|
||||||
|
output row. **On read**, an input rule and its swapped output twin are collapsed
|
||||||
|
back into one `DirAny` rule (the same way a v4/v6 pair collapses to `FamilyAny`).
|
||||||
|
- **Removing a single direction** of a `DirAny` rule leaves the other in place: the
|
||||||
|
chain backends drop only that direction's row, while csf/apf split their
|
||||||
|
bidirectional plain `csf.allow`/`allow_hosts` line and re-express the surviving
|
||||||
|
direction through their raw-iptables hook.
|
||||||
|
- On csf/apf, a bare (address-only, no-port) `DirAny` host allow is the single
|
||||||
|
bidirectional plain line; a one-way (`DirInput`/`DirOutput`) bare host allow is
|
||||||
|
written to the hook instead, since a plain line is inherently bidirectional and an
|
||||||
|
advanced rule requires a port.
|
||||||
|
- On a backend that does not distinguish an output chain (`Capabilities().Output`
|
||||||
|
is false, e.g. **firewalld**), a both-directions rule cannot be expressed, so a
|
||||||
|
`DirAny` rule degrades to its input half (`DirInput`, same fields) rather than
|
||||||
|
being rejected.
|
||||||
|
|
||||||
|
## NAT (port forwarding and masquerade)
|
||||||
|
|
||||||
|
NAT rules are managed separately from filter rules through
|
||||||
|
`AddNATRule`/`RemoveNATRule`/`GetNATRules`, using the `NATRule` type.
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| ----------- | -------------------------------------------------------------------------------- |
|
||||||
|
| `Kind` | `DNAT`, `Redirect`, `SNAT`, or `Masquerade`. |
|
||||||
|
| `Family` | `FamilyAny`, `IPv4`, or `IPv6`. |
|
||||||
|
| `Proto` | Protocol to match (`TCP`, `UDP`, etc.). |
|
||||||
|
| `Port` | Matched destination port (`Ports` for a list/range). Requires a tcp/udp protocol. |
|
||||||
|
| `ToAddress` | Rewrite target address: new destination for `DNAT`, new source for `SNAT`. |
|
||||||
|
| `ToPort` | Rewrite target port (`DNAT`/`Redirect`). Unused for `SNAT`/`Masquerade`. |
|
||||||
|
| `Interface` | Inbound interface for `DNAT`/`Redirect`; outbound for `SNAT`/`Masquerade`. |
|
||||||
|
| `HasPrefix` | Informational flag, same semantics as `Rule.HasPrefix`. |
|
||||||
|
|
||||||
|
`DNAT` forwards inbound traffic to `ToAddress:ToPort`. `Redirect` sends matching
|
||||||
|
traffic to a local `ToPort`. `SNAT` rewrites the source to a fixed address, and
|
||||||
|
`Masquerade` uses the outgoing interface address. Backends that cannot express
|
||||||
|
NAT return `ErrUnsupportedNAT`.
|
||||||
|
|
||||||
|
## Capabilities
|
||||||
|
|
||||||
|
`mgr.Capabilities()` returns a `Capabilities` struct advertising which features
|
||||||
|
the active backend can express, so a caller can branch before trial-and-error:
|
||||||
|
|
||||||
|
```go
|
||||||
|
caps := mgr.Capabilities()
|
||||||
|
if !caps.NAT {
|
||||||
|
log.Println("this backend cannot do NAT")
|
||||||
|
}
|
||||||
|
if caps.RuleCounters {
|
||||||
|
// rules read back will carry Packets/Bytes
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Every boolean corresponds to a `Rule`/`NATRule` field or an interface method
|
||||||
|
(`NAT`, `RuleOrdering`, `DefaultPolicy`, `RuleCounters`, `AddressSets`,
|
||||||
|
`Comments`, …). A false `RuleCounters`/`Comments` means `GetRules` simply reports
|
||||||
|
zero counters / an empty comment; every other false field means the corresponding
|
||||||
|
operation returns an unsupported error. Features every backend supports — ICMP and
|
||||||
|
ICMP-type matching, port ranges, source ports, and backup/restore — are not
|
||||||
|
advertised as booleans; a caller can rely on them unconditionally. The matrix
|
||||||
|
below still documents how each backend expresses these features.
|
||||||
|
|
||||||
|
| Feature | firewalld | ufw | CSF | APF | iptables | nftables | pf | WFP |
|
||||||
|
| ---------------- | ------------- | --- | ------------ | ---------- | -------- | -------- | --------------------- | --- |
|
||||||
|
| Forward rules | no | yes (route) | via hook | via hook | yes | yes | no | no |
|
||||||
|
| ICMP | yes | yes | with address | type list | yes | yes | yes | yes |
|
||||||
|
| ICMPv6 | yes | yes | yes | yes | yes | yes | yes | yes |
|
||||||
|
| ICMP type | rich rule | yes | yes | yes | yes | yes | yes | yes |
|
||||||
|
| SCTP/GRE/ESP/AH | rich rule | yes | yes | yes | yes | yes | yes | by number (no SCTP port) |
|
||||||
|
| Comment | no | yes | yes | yes | yes | yes | label | description |
|
||||||
|
| Port range | yes | yes | yes | yes | yes | yes | yes | yes |
|
||||||
|
| Port list | no | yes | yes | ports config | yes | yes | yes | yes |
|
||||||
|
| Source port | yes | yes | adv rule | adv rule | yes | yes | yes | yes |
|
||||||
|
| Connection state | no | yes | yes | yes | yes | yes | no (stateful) | no |
|
||||||
|
| Interface match | no (zone) | yes | yes | yes | yes | yes | yes | no |
|
||||||
|
| Logging | rich rule | yes | yes | yes | yes | yes | yes (no prefix) | no |
|
||||||
|
| Rate limit | rich rule | yes | yes | yes | yes | yes | per-source | no |
|
||||||
|
| Connection limit | no | yes | per-port | per-port | yes | yes | per-source | no |
|
||||||
|
| NAT | fwd-port/masq | yes | dnat/redirect | dnat/snat/masq | yes | yes | rdr/nat (no redirect) | no |
|
||||||
|
|
||||||
|
## Default policy
|
||||||
|
|
||||||
|
`GetDefaultPolicy`/`SetDefaultPolicy` read and set the default action applied to
|
||||||
|
packets that match no rule. A `DefaultPolicy` carries an `Action` per direction
|
||||||
|
(`Input`, `Output`, `Forward`); a direction left as `ActionInvalid` is not
|
||||||
|
exposed (on `Get`) or left unchanged (on `Set`). On a backend that supports it,
|
||||||
|
the policy is captured in a `Backup` and re-asserted by `Restore`, so a snapshot
|
||||||
|
of a default-drop host reproduces that policy on replay rather than inheriting the
|
||||||
|
restore host's.
|
||||||
|
|
||||||
|
| Backend | Directions supported |
|
||||||
|
| ------------ | ------------------------------- |
|
||||||
|
| iptables | input, output, forward |
|
||||||
|
| ufw | input, output, forward |
|
||||||
|
| nftables | input, output, forward |
|
||||||
|
| firewalld | input (the zone target) |
|
||||||
|
| others | unsupported (`ErrUnsupportedPolicy`) |
|
||||||
|
|
||||||
|
## Address sets (ipset / nftset / pf tables)
|
||||||
|
|
||||||
|
Address sets are named collections of addresses (`AddressSet`) that rules can
|
||||||
|
match against, managed separately from filter and NAT rules. A `Backup` captures
|
||||||
|
the managed sets (with their entries) and `Restore` recreates them before the
|
||||||
|
rules, so a set-referencing rule (`@set`) resolves when a snapshot is replayed on
|
||||||
|
a host that does not yet have the set. They map onto the backend's native
|
||||||
|
construct:
|
||||||
|
|
||||||
|
| Backend | Construct |
|
||||||
|
| --------- | ---------------------------------- |
|
||||||
|
| iptables | ipset (`hash:ip`, `hash:net`) |
|
||||||
|
| ufw | ipset (via the host iptables) |
|
||||||
|
| nftables | a set in the private `inet` table |
|
||||||
|
| firewalld | a firewalld ipset (D-Bus) |
|
||||||
|
| pf | a pf table |
|
||||||
|
| CSF/APF/WFP | unsupported (`ErrUnsupportedSet`) |
|
||||||
|
|
||||||
|
```go
|
||||||
|
set := &firewall.AddressSet{Name: "blocklist", Family: firewall.IPv4, Type: firewall.SetHashNet}
|
||||||
|
_ = mgr.AddAddressSet(ctx, set)
|
||||||
|
_ = mgr.AddAddressSetEntry(ctx, "blocklist", "203.0.113.0/24")
|
||||||
|
sets, _ := mgr.GetAddressSets(ctx)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
The marshal/unmarshal (rule encoding/decoding) logic for each backend is unit
|
||||||
|
tested and does not require a live firewall. Backend detection and rule
|
||||||
|
application do require the corresponding firewall to be installed and running.
|
||||||
2196
apf_linux.go
Normal file
2196
apf_linux.go
Normal file
File diff suppressed because it is too large
Load diff
819
apf_linux_test.go
Normal file
819
apf_linux_test.go
Normal file
|
|
@ -0,0 +1,819 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestAPFAdvRules(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
|
||||||
|
// Advanced-rule encodings, including underscore ranges and bracketed IPv6.
|
||||||
|
cases := []struct {
|
||||||
|
rule *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{&Rule{Proto: TCP, Port: 22, Source: "192.168.2.1", Family: IPv4, Action: Accept}, "tcp:in:d=22:s=192.168.2.1"},
|
||||||
|
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 6000, End: 7000}}, Source: "192.168.5.0/24", Family: IPv4, Action: Accept}, "tcp:in:d=6000_7000:s=192.168.5.0/24"},
|
||||||
|
{&Rule{Proto: TCP, Port: 443, Source: "2001:db8::/32", Family: IPv6, Action: Accept}, "tcp:in:d=443:s=[2001:db8::/32]"},
|
||||||
|
{&Rule{Direction: DirOutput, Proto: UDP, Port: 53, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "udp:out:d=53:d=192.0.2.1"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := fw.MarshalAdvRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
||||||
|
|
||||||
|
parsed := fw.ParseAdvRule(got, c.rule.Action)
|
||||||
|
require.NotNil(t, parsed, "failed to parse %q", got)
|
||||||
|
require.True(t, parsed.Equal(c.rule, true),
|
||||||
|
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bracketed IPv6 host address parses back without brackets.
|
||||||
|
r := fw.ParseAdvRule("tcp:in:d=443:s=[2001:db8::1]", Accept)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.Equal(t, "2001:db8::1", r.Source)
|
||||||
|
require.Equal(t, IPv6, r.Family)
|
||||||
|
require.EqualValues(t, 443, r.Port)
|
||||||
|
|
||||||
|
// MarshalAdvRule rejects rules apf cannot express as advanced rules.
|
||||||
|
bad := []*Rule{
|
||||||
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept},
|
||||||
|
{Proto: TCP, Port: 80, Action: Accept},
|
||||||
|
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range bad {
|
||||||
|
_, err := fw.MarshalAdvRule(r)
|
||||||
|
require.Error(t, err, "expected error marshalling %+v", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port-only deny whose action is Drop (conf.apf's default ALL_STOP=DROP) must
|
||||||
|
// still be written to deny_hosts: the placeholder branch keys on "not an accept",
|
||||||
|
// not on Reject. Before the fix it keyed on Reject, so a Drop deny wrote nothing
|
||||||
|
// while AddRule reported success — the port stayed open.
|
||||||
|
func TestAPFPortOnlyDropDenyIsWritten(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fw := new(APF)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "deny_hosts.rules")
|
||||||
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
||||||
|
|
||||||
|
drop := &Rule{Family: IPv4, Proto: TCP, Port: 3306, Action: Drop}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, drop, false))
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(data), "tcp:in:d=3306:s=0.0.0.0/0",
|
||||||
|
"a port-only Drop deny must be written with the any-network placeholder")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPFSourcePorts(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
|
||||||
|
// Source ports round-trip through the s= port-flow field (single port and
|
||||||
|
// underscore range).
|
||||||
|
cases := []struct {
|
||||||
|
rule *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{&Rule{Proto: TCP, SourcePort: 1234, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "tcp:in:s=1234:d=192.0.2.1"},
|
||||||
|
{&Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 1024, End: 2048}}, Source: "192.0.2.1", Family: IPv4, Action: Accept}, "udp:in:s=1024_2048:s=192.0.2.1"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := fw.MarshalAdvRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
||||||
|
|
||||||
|
parsed := fw.ParseAdvRule(got, c.rule.Action)
|
||||||
|
require.NotNil(t, parsed, "failed to parse %q", got)
|
||||||
|
require.True(t, parsed.Equal(c.rule, true),
|
||||||
|
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matching both a source and a destination port is not representable.
|
||||||
|
_, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept})
|
||||||
|
require.Error(t, err, "expected error matching both source and destination ports")
|
||||||
|
|
||||||
|
// A source-port rule with no address has no advanced-rule form, so it routes to
|
||||||
|
// the hook; one carrying an address stays on the native advanced-rule path.
|
||||||
|
require.True(t, fw.needsHook(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}),
|
||||||
|
"an address-less source-port rule must route to the hook")
|
||||||
|
require.False(t, fw.needsHook(&Rule{Proto: TCP, SourcePort: 1234, Source: "1.2.3.4", Action: Accept}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPFDualStackPort locks in that apf routes a single-family bare port accept to
|
||||||
|
// the hook: its CPORTS lists are dual-stack and express only a FamilyAny port, so a
|
||||||
|
// concrete-family one is written per-family through the hook. A FamilyAny port stays
|
||||||
|
// native, and a port with an address or an ICMP type takes another path.
|
||||||
|
func TestAPFDualStackPort(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
// A concrete-family bare TCP/UDP port accept goes to the hook.
|
||||||
|
require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCP, Port: 3492, Action: Accept}))
|
||||||
|
require.True(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv6, Proto: UDP, Port: 3492, Action: Accept}))
|
||||||
|
|
||||||
|
// A dual-stack (FamilyAny) port is what the shared CPORTS list represents.
|
||||||
|
require.False(t, fw.dualStackPortNeedsHook(&Rule{Proto: TCP, Port: 3492, Action: Accept}))
|
||||||
|
// A port with an address is a host rule, and ICMP has no port — neither is a
|
||||||
|
// dual-stack CPORTS accept.
|
||||||
|
require.False(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv4, Proto: TCP, Port: 3492, Source: "192.0.2.1", Action: Accept}))
|
||||||
|
require.False(t, fw.dualStackPortNeedsHook(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPFBarePortAccept locks in the removal routing shared by single-family and
|
||||||
|
// FamilyAny bare tcp/udp port accepts. A FamilyAny port merged back from a v4+v6 hook
|
||||||
|
// pair reads as impliedFamily FamilyAny, so dualStackPortNeedsHook (single-family
|
||||||
|
// only) does not match it; barePortAccept must, so RemoveRule routes it to the
|
||||||
|
// family-agnostic removeFamilyAnyPort instead of the native-only EditConf path that
|
||||||
|
// cannot clear the hook rows. Regression for mergedfamilyremove leaving the port open.
|
||||||
|
func TestAPFBarePortAccept(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
// Both a concrete-family and a FamilyAny bare port accept are bare-port accepts;
|
||||||
|
// only the concrete-family one additionally forces the add-time hook.
|
||||||
|
v4 := &Rule{Family: IPv4, Proto: TCP, Port: 3492, Action: Accept}
|
||||||
|
any := &Rule{Family: FamilyAny, Proto: TCP, Port: 3492, Action: Accept}
|
||||||
|
require.True(t, fw.barePortAccept(v4))
|
||||||
|
require.True(t, fw.barePortAccept(any))
|
||||||
|
require.True(t, fw.dualStackPortNeedsHook(v4))
|
||||||
|
require.False(t, fw.dualStackPortNeedsHook(any), "a FamilyAny port must route to removeFamilyAnyPort, not the single-family split")
|
||||||
|
|
||||||
|
// A deny, an address-bearing rule, a source-port-only match (no CPORTS form), and
|
||||||
|
// a non-tcp/udp match are not bare-port accepts and take other removal paths.
|
||||||
|
require.False(t, fw.barePortAccept(&Rule{Proto: TCP, Port: 3492, Action: Drop}))
|
||||||
|
require.False(t, fw.barePortAccept(&Rule{Proto: TCP, Port: 3492, Source: "192.0.2.1", Action: Accept}))
|
||||||
|
require.False(t, fw.barePortAccept(&Rule{Proto: TCP, SourcePort: 3492, Action: Accept}))
|
||||||
|
require.False(t, fw.barePortAccept(&Rule{Proto: ICMP, Action: Accept}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPFConnLimit(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
|
||||||
|
// IG_TCP_CLIMIT parses into per-port reject rules with a per-source cap; an
|
||||||
|
// underscore port range is preserved.
|
||||||
|
rules := fw.ParseConnLimit("80:50,8080_8090:25", TCP)
|
||||||
|
require.Len(t, rules, 2)
|
||||||
|
require.Equal(t, Reject, rules[0].Action)
|
||||||
|
require.EqualValues(t, 80, rules[0].Port)
|
||||||
|
require.NotNil(t, rules[0].ConnLimit)
|
||||||
|
require.EqualValues(t, 50, rules[0].ConnLimit.Count)
|
||||||
|
require.True(t, rules[0].ConnLimit.PerSource)
|
||||||
|
require.Len(t, rules[1].Ports, 1)
|
||||||
|
require.Equal(t, PortRange{Start: 8080, End: 8090}, rules[1].Ports[0])
|
||||||
|
|
||||||
|
// Editing adds a range entry and removes a port entry.
|
||||||
|
added := fw.editConnLimit("IG_TCP_CLIMIT", "80:50",
|
||||||
|
&Rule{Proto: TCP, Ports: []PortRange{{Start: 8080, End: 8090}}, Action: Reject, ConnLimit: &ConnLimit{Count: 25, PerSource: true}}, false)
|
||||||
|
require.Equal(t, `IG_TCP_CLIMIT="80:50,8080_8090:25"`, added)
|
||||||
|
removed := fw.editConnLimit("IG_TCP_CLIMIT", "80:50,443:100",
|
||||||
|
&Rule{Proto: TCP, Port: 443, Action: Reject, ConnLimit: &ConnLimit{Count: 100, PerSource: true}}, true)
|
||||||
|
require.Equal(t, `IG_TCP_CLIMIT="80:50"`, removed)
|
||||||
|
|
||||||
|
// Only a single inbound tcp/udp port|range, address-less, per-source, reject
|
||||||
|
// rule maps onto conf.apf natively; every other connection limit routes to the
|
||||||
|
// hook rather than being rejected.
|
||||||
|
native := &Rule{Proto: UDP, Port: 53, Action: Reject, ConnLimit: &ConnLimit{Count: 100, PerSource: true}}
|
||||||
|
require.True(t, fw.isConnLimitRule(native))
|
||||||
|
require.False(t, fw.needsHook(native), "a native connlimit stays in conf.apf")
|
||||||
|
hooked := []*Rule{
|
||||||
|
{Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // non-reject action
|
||||||
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // proto
|
||||||
|
{Proto: TCP, Port: 80, Source: "1.2.3.4", Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // address
|
||||||
|
{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}, // global
|
||||||
|
}
|
||||||
|
for _, r := range hooked {
|
||||||
|
require.True(t, fw.needsHook(r), "expected hook routing for %+v", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPFNATRoundTrip(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
cases := []*NATRule{
|
||||||
|
{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080},
|
||||||
|
{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080},
|
||||||
|
{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4"},
|
||||||
|
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
|
||||||
|
}
|
||||||
|
for _, orig := range cases {
|
||||||
|
for _, fam := range fw.natFamilies(orig) {
|
||||||
|
line, err := fw.natLine(orig, fam)
|
||||||
|
require.NoError(t, err, "marshal %+v", *orig)
|
||||||
|
got, ok := fw.parseNATLine(line)
|
||||||
|
require.True(t, ok, "failed to parse %q", line)
|
||||||
|
require.True(t, got.EqualBase(orig), "line %q: want %+v got %+v", line, orig, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The command line targets the right binary and nat chain.
|
||||||
|
dnat, err := fw.natLine(&NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, dnat, "iptables -t nat -A PREROUTING")
|
||||||
|
require.Equal(t, APFPreroute, fw.natFile(&NATRule{Kind: DNAT}))
|
||||||
|
require.Equal(t, APFPostroute, fw.natFile(&NATRule{Kind: Masquerade}))
|
||||||
|
|
||||||
|
masq6, err := fw.natLine(&NATRule{Kind: Masquerade, Family: IPv6, Interface: "eth1"}, IPv6)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, masq6, "ip6tables -t nat -A POSTROUTING")
|
||||||
|
|
||||||
|
// A non-NAT line is ignored by the parser.
|
||||||
|
_, ok := fw.parseNATLine("# place your custom routing rules below")
|
||||||
|
require.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPFNATHasPrefixFlag(t *testing.T) {
|
||||||
|
r := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}
|
||||||
|
|
||||||
|
// With a prefix configured, the emitted iptables line carries the prefix
|
||||||
|
// comment tag, and parsing it back reports HasPrefix — APF's NAT rules are
|
||||||
|
// real iptables commands, so the same comment mechanism as the filter rules
|
||||||
|
// applies.
|
||||||
|
withPrefix := &APF{rulePrefix: "myapp"}
|
||||||
|
line, err := withPrefix.natLine(r, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, line, `-m comment --comment "myapp"`)
|
||||||
|
got, ok := withPrefix.parseNATLine(line)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.True(t, got.HasPrefix, "an APF NAT rule tagged with the prefix reports HasPrefix")
|
||||||
|
|
||||||
|
// With no prefix no tag is written, so HasPrefix stays false.
|
||||||
|
noPrefix := &APF{}
|
||||||
|
line, err = noPrefix.natLine(r, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, ok = noPrefix.parseNATLine(line)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.False(t, got.HasPrefix, "with no prefix an APF NAT rule reports no HasPrefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPFPortAndICMPConfig(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
|
||||||
|
// Port lists parse single ports and underscore ranges.
|
||||||
|
rules := fw.ParsePorts("21,22,6000_7000", TCP, false)
|
||||||
|
require.Len(t, rules, 3, "expected 3 port rules")
|
||||||
|
require.Len(t, rules[2].Ports, 1)
|
||||||
|
require.Equal(t, PortRange{Start: 6000, End: 7000}, rules[2].Ports[0],
|
||||||
|
"expected a 6000-7000 range rule")
|
||||||
|
|
||||||
|
// ICMP type lists become ICMP rules, one per type.
|
||||||
|
icmp := fw.ParseICMPTypes("3,5,8", ICMP, false)
|
||||||
|
require.Len(t, icmp, 3, "expected 3 icmp rules")
|
||||||
|
require.Equal(t, ICMP, icmp[2].Proto)
|
||||||
|
require.NotNil(t, icmp[2].ICMPType, "expected icmp type 8 rule, got %+v", *icmp[2])
|
||||||
|
require.EqualValues(t, 8, *icmp[2].ICMPType, "expected icmp type 8 rule")
|
||||||
|
|
||||||
|
// EditRulePort adds a range token to the matching port list.
|
||||||
|
got := fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22",
|
||||||
|
&Rule{Proto: TCP, Ports: []PortRange{{Start: 6000, End: 7000}}, Action: Accept}, false)
|
||||||
|
require.Equal(t, `IG_TCP_CPORTS="22,6000_7000"`, got, "unexpected port edit")
|
||||||
|
|
||||||
|
// EditRulePort adds an ICMP type to the icmp type list.
|
||||||
|
got = fw.EditRulePort(`IG_ICMP_TYPES="3,5"`, "IG_ICMP_TYPES", "3,5",
|
||||||
|
&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, false)
|
||||||
|
require.Equal(t, `IG_ICMP_TYPES="3,5,8"`, got, "unexpected icmp edit")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPFICMPNamedTypeReconcile guards ICMP-type reconciliation against a conf.apf
|
||||||
|
// list that spells a type by name (e.g. "echo-request") rather than its number.
|
||||||
|
// The read path resolves names to numbers, so removal/add must compare by resolved
|
||||||
|
// number or a foreign name-based entry can never be removed (Sync never converges)
|
||||||
|
// and an add would append a numeric duplicate.
|
||||||
|
func TestAPFICMPNamedTypeReconcile(t *testing.T) {
|
||||||
|
// Removing ICMP type 8 must clear a name-based "echo-request" entry.
|
||||||
|
fw := new(APF)
|
||||||
|
got := fw.EditRulePort(`IG_ICMP_TYPES="echo-request"`, "IG_ICMP_TYPES", "echo-request",
|
||||||
|
&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, true)
|
||||||
|
require.Equal(t, `IG_ICMP_TYPES=""`, got, "a name-based echo-request entry must be removed for type 8")
|
||||||
|
require.True(t, fw.ConfigChanged, "the config must be marked changed when the entry is removed")
|
||||||
|
|
||||||
|
// Adding type 8 when "echo-request" is already present must not duplicate it.
|
||||||
|
fw = new(APF)
|
||||||
|
got = fw.EditRulePort(`IG_ICMP_TYPES="echo-request"`, "IG_ICMP_TYPES", "echo-request",
|
||||||
|
&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, false)
|
||||||
|
require.Equal(t, `IG_ICMP_TYPES="echo-request"`, got, "adding a type already present by name must not duplicate")
|
||||||
|
require.False(t, fw.ConfigChanged, "no change when the type is already present by name")
|
||||||
|
|
||||||
|
// The same holds for ICMPv6, resolved through the ICMPv6 name table (128).
|
||||||
|
fw = new(APF)
|
||||||
|
got = fw.EditRulePort(`IG_ICMPV6_TYPES="echo-request"`, "IG_ICMPV6_TYPES", "echo-request",
|
||||||
|
&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, true)
|
||||||
|
require.Equal(t, `IG_ICMPV6_TYPES=""`, got, "a name-based ICMPv6 echo-request entry must be removed for type 128")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPFICMPRouting(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
// An ICMPv4 rule apf's IG_ICMP_TYPES list cannot express — one carrying an
|
||||||
|
// address or a non-accept action — routes to the hook.
|
||||||
|
require.True(t, fw.needsHook(&Rule{Proto: ICMP, Source: "1.2.3.4", ICMPType: Ptr[uint8](8), Action: Accept}))
|
||||||
|
require.True(t, fw.needsHook(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Drop}))
|
||||||
|
|
||||||
|
// The ICMPv6 equivalents route to the hook via ruleNeedsHook (every ICMPv6 rule
|
||||||
|
// does) since they are not a native type list entry (nativeICMPv6).
|
||||||
|
for _, r := range []*Rule{
|
||||||
|
{Proto: ICMPv6, Source: "2001:db8::1", ICMPType: Ptr[uint8](128), Action: Accept},
|
||||||
|
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Drop},
|
||||||
|
} {
|
||||||
|
require.True(t, ruleNeedsHook(r) && !fw.nativeICMPv6(r), "expected hook routing for %+v", *r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Valid inbound allows stay native: an ICMP type, an ICMPv6 type, and a typeless
|
||||||
|
// "all" allow are address-less accepts (isConfRule), not hook rules.
|
||||||
|
for _, r := range []*Rule{
|
||||||
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
|
||||||
|
{Proto: ICMP, Action: Accept},
|
||||||
|
{Proto: ICMPv6, Action: Accept},
|
||||||
|
} {
|
||||||
|
require.False(t, fw.needsHook(r), "%+v must stay native", *r)
|
||||||
|
require.True(t, fw.isConfRule(r), "%+v must be a conf.apf rule", *r)
|
||||||
|
if r.Proto == ICMPv6 {
|
||||||
|
require.True(t, fw.nativeICMPv6(r), "%+v must be a native ICMPv6 type", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// apf carries ICMPv6 types and the "all" wildcard natively; both must round-trip
|
||||||
|
// through the conf.apf type lists and stay off the raw-iptables hook.
|
||||||
|
func TestAPFICMPv6AndAllWildcard(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
|
||||||
|
// IG_ICMPV6_TYPES parses to IPv6 ICMPv6 accepts, one per type.
|
||||||
|
v6 := fw.ParseICMPTypes("1,2,128,129", ICMPv6, false)
|
||||||
|
require.Len(t, v6, 4)
|
||||||
|
require.Equal(t, ICMPv6, v6[3].Proto)
|
||||||
|
require.Equal(t, IPv6, v6[3].Family)
|
||||||
|
require.EqualValues(t, 129, *v6[3].ICMPType)
|
||||||
|
|
||||||
|
// The "all" wildcard parses to a typeless (all-types) accept.
|
||||||
|
all := fw.ParseICMPTypes("all", ICMP, true)
|
||||||
|
require.Len(t, all, 1)
|
||||||
|
require.Nil(t, all[0].ICMPType, "'all' must be an all-types rule")
|
||||||
|
require.True(t, all[0].IsOutput())
|
||||||
|
|
||||||
|
// Writing an ICMPv6 type into its native list.
|
||||||
|
got := fw.EditRulePort(`IG_ICMPV6_TYPES="1,2"`, "IG_ICMPV6_TYPES", "1,2",
|
||||||
|
&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, false)
|
||||||
|
require.Equal(t, `IG_ICMPV6_TYPES="1,2,128"`, got)
|
||||||
|
|
||||||
|
// Writing the "all" wildcard for a typeless ICMP accept.
|
||||||
|
got = fw.EditRulePort(`EG_ICMP_TYPES=""`, "EG_ICMP_TYPES", "",
|
||||||
|
&Rule{Proto: ICMP, Direction: DirOutput, Action: Accept}, false)
|
||||||
|
require.Equal(t, `EG_ICMP_TYPES="all"`, got)
|
||||||
|
|
||||||
|
// A native ICMPv6 accept is routed to conf.apf, not the hook.
|
||||||
|
require.True(t, fw.nativeICMPv6(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}))
|
||||||
|
require.True(t, fw.isConfRule(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}))
|
||||||
|
// An ICMPv6 rule that also needs state matching stays on the hook path.
|
||||||
|
require.False(t, fw.nativeICMPv6(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// apf's own shell logic (apf_trust.sh trust_hosts(), cports.common) silently
|
||||||
|
// no-ops a bare IPv6 host and the native ICMPv6 type lists whenever conf.apf's
|
||||||
|
// USE_IPV6 is not "1" (the shipped default). ipv6Unavailable must flag
|
||||||
|
// exactly those two native shapes, and only when ipv6Enabled is false; a rule
|
||||||
|
// diverted to the raw-iptables hook (which bypasses USE_IPV6 entirely) must
|
||||||
|
// never be flagged.
|
||||||
|
func TestAPFIPv6UnavailableGate(t *testing.T) {
|
||||||
|
disabled := &APF{ipv6Enabled: false}
|
||||||
|
enabled := &APF{ipv6Enabled: true}
|
||||||
|
|
||||||
|
nativeICMPv6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}
|
||||||
|
require.True(t, disabled.ipv6Unavailable(nativeICMPv6),
|
||||||
|
"a native ICMPv6 type rule must be blocked when USE_IPV6 is off")
|
||||||
|
require.False(t, enabled.ipv6Unavailable(nativeICMPv6),
|
||||||
|
"a native ICMPv6 type rule must be allowed when USE_IPV6 is on")
|
||||||
|
|
||||||
|
bareV6Host := &Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept}
|
||||||
|
require.True(t, disabled.ipv6Unavailable(bareV6Host),
|
||||||
|
"a bare IPv6 host rule must be blocked when USE_IPV6 is off")
|
||||||
|
require.False(t, enabled.ipv6Unavailable(bareV6Host),
|
||||||
|
"a bare IPv6 host rule must be allowed when USE_IPV6 is on")
|
||||||
|
|
||||||
|
bareV4Host := &Rule{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept}
|
||||||
|
require.False(t, disabled.ipv6Unavailable(bareV4Host),
|
||||||
|
"an IPv4 rule must never be blocked by the IPv6 gate")
|
||||||
|
|
||||||
|
// An ICMPv6 rule that also needs state matching is diverted to the
|
||||||
|
// raw-iptables hook (see nativeICMPv6), which runs outside apf's
|
||||||
|
// USE_IPV6-gated shell logic, so it must not be blocked either way.
|
||||||
|
hookRoutedICMPv6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept}
|
||||||
|
require.False(t, disabled.ipv6Unavailable(hookRoutedICMPv6),
|
||||||
|
"a hook-routed ICMPv6 rule must not be blocked by the IPv6 gate")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAPFIPListComment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "allow_hosts.rules")
|
||||||
|
fw := &APF{rulePrefix: "myapp"}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(
|
||||||
|
"# myapp trusted office\n"+
|
||||||
|
"tcp:in:d=22:s=10.0.0.0/24\n"+
|
||||||
|
"\n"+
|
||||||
|
"# unrelated note\n"+
|
||||||
|
"# separated by blank\n"+
|
||||||
|
"192.0.2.5\n"+
|
||||||
|
"2001:db8::1 # inline ignored\n",
|
||||||
|
), 0644))
|
||||||
|
|
||||||
|
rules, err := fw.ParseIPList(path, Accept)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Advanced rule keeps the comment immediately above it.
|
||||||
|
adv := rules[0]
|
||||||
|
require.Equal(t, "trusted office", adv.Comment)
|
||||||
|
require.Equal(t, "10.0.0.0/24", adv.Source)
|
||||||
|
require.EqualValues(t, 22, adv.Port)
|
||||||
|
|
||||||
|
// A bare IPv4 line is one bidirectional DirAny rule carrying the accumulated comment.
|
||||||
|
host := rules[1]
|
||||||
|
require.Equal(t, DirAny, host.Direction)
|
||||||
|
require.Equal(t, "192.0.2.5", host.Source)
|
||||||
|
require.Equal(t, "unrelated note separated by blank", host.Comment)
|
||||||
|
|
||||||
|
// Inline comment is ignored, not treated as a rule comment.
|
||||||
|
v6 := rules[2]
|
||||||
|
require.Equal(t, DirAny, v6.Direction)
|
||||||
|
require.Equal(t, "", v6.Comment)
|
||||||
|
require.Equal(t, "2001:db8::1", v6.Source)
|
||||||
|
|
||||||
|
// Add a rule with a comment: a prefixed full-line comment is written above it.
|
||||||
|
add := &Rule{Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Accept, Comment: "web"}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, false))
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(data), "# myapp web\n")
|
||||||
|
require.Contains(t, string(data), "tcp:in:d=443:s=192.0.2.10")
|
||||||
|
|
||||||
|
// Removing the rule drops the comment line above it as well.
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, true))
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(data), "# myapp web")
|
||||||
|
require.NotContains(t, string(data), "192.0.2.10")
|
||||||
|
|
||||||
|
// A port-only rule has nowhere to go in an IP-list file; no dangling
|
||||||
|
// comment line should be written even when a comment is supplied.
|
||||||
|
portOnly := &Rule{Proto: TCP, Port: 8080, Action: Accept, Comment: "not-stored"}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, portOnly, false))
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(data), "not-stored")
|
||||||
|
|
||||||
|
// A rule appended after instructional header comments must still report
|
||||||
|
// HasPrefix: the prefix tag starts a fresh comment block so header
|
||||||
|
// comments are not absorbed into the rule's comment.
|
||||||
|
headerPath := filepath.Join(dir, "header_allow_hosts.rules")
|
||||||
|
require.NoError(t, os.WriteFile(headerPath, []byte(
|
||||||
|
"# This is the apf allow_hosts.rules file.\n"+
|
||||||
|
"# Add hosts/rules below, one per line.\n"+
|
||||||
|
"# Format: proto:flow:port:ip\n",
|
||||||
|
), 0644))
|
||||||
|
appendRule := &Rule{Proto: TCP, Port: 3456, Source: "192.0.2.10/32", Action: Accept}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, headerPath, Accept, appendRule, false))
|
||||||
|
parsed, err := fw.ParseIPList(headerPath, Accept)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, parsed, 1)
|
||||||
|
require.True(t, parsed[0].HasPrefix, "rule after header comments must be flagged with the prefix")
|
||||||
|
require.Equal(t, "", parsed[0].Comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPFRemovePreservesForeignHeader verifies that removing a managed rule keeps
|
||||||
|
// a foreign section header sitting directly above its prefix tag. ParseIPList
|
||||||
|
// treats the tag as starting a fresh comment block, so the header is not part of
|
||||||
|
// the rule's comment; removal must mirror that and not delete it.
|
||||||
|
func TestAPFRemovePreservesForeignHeader(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "allow_hosts.rules")
|
||||||
|
fw := &APF{rulePrefix: "myapp"}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(
|
||||||
|
"# Section: web servers\n"+
|
||||||
|
"# myapp trusted\n"+
|
||||||
|
"tcp:in:d=22:s=192.0.2.50/32\n",
|
||||||
|
), 0644))
|
||||||
|
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, &Rule{Proto: TCP, Port: 22, Source: "192.0.2.50/32", Action: Accept}, true))
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got := string(data)
|
||||||
|
require.NotContains(t, got, "tcp:in:d=22", "the managed rule must be removed")
|
||||||
|
require.NotContains(t, got, "# myapp trusted", "the rule's own tag comment is removed with it")
|
||||||
|
require.Contains(t, got, "# Section: web servers", "the foreign section header must be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// conf.apf's ALL_STOP accepts DROP, REJECT and PROHIBIT (upstream apf builds a
|
||||||
|
// dedicated PROHIBIT chain that rejects with an ICMP prohibited response). This
|
||||||
|
// model has no third action, so PROHIBIT must map to Reject like REJECT does,
|
||||||
|
// not silently fall into the DROP default alongside genuinely unrecognized
|
||||||
|
// values.
|
||||||
|
func TestAPFParseStopAction(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
cases := []struct {
|
||||||
|
val string
|
||||||
|
want Action
|
||||||
|
}{
|
||||||
|
{"DROP", Drop},
|
||||||
|
{"REJECT", Reject},
|
||||||
|
{"PROHIBIT", Reject},
|
||||||
|
{"prohibit", Reject},
|
||||||
|
{`"PROHIBIT"`, Reject},
|
||||||
|
{"", Drop},
|
||||||
|
{"BOGUS", Drop},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
require.Equal(t, c.want, fw.parseStopAction(c.val), "ALL_STOP=%q", c.val)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// conf.apf's ALL_STOP, TCP_STOP and UDP_STOP are independent settings (upstream
|
||||||
|
// apf_validate.sh validates each separately with no equality constraint, and
|
||||||
|
// they may be set differently on a real host). readStopAction/stopKey must
|
||||||
|
// read the setting matching a deny's protocol, not conflate them, so a fixture
|
||||||
|
// where they diverge is read back correctly.
|
||||||
|
func TestAPFReadStopActionIndependentSettings(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
dir := t.TempDir()
|
||||||
|
conf := filepath.Join(dir, "conf.apf")
|
||||||
|
require.NoError(t, os.WriteFile(conf, []byte("ALL_STOP=\"DROP\"\nTCP_STOP=\"REJECT\"\nUDP_STOP=\"DROP\"\n"), 0o644))
|
||||||
|
|
||||||
|
require.Equal(t, Drop, fw.readStopAction(conf, "ALL_STOP"))
|
||||||
|
require.Equal(t, Reject, fw.readStopAction(conf, "TCP_STOP"))
|
||||||
|
require.Equal(t, Drop, fw.readStopAction(conf, "UDP_STOP"))
|
||||||
|
|
||||||
|
require.Equal(t, "ALL_STOP", fw.stopKey(ProtocolAny))
|
||||||
|
require.Equal(t, "TCP_STOP", fw.stopKey(TCP))
|
||||||
|
require.Equal(t, "UDP_STOP", fw.stopKey(UDP))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before this fix, a tcp/udp advanced deny_hosts entry (a port-carrying rule) was
|
||||||
|
// stamped/matched with the ALL_STOP-derived action for every entry, including
|
||||||
|
// advanced ones. Upstream apf actually routes an advanced entry through
|
||||||
|
// trust_entry_rule, which ignores ALL_STOP entirely and applies TCP_STOP/UDP_STOP
|
||||||
|
// instead (files/internals/apf_trust.sh) — invisible on a stock install where all
|
||||||
|
// three default to DROP, but wrong whenever an operator sets them differently.
|
||||||
|
// resolveAction must re-derive the action for a tcp/udp rule rather than
|
||||||
|
// passing the ALL_STOP-derived base straight through; a bare (protocol-less)
|
||||||
|
// rule has no such override and keeps the base unchanged.
|
||||||
|
func TestAPFResolveActionDoesNotConflateAllStopWithProtocolStops(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
// A bare-address deny has no protocol; ALL_STOP governs it directly.
|
||||||
|
require.Equal(t, Reject, fw.resolveAction(Reject, ProtocolAny))
|
||||||
|
|
||||||
|
// A tcp/udp advanced deny must NOT simply inherit an ALL_STOP-derived base.
|
||||||
|
// With no real conf.apf in this test environment, TCP_STOP/UDP_STOP resolve
|
||||||
|
// to their stock default (Drop) — which must differ from the Reject base
|
||||||
|
// passed in to prove the value was actually re-derived, not passed through.
|
||||||
|
require.Equal(t, Drop, fw.resolveAction(Reject, TCP))
|
||||||
|
require.Equal(t, Drop, fw.resolveAction(Reject, UDP))
|
||||||
|
|
||||||
|
// Accept (allow_hosts) has no per-protocol distinction.
|
||||||
|
require.Equal(t, Accept, fw.resolveAction(Accept, TCP))
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRule's deny-action validation must check a tcp/udp advanced rule against
|
||||||
|
// TCP_STOP/UDP_STOP, not ALL_STOP: before this fix, a Reject deny with a tcp port
|
||||||
|
// was rejected as "unsupported" whenever the (irrelevant) ALL_STOP-derived action
|
||||||
|
// differed from Reject, even though nothing about ALL_STOP governs this entry.
|
||||||
|
func TestAPFAddRuleValidatesDenyActionPerProtocol(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fw := &APF{}
|
||||||
|
|
||||||
|
dir := t.TempDir()
|
||||||
|
deny := filepath.Join(dir, "deny_hosts.rules")
|
||||||
|
require.NoError(t, os.WriteFile(deny, nil, 0o644))
|
||||||
|
|
||||||
|
// AddRule itself always targets the real APFDeny path, so exercise the same
|
||||||
|
// validation + write path it uses (denyActionFor + EditIPList) directly
|
||||||
|
// against a fixture, mirroring AddRule's own logic.
|
||||||
|
r := &Rule{Action: Drop, Proto: TCP, Port: 8080, Source: "192.0.2.1", Family: IPv4}
|
||||||
|
denyAction := fw.denyActionFor(r.Proto)
|
||||||
|
require.Equal(t, Drop, denyAction, "stock/no-conf.apf default for TCP_STOP is Drop")
|
||||||
|
require.Equal(t, r.Action, denyAction, "a Drop tcp deny must be accepted (matches the TCP_STOP-derived default)")
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, deny, denyAction, r, false))
|
||||||
|
|
||||||
|
got, err := fw.ParseIPList(deny, denyAction)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
require.True(t, r.Equal(got[0], false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// apf's deny_hosts list encodes no action of its own, so a rule added with
|
||||||
|
// Action Drop (routed to deny_hosts) must still be found and removed by the same
|
||||||
|
// Drop rule — previously the removal matched against the file's deny action and
|
||||||
|
// silently left the rule in place (an unremovable, leaking rule).
|
||||||
|
func TestAPFDropRuleRemovable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "deny_hosts.rules")
|
||||||
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
||||||
|
fw := new(APF)
|
||||||
|
drop := &Rule{Family: IPv4, Source: "1.2.3.4", Action: Drop}
|
||||||
|
|
||||||
|
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false))
|
||||||
|
// A second add of the same rule must be idempotent (no duplicate line).
|
||||||
|
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false))
|
||||||
|
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, true))
|
||||||
|
data, _ := os.ReadFile(path)
|
||||||
|
require.NotContains(t, string(data), "1.2.3.4", "a Drop rule must be removable by the same Drop rule")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ported apf advanced rule that matches both a source and a destination address
|
||||||
|
// cannot be expressed (apf's advanced rule holds a single address field) and must
|
||||||
|
// be rejected rather than silently dropping the destination. The portless bare
|
||||||
|
// form is not tested here: AddRule diverts it to the raw-iptables hook
|
||||||
|
// (hostNeedsHook), so the writer never sees it.
|
||||||
|
func TestAPFDualAddressRejected(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
_, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 22, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept})
|
||||||
|
require.Error(t, err, "a dual-address apf advanced rule must be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// APF's IG_*_CPORTS lists are dual-stack (one list applied to both v4 and v6), so
|
||||||
|
// a port rule read from them must be FamilyAny, not IPv4. Otherwise a FamilyAny
|
||||||
|
// desired rule (the default) never matches its own read-back and Sync churns.
|
||||||
|
func TestAPFPortListFamilyIsAny(t *testing.T) {
|
||||||
|
f := new(APF)
|
||||||
|
rules := f.ParsePorts("22", TCP, false)
|
||||||
|
require.Len(t, rules, 1)
|
||||||
|
require.Equal(t, FamilyAny, rules[0].Family,
|
||||||
|
"a dual-stack CPORTS entry must read back as FamilyAny")
|
||||||
|
|
||||||
|
// End to end: a default (FamilyAny) desired rule equals its read-back.
|
||||||
|
desired := &Rule{Proto: TCP, Port: 22, Action: Accept}
|
||||||
|
require.True(t, desired.Equal(rules[0], true),
|
||||||
|
"FamilyAny tcp/22 must equal the APF read-back or Sync churns")
|
||||||
|
}
|
||||||
|
|
||||||
|
// APF's IG_*_CLIMIT lists are likewise dual-stack, so a connection-limit rule
|
||||||
|
// read from them must be FamilyAny to reconcile with a FamilyAny desired rule.
|
||||||
|
func TestAPFConnLimitFamilyIsAny(t *testing.T) {
|
||||||
|
f := new(APF)
|
||||||
|
rules := f.ParseConnLimit("80:50", TCP)
|
||||||
|
require.Len(t, rules, 1)
|
||||||
|
require.Equal(t, FamilyAny, rules[0].Family,
|
||||||
|
"a dual-stack CLIMIT entry must read back as FamilyAny")
|
||||||
|
|
||||||
|
desired := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 50, PerSource: true}}
|
||||||
|
require.True(t, desired.Equal(rules[0], true),
|
||||||
|
"FamilyAny connlimit must equal the APF read-back or Sync churns")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAPFBareProtocolRoutesToHook is the apf analogue of the csf case: a bare
|
||||||
|
// protocol match (no port, no address) has no native apf construct but iptables
|
||||||
|
// expresses it directly, so AddRule diverts it to the pre-hook via needsHook
|
||||||
|
// rather than rejecting it. A native address-less ICMP accept stays out of the
|
||||||
|
// hook (it lives in conf.apf's type lists).
|
||||||
|
func TestAPFBareProtocolRoutesToHook(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
for _, r := range []*Rule{
|
||||||
|
{Proto: TCP, Action: Accept},
|
||||||
|
{Proto: ProtocolAny, Action: Accept},
|
||||||
|
{Proto: UDP, Action: Drop},
|
||||||
|
} {
|
||||||
|
require.True(t, fw.needsHook(r),
|
||||||
|
"a portless, addressless rule must route to the hook, not be rejected: %+v", r)
|
||||||
|
}
|
||||||
|
require.False(t, fw.needsHook(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}),
|
||||||
|
"a native address-less ICMP accept stays in conf.apf, not the hook")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port-only deny must not corrupt the rule's family. apf requires an address
|
||||||
|
// field, so it writes an "any" placeholder — previously the IPv4 literal
|
||||||
|
// 0.0.0.0/0 regardless of family, so a family-neutral rule read back as IPv4
|
||||||
|
// (Sync churn) and an IPv6 rule became IPv4-only. The placeholder now matches the
|
||||||
|
// rule's family (a family-neutral rule covers both, merging back on read).
|
||||||
|
//
|
||||||
|
// The rule action is Drop, not Reject: a tcp port-carrying deny_hosts entry is an
|
||||||
|
// apf "advanced" entry, which apf routes through TCP_STOP (not ALL_STOP) — Drop is
|
||||||
|
// the stock default for both, and is the value denyActionFor actually resolves
|
||||||
|
// to here since no real conf.apf exists in this test environment. Using an action
|
||||||
|
// apf would not actually apply to this entry would make EditIPList reject it.
|
||||||
|
func TestAPFPortOnlyRejectFamily(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
ctx := context.Background()
|
||||||
|
for _, rule := range []*Rule{
|
||||||
|
{Action: Drop, Proto: TCP, Port: 80},
|
||||||
|
{Action: Drop, Proto: TCP, Port: 80, Family: IPv4},
|
||||||
|
{Action: Drop, Proto: TCP, Port: 8080, Family: IPv6},
|
||||||
|
} {
|
||||||
|
deny := filepath.Join(t.TempDir(), "deny_hosts.rules")
|
||||||
|
require.NoError(t, os.WriteFile(deny, nil, 0o644))
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, deny, Drop, rule, false))
|
||||||
|
|
||||||
|
// GetRules applies mergeFamilies to the parsed list; mirror it here.
|
||||||
|
got, err := fw.ParseIPList(deny, Drop)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got = mergeFamilies(got)
|
||||||
|
require.Len(t, got, 1, "port-only deny (%s) must round-trip to one rule", rule.Family)
|
||||||
|
require.True(t, rule.Equal(got[0], false), "read-back rule must equal the written one; want family=%s got family=%s", rule.Family, got[0].Family)
|
||||||
|
|
||||||
|
// It must also be removable (matched back on delete).
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, deny, Drop, rule, true))
|
||||||
|
got, err = fw.ParseIPList(deny, Drop)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 0, "rule (%s) must be fully removed", rule.Family)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare all-protocol host rule (address, no port) is the one portless address
|
||||||
|
// shape apf's trust files express, written as the plain address line. The
|
||||||
|
// inexpressible shapes — a concrete-protocol host or a source+destination pair —
|
||||||
|
// are diverted to the hook by AddRule (hostNeedsHook) and never reach this
|
||||||
|
// writer, so only the legitimate write is exercised here.
|
||||||
|
func TestAPFBareHostWritten(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
list := filepath.Join(t.TempDir(), "allow_hosts.rules")
|
||||||
|
require.NoError(t, os.WriteFile(list, nil, 0o644))
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, list, Accept, &Rule{Source: "1.2.3.4", Action: Accept}, false))
|
||||||
|
got, err := os.ReadFile(list)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(got), "1.2.3.4", "an any-protocol host rule must be written as a plain address")
|
||||||
|
}
|
||||||
|
|
||||||
|
// needsHook selects the multi-port lists apf's config cannot express, so
|
||||||
|
// AddRule diverts them to the hook. A single port or one range is a valid apf
|
||||||
|
// port token (stays native); an address-less tcp/udp accept list lives in
|
||||||
|
// conf.apf's CPORTS lists; a non-tcp/udp match is left to iptablesRuleValid.
|
||||||
|
func TestAPFPortNeedsHook(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
list := []PortRange{{Start: 1000, End: 1000}, {Start: 2000, End: 2000}}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rule *Rule
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"multi-port deny", &Rule{Proto: TCP, Ports: list, Action: Reject}, true},
|
||||||
|
{"multi-port host accept", &Rule{Proto: TCP, Ports: list, Source: "1.2.3.4", Action: Accept}, true},
|
||||||
|
{"multi-source-port host", &Rule{Proto: UDP, SourcePorts: list, Source: "1.2.3.4", Action: Accept}, true},
|
||||||
|
{"address-less accept list", &Rule{Proto: TCP, Ports: list, Action: Accept}, false},
|
||||||
|
{"single port deny", &Rule{Proto: TCP, Port: 1000, Action: Reject}, false},
|
||||||
|
{"single range host", &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Source: "1.2.3.4", Action: Accept}, false},
|
||||||
|
{"multi-port any-proto", &Rule{Ports: list, Action: Reject}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
require.Equal(t, c.want, fw.needsHook(c.rule), c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// APF EditIPList must write the missing IPv6 line when adding the IPv6 twin of an
|
||||||
|
// existing IPv4 port-only deny; the family-agnostic EqualBase check previously
|
||||||
|
// treated the IPv4 line as covering IPv6 and wrote nothing, leaving IPv6 open.
|
||||||
|
func TestAPFCrossFamilyDenyAddsMissingFamily(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "deny_hosts.rules")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("tcp:in:d=80:s=0.0.0.0/0\n"), 0o644))
|
||||||
|
|
||||||
|
f := &APF{}
|
||||||
|
r := &Rule{Proto: TCP, Port: 80, Direction: DirInput, Family: IPv6, Action: Drop}
|
||||||
|
require.NoError(t, f.EditIPList(context.Background(), path, Drop, r, false))
|
||||||
|
|
||||||
|
out, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(out), "::", "an IPv6 (::/0) deny line must be written so IPv6 port 80 is blocked")
|
||||||
|
require.Contains(t, string(out), "0.0.0.0/0", "the existing IPv4 deny must be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// APF RemoveRule of an IPv4-pinned port-only deny must not take out the IPv6 twin:
|
||||||
|
// EqualForRemoval gates the family so removing one family keeps the other.
|
||||||
|
func TestAPFCrossFamilyRemoveKeepsOppositeFamily(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "deny_hosts.rules")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("tcp:in:d=80:s=0.0.0.0/0\ntcp:in:d=80:s=[::/0]\n"), 0o644))
|
||||||
|
|
||||||
|
f := &APF{}
|
||||||
|
// Remove only the IPv4 port-80 deny.
|
||||||
|
r := &Rule{Proto: TCP, Port: 80, Direction: DirInput, Family: IPv4, Action: Drop}
|
||||||
|
require.NoError(t, f.EditIPList(context.Background(), path, Drop, r, true))
|
||||||
|
|
||||||
|
out, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(out), "[::/0]", "the IPv6 deny must survive removing the IPv4 twin")
|
||||||
|
require.NotContains(t, string(out), "0.0.0.0/0", "the IPv4 deny must be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Editing an existing connection-limit entry's count must record a config change
|
||||||
|
// so Reload runs apf --restart and the new limit is applied; an unchanged count
|
||||||
|
// must not trigger a spurious restart.
|
||||||
|
func TestAPFConnLimitCountChangeReloads(t *testing.T) {
|
||||||
|
fw := new(APF)
|
||||||
|
|
||||||
|
// Changing the count from 50 to 25 must flag a config change.
|
||||||
|
fw.ConfigChanged = false
|
||||||
|
out := fw.editConnLimit("IG_TCP_CLIMIT", "80:50",
|
||||||
|
&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 25, PerSource: true}}, false)
|
||||||
|
require.Equal(t, `IG_TCP_CLIMIT="80:25"`, out)
|
||||||
|
require.True(t, fw.ConfigChanged, "a changed connlimit count must set ConfigChanged")
|
||||||
|
|
||||||
|
// Re-applying the same count must not flag a change.
|
||||||
|
fw.ConfigChanged = false
|
||||||
|
out = fw.editConnLimit("IG_TCP_CLIMIT", "80:25",
|
||||||
|
&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 25, PerSource: true}}, false)
|
||||||
|
require.Equal(t, `IG_TCP_CLIMIT="80:25"`, out)
|
||||||
|
require.False(t, fw.ConfigChanged, "an unchanged connlimit count must not set ConfigChanged")
|
||||||
|
}
|
||||||
275
backup.go
Normal file
275
backup.go
Normal file
|
|
@ -0,0 +1,275 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file makes a Backup portable: it can be serialized to and from JSON so a
|
||||||
|
// snapshot taken on one host can be replayed on another (or persisted to disk).
|
||||||
|
//
|
||||||
|
// The rule/NAT-rule structs are plain data, but their enum fields (Action,
|
||||||
|
// Family, Protocol, ...) are iota-based uint8 values. Left to encoding/json
|
||||||
|
// those would marshal as bare numbers, which are unreadable and would silently
|
||||||
|
// change meaning if a constant were ever reordered. They are therefore given
|
||||||
|
// MarshalJSON/UnmarshalJSON implementations that carry the canonical, stable
|
||||||
|
// string name each type's String method already emits. The two generic helpers
|
||||||
|
// below keep that boilerplate to one line per type.
|
||||||
|
|
||||||
|
// marshalEnum renders an enum value as its canonical string name.
|
||||||
|
func marshalEnum[T ~uint8](v T, str func(T) string) ([]byte, error) {
|
||||||
|
return json.Marshal(str(v))
|
||||||
|
}
|
||||||
|
|
||||||
|
// unmarshalEnum parses an enum value from its canonical string name via parse.
|
||||||
|
func unmarshalEnum[T ~uint8](data []byte, parse func(string) (T, error)) (T, error) {
|
||||||
|
var zero T
|
||||||
|
var s string
|
||||||
|
if err := json.Unmarshal(data, &s); err != nil {
|
||||||
|
return zero, err
|
||||||
|
}
|
||||||
|
return parse(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON renders the action as its stable name (e.g. "accept").
|
||||||
|
func (a Action) MarshalJSON() ([]byte, error) { return marshalEnum(a, Action.String) }
|
||||||
|
|
||||||
|
// UnmarshalJSON parses an action from its stable name. The sentinel "invalid"
|
||||||
|
// (ActionInvalid) is accepted on the wire for round-trip fidelity even though
|
||||||
|
// ParseAction rejects it as caller input.
|
||||||
|
func (a *Action) UnmarshalJSON(data []byte) error {
|
||||||
|
v, err := unmarshalEnum(data, func(s string) (Action, error) {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(s), "invalid") {
|
||||||
|
return ActionInvalid, nil
|
||||||
|
}
|
||||||
|
return ParseAction(s)
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*a = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON renders the family as its stable name (e.g. "ipv4").
|
||||||
|
func (f Family) MarshalJSON() ([]byte, error) { return marshalEnum(f, Family.String) }
|
||||||
|
|
||||||
|
// UnmarshalJSON parses a family from its stable name.
|
||||||
|
func (f *Family) UnmarshalJSON(data []byte) error {
|
||||||
|
v, err := unmarshalEnum(data, ParseFamily)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*f = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON renders the protocol as its stable name (e.g. "tcp").
|
||||||
|
func (p Protocol) MarshalJSON() ([]byte, error) { return marshalEnum(p, Protocol.String) }
|
||||||
|
|
||||||
|
// UnmarshalJSON parses a protocol from its stable name. An unrecognized token is
|
||||||
|
// rejected rather than silently widening the rule to ProtocolAny; only the
|
||||||
|
// wildcard spellings ("any" or empty) resolve to ProtocolAny.
|
||||||
|
func (p *Protocol) UnmarshalJSON(data []byte) error {
|
||||||
|
v, err := unmarshalEnum(data, func(s string) (Protocol, error) {
|
||||||
|
proto := GetProtocol(s)
|
||||||
|
t := strings.TrimSpace(s)
|
||||||
|
if proto == ProtocolAny && t != "" && !strings.EqualFold(t, "any") {
|
||||||
|
return 0, fmt.Errorf("unknown protocol %q", s)
|
||||||
|
}
|
||||||
|
return proto, nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*p = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON renders the connection-state set as a comma-joined name list.
|
||||||
|
func (s ConnState) MarshalJSON() ([]byte, error) { return marshalEnum(s, ConnState.String) }
|
||||||
|
|
||||||
|
// UnmarshalJSON parses a connection-state set from a comma-joined name list.
|
||||||
|
func (s *ConnState) UnmarshalJSON(data []byte) error {
|
||||||
|
v, err := unmarshalEnum(data, func(tok string) (ConnState, error) { return ParseConnState(tok) })
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*s = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON renders the rate unit as its stable name (e.g. "minute").
|
||||||
|
func (u RateUnit) MarshalJSON() ([]byte, error) { return marshalEnum(u, RateUnit.String) }
|
||||||
|
|
||||||
|
// UnmarshalJSON parses a rate unit from its stable name.
|
||||||
|
func (u *RateUnit) UnmarshalJSON(data []byte) error {
|
||||||
|
v, err := unmarshalEnum(data, ParseRateUnit)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*u = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON renders the NAT kind as its stable name (e.g. "dnat").
|
||||||
|
func (k NATKind) MarshalJSON() ([]byte, error) { return marshalEnum(k, NATKind.String) }
|
||||||
|
|
||||||
|
// UnmarshalJSON parses a NAT kind from its stable name.
|
||||||
|
func (k *NATKind) UnmarshalJSON(data []byte) error {
|
||||||
|
v, err := unmarshalEnum(data, ParseNATKind)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*k = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON renders the direction as its stable name (e.g. "input").
|
||||||
|
func (d Direction) MarshalJSON() ([]byte, error) { return marshalEnum(d, Direction.String) }
|
||||||
|
|
||||||
|
// UnmarshalJSON parses a direction from its stable name.
|
||||||
|
func (d *Direction) UnmarshalJSON(data []byte) error {
|
||||||
|
v, err := unmarshalEnum(data, ParseDirection)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*d = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON renders the set type as its stable name (e.g. "hash:net").
|
||||||
|
func (t SetType) MarshalJSON() ([]byte, error) { return marshalEnum(t, SetType.String) }
|
||||||
|
|
||||||
|
// UnmarshalJSON parses a set type from its stable name.
|
||||||
|
func (t *SetType) UnmarshalJSON(data []byte) error {
|
||||||
|
v, err := unmarshalEnum(data, ParseSetType)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
*t = v
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// captureBackupState fills a backup's DefaultPolicy and AddressSets from the
|
||||||
|
// backend, for the backends that advertise those features. It is shared by every
|
||||||
|
// Backup implementation so a snapshot captures the full managed state — not just
|
||||||
|
// filter/NAT rules — without each backend re-probing capabilities. A backend that
|
||||||
|
// advertises neither feature leaves both fields nil, so the snapshot is unchanged.
|
||||||
|
func captureBackupState(ctx context.Context, mgr Manager, zoneName string, b *Backup) error {
|
||||||
|
caps := mgr.Capabilities()
|
||||||
|
if caps.DefaultPolicy {
|
||||||
|
// Best-effort: a backend that cannot report a single coherent default policy
|
||||||
|
// (iptables with out-of-band divergent IPv4/IPv6 chain policies) captures none
|
||||||
|
// rather than failing the whole backup. A genuine read error would already have
|
||||||
|
// surfaced from the GetRules call the Backup makes before this, which reads the
|
||||||
|
// same source; leaving the field nil then makes Restore leave the policy as it
|
||||||
|
// finds it.
|
||||||
|
if policy, err := mgr.GetDefaultPolicy(ctx, zoneName); err == nil {
|
||||||
|
b.DefaultPolicy = policy
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if caps.AddressSets {
|
||||||
|
sets, err := mgr.GetAddressSets(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b.AddressSets = sets
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreBackupSets recreates a backup's address sets so a set-referencing rule
|
||||||
|
// (@set) resolves on Restore. It runs before the filter rules are re-added. Only
|
||||||
|
// backends that advertise AddressSets act. cleanFirst removes each set before
|
||||||
|
// recreating it: a caller whose restore has already cleared the rules that could
|
||||||
|
// reference a set (a container backend that flushes its table/anchor) passes true
|
||||||
|
// so the set is rebuilt from a clean slate — needed for nftables, whose
|
||||||
|
// AddAddressSet is a no-op on an existing set and so would not reconcile its
|
||||||
|
// entries. A caller whose old rules are still loaded when sets are recreated
|
||||||
|
// (a tag/rewrite backend) passes false and relies on AddAddressSet's own
|
||||||
|
// idempotent create-or-repopulate (ipset -exist, pfctl -T add).
|
||||||
|
func restoreBackupSets(ctx context.Context, mgr Manager, b *Backup, cleanFirst bool) error {
|
||||||
|
if b == nil || !mgr.Capabilities().AddressSets {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, set := range b.AddressSets {
|
||||||
|
if cleanFirst {
|
||||||
|
if err := mgr.RemoveAddressSet(ctx, set.Name); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := mgr.AddAddressSet(ctx, set); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyBackupPolicy re-asserts a backup's default policy, after the rules are
|
||||||
|
// restored (the policy is independent of rule order). Only backends that
|
||||||
|
// advertise DefaultPolicy act, and a nil snapshot policy (a backend that captured
|
||||||
|
// none) is left unchanged.
|
||||||
|
func applyBackupPolicy(ctx context.Context, mgr Manager, zoneName string, b *Backup) error {
|
||||||
|
if b == nil || b.DefaultPolicy == nil || !mgr.Capabilities().DefaultPolicy {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mgr.SetDefaultPolicy(ctx, zoneName, b.DefaultPolicy)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteBackup encodes backup as JSON to w so it can be persisted to disk (or
|
||||||
|
// moved to another host) and later replayed with ReadBackup or RestoreReader.
|
||||||
|
//
|
||||||
|
// The encoding is portable: enum fields are written as their stable string
|
||||||
|
// names, so a backup survives a reordering of the library's iota constants and
|
||||||
|
// is readable on a host running a different version of the library. Per-rule
|
||||||
|
// counters (Packets/Bytes) are carried through for the record but are ignored
|
||||||
|
// when a backup is restored (they are not part of rule identity).
|
||||||
|
//
|
||||||
|
// f, _ := os.Create("backup.json")
|
||||||
|
// _ = firewall.WriteBackup(f, backup)
|
||||||
|
func WriteBackup(w io.Writer, backup *Backup) error {
|
||||||
|
if backup == nil {
|
||||||
|
return fmt.Errorf("backup cannot be nil")
|
||||||
|
}
|
||||||
|
if w == nil {
|
||||||
|
return fmt.Errorf("writer cannot be nil")
|
||||||
|
}
|
||||||
|
enc := json.NewEncoder(w)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(backup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadBackup decodes a backup previously written by WriteBackup from r.
|
||||||
|
//
|
||||||
|
// f, _ := os.Open("backup.json")
|
||||||
|
// backup, _ := firewall.ReadBackup(f)
|
||||||
|
// _ = mgr.Restore(ctx, zone, backup)
|
||||||
|
func ReadBackup(r io.Reader) (*Backup, error) {
|
||||||
|
if r == nil {
|
||||||
|
return nil, fmt.Errorf("reader cannot be nil")
|
||||||
|
}
|
||||||
|
var backup Backup
|
||||||
|
if err := json.NewDecoder(r).Decode(&backup); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &backup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreReader reads a backup from r and applies it via mgr.Restore. It is the
|
||||||
|
// streaming counterpart of Manager.Restore: a caller can replay a backup
|
||||||
|
// straight from a file or network reader without first buffering it into a
|
||||||
|
// *Backup. A read error is returned before any rules are touched.
|
||||||
|
//
|
||||||
|
// f, _ := os.Open("backup.json")
|
||||||
|
// _ = firewall.RestoreReader(ctx, mgr, zone, f)
|
||||||
|
func RestoreReader(ctx context.Context, mgr Manager, zoneName string, r io.Reader) error {
|
||||||
|
backup, err := ReadBackup(r)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return mgr.Restore(ctx, zoneName, backup)
|
||||||
|
}
|
||||||
120
capabilities_linux_test.go
Normal file
120
capabilities_linux_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestCSFAPFStillUnsupported(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
csf := &CSF{}
|
||||||
|
apf := &APF{}
|
||||||
|
|
||||||
|
// Logging, rate limiting, connection-state and interface matching are now
|
||||||
|
// expressed by injecting iptables rules through the pre-hook (see
|
||||||
|
// TestHookScriptRoundTrip), so those route to the hook rather than being
|
||||||
|
// rejected. What remains genuinely unsupported: address sets and explicit
|
||||||
|
// rule ordering on both backends, plus source NAT on CSF.
|
||||||
|
for _, m := range []Manager{csf, apf} {
|
||||||
|
require.ErrorIs(t, m.AddAddressSet(ctx, &AddressSet{Name: "x"}), ErrUnsupportedSet,
|
||||||
|
"%s should reject address sets", m.Type())
|
||||||
|
require.ErrorIs(t, m.InsertRule(ctx, "", 1, &Rule{Port: 22, Proto: TCP, Action: Accept}), ErrUnsupportedOrdering,
|
||||||
|
"%s should reject explicit ordering", m.Type())
|
||||||
|
// NAT ordering is unsupported on both even though they store NAT rules.
|
||||||
|
dnat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080}
|
||||||
|
require.ErrorIs(t, m.InsertNATRule(ctx, "", 1, dnat), ErrUnsupportedOrdering,
|
||||||
|
"%s should reject NAT ordering", m.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// csf.redirect expresses only destination NAT, so a source NAT is rejected
|
||||||
|
// with the NAT sentinel before any file is touched.
|
||||||
|
snat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"}
|
||||||
|
require.ErrorIs(t, csf.AddNATRule(ctx, "", snat), ErrUnsupportedNAT,
|
||||||
|
"csf should reject source NAT")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every unsupported-feature path wraps a sentinel error that callers can match
|
||||||
|
// with errors.Is. The umbrella ErrUnsupported matches them all.
|
||||||
|
func TestSentinelErrors(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
csf := &CSF{}
|
||||||
|
|
||||||
|
// NAT, policy and address-set rejections carry their specific sentinel. CSF
|
||||||
|
// supports destination NAT (csf.redirect) but not source NAT, so a SNAT rule
|
||||||
|
// carries the NAT sentinel.
|
||||||
|
nat := &NATRule{Kind: SNAT, ToAddress: "1.2.3.4"}
|
||||||
|
err := csf.AddNATRule(ctx, "", nat)
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedNAT)
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported)
|
||||||
|
|
||||||
|
_, err = csf.GetDefaultPolicy(ctx, "")
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedPolicy)
|
||||||
|
|
||||||
|
err = csf.AddAddressSet(ctx, &AddressSet{Name: "x"})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedSet)
|
||||||
|
|
||||||
|
// The shared per-rule reject helper wraps sentinels too (used by the wf backend).
|
||||||
|
err = (&Rule{Proto: TCP, Port: 22, Action: Accept, Log: true}).rejectLogAndLimit("csf")
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedLog)
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities advertise each backend's supported features consistently with
|
||||||
|
// its actual behavior.
|
||||||
|
func TestCapabilities(t *testing.T) {
|
||||||
|
nft := &NFT{}
|
||||||
|
ipt := &IPTables{}
|
||||||
|
csf := &CSF{}
|
||||||
|
apf := &APF{}
|
||||||
|
|
||||||
|
require.True(t, nft.Capabilities().RuleCounters, "nftables exposes rule counters")
|
||||||
|
require.True(t, nft.Capabilities().DefaultPolicy, "nftables manages a default policy")
|
||||||
|
require.True(t, nft.Capabilities().AddressSets, "nftables manages address sets")
|
||||||
|
require.True(t, nft.Capabilities().NAT)
|
||||||
|
|
||||||
|
require.True(t, ipt.Capabilities().RuleCounters, "iptables exposes rule counters")
|
||||||
|
require.True(t, ipt.Capabilities().DefaultPolicy)
|
||||||
|
require.True(t, ipt.Capabilities().AddressSets, "iptables manages ipsets")
|
||||||
|
|
||||||
|
// CSF is a deliberately minimal backend: no counters, no policy, no sets.
|
||||||
|
require.False(t, csf.Capabilities().RuleCounters)
|
||||||
|
require.False(t, csf.Capabilities().DefaultPolicy)
|
||||||
|
require.False(t, csf.Capabilities().AddressSets)
|
||||||
|
// CSF does express (destination) NAT through csf.redirect and per-port
|
||||||
|
// connection limiting through CONNLIMIT.
|
||||||
|
require.True(t, csf.Capabilities().NAT)
|
||||||
|
require.True(t, csf.Capabilities().ConnLimit)
|
||||||
|
|
||||||
|
// APF likewise gains NAT (routing files) and connection limiting
|
||||||
|
// (IG_*_CLIMIT) from its native config, but still no address sets.
|
||||||
|
require.True(t, apf.Capabilities().NAT)
|
||||||
|
require.True(t, apf.Capabilities().ConnLimit)
|
||||||
|
require.False(t, apf.Capabilities().AddressSets)
|
||||||
|
|
||||||
|
// Both gain logging, rate limiting, connection-state, interface matching and
|
||||||
|
// forward-chain rules by injecting iptables rules through the pre-hook.
|
||||||
|
for _, c := range []Capabilities{csf.Capabilities(), apf.Capabilities()} {
|
||||||
|
require.True(t, c.Logging)
|
||||||
|
require.True(t, c.RateLimit)
|
||||||
|
require.True(t, c.ConnState)
|
||||||
|
require.True(t, c.InterfaceMatch)
|
||||||
|
require.True(t, c.Forward)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nftables, iptables and ufw express forward-chain rules natively.
|
||||||
|
require.True(t, nft.Capabilities().Forward, "nftables expresses forward rules")
|
||||||
|
require.True(t, ipt.Capabilities().Forward, "iptables expresses forward rules")
|
||||||
|
|
||||||
|
// CSF's ICMPv6 always goes through that same pre-hook (raw ip6tables), so it
|
||||||
|
// is unconditionally supported regardless of csf.conf's IPV6 setting.
|
||||||
|
require.True(t, csf.Capabilities().ICMPv6)
|
||||||
|
|
||||||
|
// APF's plain ICMPv6 rules instead use its native IG_ICMPV6_TYPES/
|
||||||
|
// EG_ICMPV6_TYPES lists, which apf itself silently no-ops unless conf.apf's
|
||||||
|
// USE_IPV6 is "1" — so the capability mirrors ipv6Enabled.
|
||||||
|
require.False(t, apf.Capabilities().ICMPv6, "USE_IPV6 not confirmed enabled, so ICMPv6 must not be advertised")
|
||||||
|
apfV6 := &APF{ipv6Enabled: true}
|
||||||
|
require.True(t, apfV6.Capabilities().ICMPv6)
|
||||||
|
}
|
||||||
97
cmd/go-firewall/backup.go
Normal file
97
cmd/go-firewall/backup.go
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
fw "github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds the backup and restore subcommands. A backup captures the
|
||||||
|
// managed filter and NAT rules as a portable JSON snapshot (via the library's
|
||||||
|
// WriteBackup/ReadBackup/RestoreReader), so a snapshot taken on one host can
|
||||||
|
// be replayed on another or persisted for later.
|
||||||
|
|
||||||
|
// BackupSubcmd snapshots the managed rules to a file or stdout. The output is
|
||||||
|
// the portable JSON form the library's WriteBackup emits, so it round-trips
|
||||||
|
// across hosts and library versions.
|
||||||
|
type BackupSubcmd struct {
|
||||||
|
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
|
||||||
|
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
|
||||||
|
File string `name:"file" short:"o" help:"Write to this file (default: stdout)." placeholder:"FILE"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run writes the managed-rule snapshot to the file or stdout.
|
||||||
|
func (c *BackupSubcmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
backup, err := mgr.Backup(context.Background(), zone)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("backup: %w", err)
|
||||||
|
}
|
||||||
|
// WriteBackup always emits JSON; --json here would be redundant, so we
|
||||||
|
// ignore the global flag and just write the snapshot.
|
||||||
|
w := os.Stdout
|
||||||
|
if c.File != "" && c.File != "-" {
|
||||||
|
f, err := os.Create(c.File)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", c.File, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
w = f
|
||||||
|
}
|
||||||
|
if err := fw.WriteBackup(w, backup); err != nil {
|
||||||
|
return fmt.Errorf("write backup: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RestoreSubcmd replays a backup from a file or stdin. Existing managed rules
|
||||||
|
// are removed before the backup rules are applied (the library's Restore
|
||||||
|
// semantics), so the managed set ends up exactly equal to the backup.
|
||||||
|
type RestoreSubcmd struct {
|
||||||
|
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
|
||||||
|
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
|
||||||
|
File string `name:"file" short:"f" help:"Read from this file (default: stdin)." placeholder:"FILE"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run replays a backup snapshot read from the file or stdin.
|
||||||
|
func (c *RestoreSubcmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
r := os.Stdin
|
||||||
|
if c.File != "" && c.File != "-" {
|
||||||
|
f, err := os.Open(c.File)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open %s: %w", c.File, err)
|
||||||
|
}
|
||||||
|
defer func() { _ = f.Close() }()
|
||||||
|
r = f
|
||||||
|
}
|
||||||
|
// RestoreReader decodes and applies in one step, failing before any rules
|
||||||
|
// are touched if the file is malformed.
|
||||||
|
if err := fw.RestoreReader(context.Background(), mgr, zone, r); err != nil {
|
||||||
|
return fmt.Errorf("restore: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("restored")
|
||||||
|
}
|
||||||
317
cmd/go-firewall/cli_test.go
Normal file
317
cmd/go-firewall/cli_test.go
Normal file
|
|
@ -0,0 +1,317 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
fw "github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests cover the CLI's pure logic — flag parsing, rate/ICMP token
|
||||||
|
// parsing, and the human-readable renderers. They never open a firewall
|
||||||
|
// manager and never touch the host, so they run anywhere `go test` does.
|
||||||
|
|
||||||
|
func TestParseRateToken(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
rate uint
|
||||||
|
unit fw.RateUnit
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"10/second", 10, fw.PerSecond, false},
|
||||||
|
{"20/m", 20, fw.PerMinute, false},
|
||||||
|
{"5/hour", 5, fw.PerHour, false},
|
||||||
|
{"1/d", 1, fw.PerDay, false},
|
||||||
|
{"10", 0, 0, true}, // missing unit
|
||||||
|
{"abc/minute", 0, 0, true}, // non-numeric rate
|
||||||
|
{"10/fortnight", 0, 0, true}, // unknown unit
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
rate, unit, err := parseRateToken(c.in)
|
||||||
|
if c.wantErr {
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("parseRateToken(%q): want error, got nil", c.in)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("parseRateToken(%q): unexpected error: %v", c.in, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rate != c.rate || unit != c.unit {
|
||||||
|
t.Errorf("parseRateToken(%q): got %d/%v, want %d/%v", c.in, rate, unit, c.rate, c.unit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParseICMPType(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
in string
|
||||||
|
v6 bool
|
||||||
|
want uint8
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"8", false, 8, true},
|
||||||
|
{"echo-request", false, 8, true},
|
||||||
|
{"ping", false, 8, true},
|
||||||
|
{"ECHO-REQUEST", false, 8, true}, // case-insensitive
|
||||||
|
{"destination-unreachable", false, 3, true},
|
||||||
|
{"255", false, 255, true},
|
||||||
|
{"echo_boop", false, 0, false}, // unknown name
|
||||||
|
// ICMPv6 resolves the same names to different numbers.
|
||||||
|
{"echo-request", true, 128, true},
|
||||||
|
{"ping", true, 128, true},
|
||||||
|
{"destination-unreachable", true, 1, true},
|
||||||
|
{"nd-neighbor-solicit", true, 135, true},
|
||||||
|
{"128", true, 128, true},
|
||||||
|
// A name that only exists in the v4 table is unknown under v6.
|
||||||
|
{"source-quench", true, 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := fw.ParseICMPType(c.in, c.v6)
|
||||||
|
if ok != c.ok {
|
||||||
|
t.Errorf("fw.ParseICMPType(%q, v6=%v): ok=%v want %v", c.in, c.v6, ok, c.ok)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ok && got != c.want {
|
||||||
|
t.Errorf("fw.ParseICMPType(%q, v6=%v): got %d want %d", c.in, c.v6, got, c.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleFlagsToRule(t *testing.T) {
|
||||||
|
// A representative rule exercising most flag-driven fields. The point is
|
||||||
|
// to confirm the string flags route through the library's Parse* helpers
|
||||||
|
// into the right struct fields.
|
||||||
|
flags := ruleFlags{
|
||||||
|
Action: "drop",
|
||||||
|
Family: "ipv4",
|
||||||
|
Proto: "tcp",
|
||||||
|
Source: "192.168.0.0/24",
|
||||||
|
Destination: "10.0.0.1",
|
||||||
|
Port: 443,
|
||||||
|
Ports: "80,443,1000-2000",
|
||||||
|
ICMPType: "echo-request",
|
||||||
|
State: "new,established",
|
||||||
|
Log: true,
|
||||||
|
LogPrefix: "https",
|
||||||
|
RateLimit: "10/minute",
|
||||||
|
RateBurst: 5,
|
||||||
|
ConnLimit: 100,
|
||||||
|
ConnPerSrc: true,
|
||||||
|
InInterface: "eth0",
|
||||||
|
Comment: "web",
|
||||||
|
}
|
||||||
|
rule, err := flags.toRule()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("toRule: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if rule.Action != fw.Drop {
|
||||||
|
t.Errorf("Action: got %v want drop", rule.Action)
|
||||||
|
}
|
||||||
|
if rule.Family != fw.IPv4 {
|
||||||
|
t.Errorf("Family: got %v want ipv4", rule.Family)
|
||||||
|
}
|
||||||
|
if rule.Proto != fw.TCP {
|
||||||
|
t.Errorf("Proto: got %v want tcp", rule.Proto)
|
||||||
|
}
|
||||||
|
if rule.Source != "192.168.0.0/24" {
|
||||||
|
t.Errorf("Source: got %q", rule.Source)
|
||||||
|
}
|
||||||
|
if len(rule.Ports) != 3 || rule.Ports[2].Start != 1000 || rule.Ports[2].End != 2000 {
|
||||||
|
t.Errorf("Ports: got %+v", rule.Ports)
|
||||||
|
}
|
||||||
|
if rule.ICMPType == nil || *rule.ICMPType != 8 {
|
||||||
|
t.Errorf("ICMPType: got %+v", rule.ICMPType)
|
||||||
|
}
|
||||||
|
if rule.State != (fw.StateNew | fw.StateEstablished) {
|
||||||
|
t.Errorf("State: got %v", rule.State)
|
||||||
|
}
|
||||||
|
if !rule.Log || rule.LogPrefix != "https" {
|
||||||
|
t.Errorf("Log: got %v/%q", rule.Log, rule.LogPrefix)
|
||||||
|
}
|
||||||
|
if rule.RateLimit == nil || rule.RateLimit.Rate != 10 || rule.RateLimit.Unit != fw.PerMinute || rule.RateLimit.Burst != 5 {
|
||||||
|
t.Errorf("RateLimit: got %+v", rule.RateLimit)
|
||||||
|
}
|
||||||
|
if rule.ConnLimit == nil || rule.ConnLimit.Count != 100 || !rule.ConnLimit.PerSource {
|
||||||
|
t.Errorf("ConnLimit: got %+v", rule.ConnLimit)
|
||||||
|
}
|
||||||
|
if rule.InInterface != "eth0" {
|
||||||
|
t.Errorf("InInterface: got %q", rule.InInterface)
|
||||||
|
}
|
||||||
|
if rule.Comment != "web" {
|
||||||
|
t.Errorf("Comment: got %q", rule.Comment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRuleFlagsToRuleErrors(t *testing.T) {
|
||||||
|
// Each malformed flag should surface as an error rather than a silent
|
||||||
|
// default — the CLI must never author a broader rule than the user asked.
|
||||||
|
bad := []ruleFlags{
|
||||||
|
{Action: "explode"},
|
||||||
|
{Family: "ipv9"},
|
||||||
|
{Proto: "tpc"}, // typo must not silently widen to any
|
||||||
|
{Ports: "80,abc"},
|
||||||
|
{State: "new,bogus"},
|
||||||
|
{RateLimit: "10"},
|
||||||
|
}
|
||||||
|
for _, f := range bad {
|
||||||
|
if _, err := f.toRule(); err == nil {
|
||||||
|
t.Errorf("toRule(%+v): want error, got nil", f)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The same unknown-protocol rejection applies to NAT rules via toNATRule.
|
||||||
|
if _, err := (&natFlags{Kind: "dnat", Family: "any", Proto: "tpc"}).toNATRule(); err == nil {
|
||||||
|
t.Errorf("toNATRule with bad proto: want error, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDescribeRule(t *testing.T) {
|
||||||
|
r := &fw.Rule{
|
||||||
|
Direction: fw.DirInput,
|
||||||
|
Action: fw.Accept,
|
||||||
|
Family: fw.IPv4,
|
||||||
|
Proto: fw.TCP,
|
||||||
|
Source: "192.168.1.0/24",
|
||||||
|
Port: 443,
|
||||||
|
Log: true,
|
||||||
|
LogPrefix: "https",
|
||||||
|
Comment: "web",
|
||||||
|
}
|
||||||
|
got := describeRule(r)
|
||||||
|
// Spot-check a few segments rather than the whole string, so the test
|
||||||
|
// does not break on cosmetic wording changes.
|
||||||
|
for _, want := range []string{"in", "accept", "ipv4", "tcp", "from 192.168.1.0/24", "port 443", "log=https", "# web"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("describeRule: missing %q in %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDescribeNATRule(t *testing.T) {
|
||||||
|
r := &fw.NATRule{
|
||||||
|
Kind: fw.DNAT,
|
||||||
|
Family: fw.IPv4,
|
||||||
|
Proto: fw.TCP,
|
||||||
|
Port: 8080,
|
||||||
|
ToAddress: "10.0.0.5",
|
||||||
|
ToPort: 80,
|
||||||
|
}
|
||||||
|
got := describeNATRule(r)
|
||||||
|
for _, want := range []string{"dnat", "ipv4", "tcp", "port 8080", "-> 10.0.0.5:80"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("describeNATRule: missing %q in %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintSet(t *testing.T) {
|
||||||
|
var buf strings.Builder
|
||||||
|
set := &fw.AddressSet{
|
||||||
|
Name: "blocklist",
|
||||||
|
Family: fw.IPv4,
|
||||||
|
Type: fw.SetHashNet,
|
||||||
|
Entries: []string{"203.0.113.0/24", "198.51.100.7"},
|
||||||
|
}
|
||||||
|
printSet(&buf, set)
|
||||||
|
got := buf.String()
|
||||||
|
for _, want := range []string{"blocklist", "family=ipv4", "type=hash:net", "entries=2", "203.0.113.0/24", "198.51.100.7"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("printSet: missing %q in %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A zero Type defaults to hash:ip, and an empty set is rendered explicitly
|
||||||
|
// rather than as a blank line.
|
||||||
|
buf.Reset()
|
||||||
|
printSet(&buf, &fw.AddressSet{Name: "empty"})
|
||||||
|
got = buf.String()
|
||||||
|
for _, want := range []string{"type=hash:ip", "(no entries)"} {
|
||||||
|
if !strings.Contains(got, want) {
|
||||||
|
t.Errorf("printSet(empty): missing %q in %q", want, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPrintRulesHonorsWriter(t *testing.T) {
|
||||||
|
// printRules/printNATRules/printSets must write to the writer they are
|
||||||
|
// given, not an implicit os.Stdout — a bytes buffer here would stay empty
|
||||||
|
// if the writer were ignored.
|
||||||
|
var buf strings.Builder
|
||||||
|
printRules(&buf, []*fw.Rule{{Action: fw.Accept, Proto: fw.TCP, Port: 22}})
|
||||||
|
if !strings.Contains(buf.String(), "port 22") {
|
||||||
|
t.Errorf("printRules did not write to the provided writer: %q", buf.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResolveZone(t *testing.T) {
|
||||||
|
// resolveZone never touches a real manager when zone is explicit: it
|
||||||
|
// short-circuits before calling GetZone. A nil manager is therefore fine
|
||||||
|
// for the explicit-zone case (and only that case).
|
||||||
|
zone, err := resolveZone(nil, "public", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveZone: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if zone != "public" {
|
||||||
|
t.Errorf("resolveZone: got %q want public", zone)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An explicit --zone takes precedence over --interface (the documented
|
||||||
|
// contract). With a nil manager this passes only because resolveZone returns
|
||||||
|
// the zone before it would resolve the interface; if the precedence were
|
||||||
|
// reversed it would dereference the nil manager and panic.
|
||||||
|
zone, err = resolveZone(nil, "public", "eth0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("resolveZone with both zone and interface: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if zone != "public" {
|
||||||
|
t.Errorf("resolveZone: --zone must win over --interface; got %q want public", zone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A redirect NAT rule needs a port-bearing protocol and a destination port; the
|
||||||
|
// CLI must reject the ambiguous defaults up front rather than deferring to the
|
||||||
|
// backend.
|
||||||
|
func TestNATFlagsRedirectValidation(t *testing.T) {
|
||||||
|
// Redirect with proto any and no --to-port is rejected on the protocol.
|
||||||
|
if _, err := (&natFlags{Kind: "redirect", Family: "any", Proto: "any"}).toNATRule(); err == nil {
|
||||||
|
t.Errorf("redirect with proto any / no to-port: want error, got nil")
|
||||||
|
}
|
||||||
|
// Redirect with a port-bearing proto but still no --to-port is rejected.
|
||||||
|
if _, err := (&natFlags{Kind: "redirect", Family: "any", Proto: "tcp"}).toNATRule(); err == nil {
|
||||||
|
t.Errorf("redirect without to-port: want error, got nil")
|
||||||
|
}
|
||||||
|
// A complete redirect is accepted.
|
||||||
|
r, err := (&natFlags{Kind: "redirect", Family: "any", Proto: "tcp", ToPort: 8080, Port: 80}).toNATRule()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("valid redirect: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
if r.Kind != fw.Redirect || r.ToPort != 8080 {
|
||||||
|
t.Errorf("unexpected redirect rule: %+v", r)
|
||||||
|
}
|
||||||
|
// A dnat still accepts the default proto (validation is redirect-specific).
|
||||||
|
if _, err := (&natFlags{Kind: "dnat", Family: "any", Proto: "any", ToAddr: "10.0.0.1"}).toNATRule(); err != nil {
|
||||||
|
t.Errorf("dnat with default proto: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interface matches are direction-specific: toRule must reject an outbound
|
||||||
|
// interface on an input rule and an inbound interface on an output rule.
|
||||||
|
func TestRuleFlagsInterfaceDirection(t *testing.T) {
|
||||||
|
// --out-interface on an input (default) rule is rejected.
|
||||||
|
if _, err := (&ruleFlags{Action: "accept", Family: "any", Proto: "any", OutInterface: "eth0"}).toRule(); err == nil {
|
||||||
|
t.Errorf("out-interface on input rule: want error, got nil")
|
||||||
|
}
|
||||||
|
// --in-interface on an output rule is rejected.
|
||||||
|
if _, err := (&ruleFlags{Action: "accept", Family: "any", Proto: "any", Output: true, InInterface: "eth0"}).toRule(); err == nil {
|
||||||
|
t.Errorf("in-interface on output rule: want error, got nil")
|
||||||
|
}
|
||||||
|
// The matching combinations are accepted.
|
||||||
|
if _, err := (&ruleFlags{Action: "accept", Family: "any", Proto: "any", InInterface: "eth0"}).toRule(); err != nil {
|
||||||
|
t.Errorf("in-interface on input rule: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
if _, err := (&ruleFlags{Action: "accept", Family: "any", Proto: "any", Output: true, OutInterface: "eth0"}).toRule(); err != nil {
|
||||||
|
t.Errorf("out-interface on output rule: unexpected error %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
34
cmd/go-firewall/go.mod
Normal file
34
cmd/go-firewall/go.mod
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
module github.com/grmrgecko/firewall/cmd/go-firewall
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/alecthomas/kong v1.15.0
|
||||||
|
github.com/grmrgecko/firewall v0.0.0-00010101000000-000000000000
|
||||||
|
github.com/willabides/kongplete v0.4.0
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
|
||||||
|
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect
|
||||||
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
|
github.com/google/cabbie v1.0.2 // indirect
|
||||||
|
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d // indirect
|
||||||
|
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb // indirect
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||||
|
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847 // indirect
|
||||||
|
github.com/posener/complete v1.2.3 // indirect
|
||||||
|
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect
|
||||||
|
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e // indirect
|
||||||
|
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
)
|
||||||
|
|
||||||
|
replace github.com/grmrgecko/firewall => ../..
|
||||||
|
|
||||||
|
// Mirrors the library's own replace: the old coreos/go-systemd import path is
|
||||||
|
// redirected to the maintained v22 module (the v0 release does not compile
|
||||||
|
// against modern godbus). Kept here so the CLI is self-contained when built.
|
||||||
|
replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0
|
||||||
143
cmd/go-firewall/go.sum
Normal file
143
cmd/go-firewall/go.sum
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
bitbucket.org/creachadair/stringset v0.0.9/go.mod h1:t+4WcQ4+PXTa8aQdNKe40ZP6iwesoMFWAxPGd3UGjyY=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/StackExchange/wmi v1.2.0/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||||
|
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||||
|
github.com/alecthomas/kong v1.15.0 h1:BVJstKbpO73zKpmIu+m/aLRrNmWwxXPIGTNin9VmLVI=
|
||||||
|
github.com/alecthomas/kong v1.15.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
|
||||||
|
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||||
|
github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
|
github.com/capnspacehook/taskmaster v0.0.0-20210519235353-1629df7c85e9/go.mod h1:257CYs3Wd/CTlLQ3c72jKv+fFE2MV3WPNnV5jiroYUU=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/creachadair/staticfile v0.1.3/go.mod h1:a3qySzCIXEprDGxk6tSxSI+dBBdLzqeBOMhZ+o2d3pM=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
|
||||||
|
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/google/aukera v0.0.0-20201117230544-d145c8357fea/go.mod h1:oXqTZORBzdwQ6L32YjJmaPajqIV/hoGEouwpFMf4cJE=
|
||||||
|
github.com/google/cabbie v1.0.2 h1:UtB+Nn6fPB43wGg5xs4tgU+P3hTZ6KsulgtaHtqZZfs=
|
||||||
|
github.com/google/cabbie v1.0.2/go.mod h1:6MmHaUrgfabehCHAIaxdrbmvHSxUVXj3Abs08FMABSo=
|
||||||
|
github.com/google/glazier v0.0.0-20210617205946-bf91b619f5d4/go.mod h1:g7oyIhindbeebnBh0hbFua5rv6XUt/nweDwIWdvxirg=
|
||||||
|
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d h1:GBIF4RkD4E9USvSRT4O4tBCT77JExIr+qnruI9nkJQo=
|
||||||
|
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d/go.mod h1:h2R3DLUecGbLSyi6CcxBs5bdgtJhgK+lIffglvAcGKg=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/logger v1.1.0/go.mod h1:w7O8nrRr0xufejBlQMI83MXqRusvREoJdaAxV+CoAB4=
|
||||||
|
github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ=
|
||||||
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/google/winops v0.0.0-20210803215038-c8511b84de2b/go.mod h1:ShbX8v8clPm/3chw9zHVwtW3QhrFpL8mXOwNxClt4pg=
|
||||||
|
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb h1:2wDo4vmBRWk2n3W5EsEpMQ2t8Sx0diVXdZjJTlLCBzc=
|
||||||
|
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb/go.mod h1:PrxtlI/xoBCOT8ugAoxeuE++VGq/D7jxbz5URoeV7ow=
|
||||||
|
github.com/groob/plist v0.0.0-20210519001750-9f754062e6d6/go.mod h1:itkABA+w2cw7x5nYUS/pLRef6ludkZKOigbROmCTaFw=
|
||||||
|
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I=
|
||||||
|
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
|
||||||
|
github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
|
||||||
|
github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||||
|
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/iamacarpet/go-win64api v0.0.0-20210311141720-fe38760bed28/go.mod h1:oGJx9dz0Ny7HC7U55RZ0Smd6N9p3hXP/+hOFtuYrAxM=
|
||||||
|
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847 h1:cRHZFGwIDgQlr9abL/P93JXR7pYxzvf0xAIt0xzwrh0=
|
||||||
|
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847/go.mod h1:B7zFQPAznj+ujXel5X+LUoK3LgY6VboCdVYHZNn7gpg=
|
||||||
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
|
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||||
|
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo=
|
||||||
|
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
|
||||||
|
github.com/rickb777/date v1.14.2/go.mod h1:swmf05C+hN+m8/Xh7gEq3uB6QJDNc5pQBWojKdHetOs=
|
||||||
|
github.com/rickb777/plural v1.2.2/go.mod h1:xyHbelv4YvJE51gjMnHvk+U2e9zIysg6lTnSQK8XUYA=
|
||||||
|
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY=
|
||||||
|
github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e h1:+/AzLkOdIXEPrAQtwAeWOBnPQ0BnYlBW0aCZmSb47u4=
|
||||||
|
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e/go.mod h1:9Tc1SKnfACJb9N7cw2eyuI6xzy845G7uZONBsi5uPEA=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g=
|
||||||
|
github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57XcayPyvqSi2X8=
|
||||||
|
go4.org/intern v0.0.0-20211027215823-ae77deb06f29 h1:UXLjNohABv4S58tHmeuIZDO6e3mHpW2Dx33gaNt03LE=
|
||||||
|
go4.org/intern v0.0.0-20211027215823-ae77deb06f29/go.mod h1:cS2ma+47FKrLPdXFpr7CuxiTW3eyJbWew4qx0qtQWDA=
|
||||||
|
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf h1:IdwJUzqoIo5lkr2EOyKoe5qipUaEjbOKKY5+fzPBZ3A=
|
||||||
|
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf/go.mod h1:+QXzaoURFd0rGDIjDNpyIkv+F9R7EmeKorvlKRnhqgA=
|
||||||
|
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 h1:FyBZqvoA/jbNzuAWLQE2kG820zMAkcilx6BMjGbL/E4=
|
||||||
|
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200622182413-4b0db7f3f76b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211107104306-e0b2ad06fe42/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
138
cmd/go-firewall/info.go
Normal file
138
cmd/go-firewall/info.go
Normal file
|
|
@ -0,0 +1,138 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
fw "github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds the read-only/info subcommands: status (backend + caps),
|
||||||
|
// zone (resolve an interface's zone), and reload (force a backend reload).
|
||||||
|
|
||||||
|
// StatusCmd reports the detected backend and its capabilities. It is the
|
||||||
|
// default thing to run when orienting on an unfamiliar host.
|
||||||
|
type StatusCmd struct{}
|
||||||
|
|
||||||
|
// Run reports the detected backend and its capabilities.
|
||||||
|
func (c *StatusCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
caps := mgr.Capabilities()
|
||||||
|
type statusOut struct {
|
||||||
|
Backend string `json:"backend"`
|
||||||
|
Prefix string `json:"prefix"`
|
||||||
|
Capabilities fw.Capabilities `json:"capabilities"`
|
||||||
|
}
|
||||||
|
out := statusOut{
|
||||||
|
Backend: mgr.Type(),
|
||||||
|
Prefix: g.Prefix,
|
||||||
|
Capabilities: caps,
|
||||||
|
}
|
||||||
|
return g.emit(out, func() error {
|
||||||
|
fmt.Printf("backend: %s\n", out.Backend)
|
||||||
|
fmt.Printf("prefix: %s\n", out.Prefix)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println("capabilities:")
|
||||||
|
printCapabilities(os.Stdout, caps)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// printCapabilities lists the capability booleans in a stable, readable order.
|
||||||
|
// It walks the struct via reflection so a new bool field in fw.Capabilities is
|
||||||
|
// surfaced automatically without a parallel slice here. Non-bool fields are
|
||||||
|
// skipped so a future non-bool capability cannot panic the reflection walk.
|
||||||
|
func printCapabilities(w io.Writer, caps fw.Capabilities) {
|
||||||
|
v := reflect.ValueOf(caps)
|
||||||
|
t := v.Type()
|
||||||
|
type field struct {
|
||||||
|
name string
|
||||||
|
ok bool
|
||||||
|
}
|
||||||
|
fields := make([]field, 0, t.NumField())
|
||||||
|
for i := 0; i < t.NumField(); i++ {
|
||||||
|
if v.Field(i).Kind() != reflect.Bool {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fields = append(fields, field{
|
||||||
|
name: t.Field(i).Name,
|
||||||
|
ok: v.Field(i).Bool(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sort.Slice(fields, func(i, j int) bool { return fields[i].name < fields[j].name })
|
||||||
|
maxLen := 0
|
||||||
|
for _, f := range fields {
|
||||||
|
if len(f.name) > maxLen {
|
||||||
|
maxLen = len(f.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, f := range fields {
|
||||||
|
mark := "-"
|
||||||
|
if f.ok {
|
||||||
|
mark = "yes"
|
||||||
|
}
|
||||||
|
_, _ = fmt.Fprintf(w, " %-*s %s\n", maxLen, f.name, mark)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ZoneCmd resolves the zone an interface belongs to. On backends without zones
|
||||||
|
// (iptables, nftables, pf, WFP) the returned zone is empty and that is the
|
||||||
|
// correct answer — it just means the backend has no zone abstraction.
|
||||||
|
type ZoneCmd struct {
|
||||||
|
Interface string `arg:"" name:"interface" help:"Network interface to resolve (e.g. eth0)."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run resolves and prints the zone the interface belongs to.
|
||||||
|
func (c *ZoneCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := mgr.GetZone(context.Background(), c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("resolving zone for %q: %w", c.Interface, err)
|
||||||
|
}
|
||||||
|
type zoneOut struct {
|
||||||
|
Interface string `json:"interface"`
|
||||||
|
Zone string `json:"zone"`
|
||||||
|
}
|
||||||
|
out := zoneOut{Interface: c.Interface, Zone: zone}
|
||||||
|
return g.emit(out, func() error {
|
||||||
|
// A zoneless backend returns an empty zone; show a human placeholder here
|
||||||
|
// only, so the JSON form keeps the honest empty string.
|
||||||
|
display := out.Zone
|
||||||
|
if display == "" {
|
||||||
|
display = "(none)"
|
||||||
|
}
|
||||||
|
fmt.Printf("%s -> %s\n", out.Interface, display)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReloadCmd forces a backend reload, activating any staged rules. Useful after
|
||||||
|
// a series of --no-reload mutations, or to pick up changes made out of band.
|
||||||
|
type ReloadCmd struct{}
|
||||||
|
|
||||||
|
// Run forces a backend reload, activating any staged rules.
|
||||||
|
func (c *ReloadCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
if err := mgr.Reload(context.Background()); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("reloaded")
|
||||||
|
}
|
||||||
166
cmd/go-firewall/main.go
Normal file
166
cmd/go-firewall/main.go
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
// Command go-firewall is a unified firewall management CLI built on the
|
||||||
|
// github.com/grmrgecko/firewall library. It auto-detects the host's active
|
||||||
|
// firewall backend and exposes a single command set across every backend the
|
||||||
|
// library supports (firewalld, ufw, CSF, APF, iptables, nftables, pf, WFP).
|
||||||
|
//
|
||||||
|
// It doubles as runnable example code for the library: every subcommand maps
|
||||||
|
// one-to-one onto a Manager method.
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"text/tabwriter"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alecthomas/kong"
|
||||||
|
"github.com/willabides/kongplete"
|
||||||
|
|
||||||
|
fw "github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// managerDetectTimeout bounds backend detection so a hung probe (pfctl, D-Bus)
|
||||||
|
// fails fast instead of hanging the CLI process indefinitely.
|
||||||
|
const managerDetectTimeout = 30 * time.Second
|
||||||
|
|
||||||
|
// version is the CLI version, overridden at build time with:
|
||||||
|
//
|
||||||
|
// go build -ldflags "-X main.version=1.2.3"
|
||||||
|
var version = "dev"
|
||||||
|
|
||||||
|
// VersionFlag is a kong flag type that prints the version and exits before any
|
||||||
|
// command runs (mirrors the pattern in kong's own examples).
|
||||||
|
type VersionFlag bool
|
||||||
|
|
||||||
|
// Decode satisfies kong's flag decoder; the value itself carries no data.
|
||||||
|
func (v VersionFlag) Decode(ctx *kong.DecodeContext) error { return nil }
|
||||||
|
|
||||||
|
// IsBool marks the flag as boolean so it takes no argument.
|
||||||
|
func (v VersionFlag) IsBool() bool { return true }
|
||||||
|
|
||||||
|
// BeforeApply prints the version and exits before any command runs.
|
||||||
|
func (v VersionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error {
|
||||||
|
fmt.Println(vars["version"])
|
||||||
|
app.Exit(0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Globals are the top-level flags shared by every subcommand. Kong passes a
|
||||||
|
// pointer to each command's Run method, so every subcommand opens the firewall
|
||||||
|
// manager the same way regardless of which one ran.
|
||||||
|
type Globals struct {
|
||||||
|
// Prefix namespaces the rules this CLI creates, mirroring
|
||||||
|
// firewall.NewManager's rulePrefix argument. Backends that need a name
|
||||||
|
// (WFP rule group, nftables table, pf anchor) fall back to "go_firewall"
|
||||||
|
// when empty.
|
||||||
|
Prefix string `name:"prefix" env:"GOFIREWALL_PREFIX" help:"Rule prefix used to namespace managed rules." default:"go_firewall"`
|
||||||
|
// NoReload disables the automatic Reload after a mutating command on
|
||||||
|
// backends that stage changes (ufw, CSF, APF). Off by default so changes
|
||||||
|
// take effect immediately.
|
||||||
|
NoReload bool `name:"no-reload" env:"GOFIREWALL_NO_RELOAD" help:"Do not reload the backend after a mutation."`
|
||||||
|
// JSON switches list/status output to machine-readable JSON.
|
||||||
|
JSON bool `short:"j" name:"json" env:"GOFIREWALL_JSON" help:"Emit list/status output as JSON."`
|
||||||
|
// Version prints the CLI version and exits.
|
||||||
|
Version VersionFlag `name:"version" help:"Print version information and quit."`
|
||||||
|
|
||||||
|
Rule RuleCmd `cmd:"" help:"Manage filter rules." group:"rules"`
|
||||||
|
NAT NATCmd `cmd:"" help:"Manage NAT (port-forward/masquerade) rules." group:"rules"`
|
||||||
|
Policy PolicyCmd `cmd:"" help:"Manage the default policy." group:"rules"`
|
||||||
|
Set SetCmd `cmd:"" help:"Manage address sets (ipset/nftset/pf tables)." group:"management"`
|
||||||
|
Backup BackupSubcmd `cmd:"" help:"Snapshot managed rules to a file (or stdout)." group:"management"`
|
||||||
|
Restore RestoreSubcmd `cmd:"" help:"Replay a backup file (or stdin)." group:"management"`
|
||||||
|
Status StatusCmd `cmd:"" help:"Show the detected firewall backend and its capabilities." group:"info"`
|
||||||
|
Zone ZoneCmd `cmd:"" help:"Resolve the zone for an interface." group:"info"`
|
||||||
|
Reload ReloadCmd `cmd:"" help:"Reload the firewall backend (activate staged rules)." group:"info"`
|
||||||
|
|
||||||
|
// InstallCompletions registers shell completion for bash/zsh/fish. Running
|
||||||
|
// it emits (or installs) the completion script; the actual completion
|
||||||
|
// requests are served by kongplete.Complete in main before Parse.
|
||||||
|
InstallCompletions kongplete.InstallCompletions `cmd:"" help:"Install shell completion for go-firewall." group:"info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
var cli Globals
|
||||||
|
parser := kong.Must(&cli,
|
||||||
|
kong.Name("go-firewall"),
|
||||||
|
kong.Description("A unified firewall management CLI across firewalld, ufw, CSF, APF, iptables, nftables, pf and WFP."),
|
||||||
|
kong.UsageOnError(),
|
||||||
|
kong.ConfigureHelp(kong.HelpOptions{
|
||||||
|
Compact: true,
|
||||||
|
Tree: true,
|
||||||
|
}),
|
||||||
|
kong.Vars{"version": version},
|
||||||
|
)
|
||||||
|
// Serve shell-completion requests (COMP_LINE-driven) before parsing, so a
|
||||||
|
// completion invocation returns candidates and exits without running a
|
||||||
|
// command. It is a no-op for a normal invocation.
|
||||||
|
kongplete.Complete(parser)
|
||||||
|
|
||||||
|
ctx, err := parser.Parse(os.Args[1:])
|
||||||
|
parser.FatalIfErrorf(err)
|
||||||
|
err = ctx.Run(&cli)
|
||||||
|
ctx.FatalIfErrorf(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// manager opens the detected firewall manager, scoped to g.Prefix. The returned
|
||||||
|
// closer must be invoked by the caller. It is the single entry point every
|
||||||
|
// subcommand uses, so detection and teardown are defined once.
|
||||||
|
func (g *Globals) manager() (fw.Manager, func(), error) {
|
||||||
|
// Bound only detection: the returned closer and each command's operations run
|
||||||
|
// on their own contexts, so this deadline cannot cancel in-flight work.
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), managerDetectTimeout)
|
||||||
|
defer cancel()
|
||||||
|
mgr, err := fw.NewManager(ctx, g.Prefix)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("detecting firewall backend: %w", err)
|
||||||
|
}
|
||||||
|
cleanup := func() { _ = mgr.Close(context.Background()) }
|
||||||
|
return mgr, cleanup, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reload conditionally activates staged rules. NoReload skips it (useful when a
|
||||||
|
// caller batches several mutations and reloads once at the end).
|
||||||
|
func (g *Globals) reload(mgr fw.Manager) error {
|
||||||
|
if g.NoReload {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return mgr.Reload(context.Background())
|
||||||
|
}
|
||||||
|
|
||||||
|
// emit prints either JSON (when g.JSON is set) or the human-readable rendering
|
||||||
|
// produced by human. It is the single output path for list/status commands so
|
||||||
|
// every subcommand honors --json the same way.
|
||||||
|
func (g *Globals) emit(v any, human func() error) error {
|
||||||
|
if g.JSON {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(v)
|
||||||
|
}
|
||||||
|
return human()
|
||||||
|
}
|
||||||
|
|
||||||
|
// emitStatus reports the outcome of a mutating command. In text mode it prints
|
||||||
|
// the bare word (e.g. "added"); under --json it emits a small status object so
|
||||||
|
// a scripted caller passing -j still gets parseable output instead of empty
|
||||||
|
// stdout. It is the single success path for every mutation so they stay in sync.
|
||||||
|
func (g *Globals) emitStatus(status string) error {
|
||||||
|
if g.JSON {
|
||||||
|
enc := json.NewEncoder(os.Stdout)
|
||||||
|
enc.SetIndent("", " ")
|
||||||
|
return enc.Encode(struct {
|
||||||
|
Status string `json:"status"`
|
||||||
|
}{status})
|
||||||
|
}
|
||||||
|
fmt.Println(status)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTable returns a tabwriter that flushes to w, configured for the
|
||||||
|
// human-readable tables used by the list commands. All columns are left-aligned
|
||||||
|
// for readability.
|
||||||
|
func newTable(w io.Writer) *tabwriter.Writer {
|
||||||
|
return tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||||
|
}
|
||||||
216
cmd/go-firewall/nat.go
Normal file
216
cmd/go-firewall/nat.go
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
fw "github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds the NAT command group: list, add, remove. NAT rules are
|
||||||
|
// managed separately from filter rules in the library, so they get their own
|
||||||
|
// subcommand group here too.
|
||||||
|
|
||||||
|
// NATCmd is the top-level "nat" command group.
|
||||||
|
type NATCmd struct {
|
||||||
|
List NATListCmd `cmd:"" help:"List NAT rules (default)."`
|
||||||
|
Add NATAddCmd `cmd:"" help:"Add a NAT rule (port-forward/masquerade)."`
|
||||||
|
Insert NATInsertCmd `cmd:"" help:"Insert a NAT rule at a 1-based position within its nat chain."`
|
||||||
|
Remove NATRemoveCmd `cmd:"" help:"Remove a NAT rule matching the given fields."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NATListCmd lists every NAT rule in a zone. The PREFIX column reports which rules
|
||||||
|
// carry the configured prefix (HasPrefix); filtering is left to the caller rather
|
||||||
|
// than hidden here.
|
||||||
|
type NATListCmd struct {
|
||||||
|
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
|
||||||
|
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run lists the NAT rules in the resolved zone.
|
||||||
|
func (c *NATListCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rules, err := mgr.GetNATRules(context.Background(), zone)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing NAT rules: %w", err)
|
||||||
|
}
|
||||||
|
return g.emit(rules, func() error {
|
||||||
|
if len(rules) == 0 {
|
||||||
|
fmt.Println("(no NAT rules)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
printNATRules(os.Stdout, rules)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// natFlags carries every fw.NATRule field that add/remove accept.
|
||||||
|
type natFlags struct {
|
||||||
|
Kind string `name:"kind" short:"k" help:"dnat|redirect|snat|masquerade." default:"dnat"`
|
||||||
|
Family string `name:"family" short:"f" help:"any|ipv4|ipv6 (default any)." default:"any"`
|
||||||
|
Proto string `name:"proto" help:"any|tcp|udp|icmp|icmpv6|sctp|gre|esp|ah (default any)." default:"any"`
|
||||||
|
If string `name:"nat-interface" help:"Inbound interface for dnat/redirect; outbound for snat/masquerade."`
|
||||||
|
Source string `name:"source" short:"s" help:"Source address/CIDR."`
|
||||||
|
Dest string `name:"destination" short:"d" help:"Destination address/CIDR."`
|
||||||
|
Port uint16 `name:"port" short:"p" help:"Matched destination port. Requires tcp/udp."`
|
||||||
|
Ports string `name:"ports" help:"Matched port list/ranges. Overrides --port."`
|
||||||
|
ToAddr string `name:"to-address" help:"Translation address (dnat/snat). Empty for redirect/masquerade."`
|
||||||
|
ToPort uint16 `name:"to-port" help:"Translation port (dnat/redirect). Unused for snat/masquerade."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// toNATRule assembles natFlags into a *fw.NATRule using the library's Parse*
|
||||||
|
// helpers. Validation (e.g. dnat requires an address) is delegated to the
|
||||||
|
// library's NATRule.validate, called by each backend before marshaling.
|
||||||
|
func (f *natFlags) toNATRule() (*fw.NATRule, error) {
|
||||||
|
kind, err := fw.ParseNATKind(f.Kind)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
family, err := fw.ParseFamily(f.Family)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
proto, err := parseProto(f.Proto)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r := &fw.NATRule{
|
||||||
|
Kind: kind,
|
||||||
|
Family: family,
|
||||||
|
Proto: proto,
|
||||||
|
Interface: f.If,
|
||||||
|
Source: f.Source,
|
||||||
|
Destination: f.Dest,
|
||||||
|
Port: f.Port,
|
||||||
|
ToAddress: f.ToAddr,
|
||||||
|
ToPort: f.ToPort,
|
||||||
|
}
|
||||||
|
if f.Ports != "" {
|
||||||
|
ranges, err := fw.ParsePortRanges(f.Ports, ",")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
r.Ports = ranges
|
||||||
|
}
|
||||||
|
// A redirect sends matched traffic to a local port, so it needs a port-bearing
|
||||||
|
// protocol and a concrete destination port. Reject the ambiguous defaults here
|
||||||
|
// rather than deferring to the backend.
|
||||||
|
if kind == fw.Redirect {
|
||||||
|
if !proto.HasPorts() {
|
||||||
|
return nil, fmt.Errorf("redirect requires a port-bearing protocol (tcp, udp or sctp)")
|
||||||
|
}
|
||||||
|
if r.ToPort == 0 {
|
||||||
|
return nil, fmt.Errorf("redirect requires --to-port")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NATAddCmd adds a NAT rule.
|
||||||
|
type NATAddCmd struct {
|
||||||
|
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
|
||||||
|
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
|
||||||
|
natFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run validates the flags and adds the NAT rule.
|
||||||
|
func (c *NATAddCmd) Run(g *Globals) error {
|
||||||
|
rule, err := c.toNATRule()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mgr.AddNATRule(context.Background(), zone, rule); err != nil {
|
||||||
|
return fmt.Errorf("add NAT rule: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("added")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NATInsertCmd inserts a NAT rule at a 1-based position within its nat chain
|
||||||
|
// (1 = first; a position past the end appends). Only ordered backends support
|
||||||
|
// this; others return an unsupported-ordering error.
|
||||||
|
type NATInsertCmd struct {
|
||||||
|
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
|
||||||
|
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
|
||||||
|
Position int `arg:"" name:"position" help:"1-based position to insert at (1 = first; past the end appends)."`
|
||||||
|
natFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run inserts the NAT rule at the requested 1-based position.
|
||||||
|
func (c *NATInsertCmd) Run(g *Globals) error {
|
||||||
|
rule, err := c.toNATRule()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mgr.InsertNATRule(context.Background(), zone, c.Position, rule); err != nil {
|
||||||
|
return fmt.Errorf("insert NAT rule: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("inserted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// NATRemoveCmd removes a NAT rule matched by identity.
|
||||||
|
type NATRemoveCmd struct {
|
||||||
|
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
|
||||||
|
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
|
||||||
|
natFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run removes the NAT rule matching the given flags.
|
||||||
|
func (c *NATRemoveCmd) Run(g *Globals) error {
|
||||||
|
rule, err := c.toNATRule()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mgr.RemoveNATRule(context.Background(), zone, rule); err != nil {
|
||||||
|
return fmt.Errorf("remove NAT rule: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("removed")
|
||||||
|
}
|
||||||
239
cmd/go-firewall/output.go
Normal file
239
cmd/go-firewall/output.go
Normal file
|
|
@ -0,0 +1,239 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
fw "github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds the human-readable renderers for the library's data types.
|
||||||
|
// They have no side effects, which keeps them easy to unit-test and reuse
|
||||||
|
// across the list subcommands. The --json path serializes the structs directly
|
||||||
|
// (they already carry stable JSON via backup.go), so only the text path needs
|
||||||
|
// bespoke formatting.
|
||||||
|
|
||||||
|
// describeRule renders a *fw.Rule as a compact, single-line summary suitable
|
||||||
|
// for a table cell. The order is deliberately familiar: direction, action,
|
||||||
|
// proto, addresses, ports, then the optional modifiers (state, interface,
|
||||||
|
// limits, counters, comment).
|
||||||
|
func describeRule(r *fw.Rule) string {
|
||||||
|
var b strings.Builder
|
||||||
|
|
||||||
|
// Direction.
|
||||||
|
switch r.Direction {
|
||||||
|
case fw.DirOutput:
|
||||||
|
b.WriteString("out")
|
||||||
|
case fw.DirForward:
|
||||||
|
b.WriteString("fwd")
|
||||||
|
case fw.DirAny:
|
||||||
|
b.WriteString("any")
|
||||||
|
default:
|
||||||
|
b.WriteString("in")
|
||||||
|
}
|
||||||
|
// Action.
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(r.Action.String())
|
||||||
|
// Family (skip "any" to reduce noise; a concrete family still prints).
|
||||||
|
if r.Family != fw.FamilyAny {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(r.Family.String())
|
||||||
|
}
|
||||||
|
// Protocol.
|
||||||
|
b.WriteByte(' ')
|
||||||
|
if r.Proto == fw.ProtocolAny {
|
||||||
|
b.WriteString("any")
|
||||||
|
} else {
|
||||||
|
b.WriteString(r.Proto.String())
|
||||||
|
}
|
||||||
|
// Source/destination.
|
||||||
|
if r.Source != "" {
|
||||||
|
b.WriteString(" from ")
|
||||||
|
b.WriteString(r.Source)
|
||||||
|
}
|
||||||
|
if r.Destination != "" {
|
||||||
|
b.WriteString(" to ")
|
||||||
|
b.WriteString(r.Destination)
|
||||||
|
}
|
||||||
|
// Ports.
|
||||||
|
if ports := r.PortSpecs(); len(ports) > 0 {
|
||||||
|
b.WriteString(" port ")
|
||||||
|
b.WriteString(fw.FormatPortRanges(ports, ","))
|
||||||
|
}
|
||||||
|
if sports := r.SourcePortSpecs(); len(sports) > 0 {
|
||||||
|
b.WriteString(" sport ")
|
||||||
|
b.WriteString(fw.FormatPortRanges(sports, ","))
|
||||||
|
}
|
||||||
|
// ICMP type (only meaningful for ICMP/ICMPv6).
|
||||||
|
if r.ICMPType != nil && r.Proto.IsICMP() {
|
||||||
|
b.WriteString(" type ")
|
||||||
|
b.WriteString(strconv.FormatUint(uint64(*r.ICMPType), 10))
|
||||||
|
}
|
||||||
|
// Connection-tracking state.
|
||||||
|
if r.State != 0 {
|
||||||
|
b.WriteString(" state=")
|
||||||
|
b.WriteString(r.State.String())
|
||||||
|
}
|
||||||
|
// Interface(s): an input rule carries only an in-interface, an output rule only
|
||||||
|
// an out-interface, a forward rule may carry both — print whichever are set.
|
||||||
|
if r.InInterface != "" {
|
||||||
|
b.WriteString(" iif=")
|
||||||
|
b.WriteString(r.InInterface)
|
||||||
|
}
|
||||||
|
if r.OutInterface != "" {
|
||||||
|
b.WriteString(" oif=")
|
||||||
|
b.WriteString(r.OutInterface)
|
||||||
|
}
|
||||||
|
// Logging.
|
||||||
|
if r.Log {
|
||||||
|
b.WriteString(" log")
|
||||||
|
if r.LogPrefix != "" {
|
||||||
|
b.WriteByte('=')
|
||||||
|
b.WriteString(r.LogPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Rate / connection limits.
|
||||||
|
if r.RateLimit != nil {
|
||||||
|
b.WriteString(" rate=")
|
||||||
|
b.WriteString(r.RateLimit.String())
|
||||||
|
if r.RateLimit.Burst != 0 {
|
||||||
|
b.WriteString("/burst=")
|
||||||
|
b.WriteString(strconv.FormatUint(uint64(r.RateLimit.Burst), 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r.ConnLimit != nil {
|
||||||
|
b.WriteString(" conn=")
|
||||||
|
b.WriteString(strconv.FormatUint(uint64(r.ConnLimit.Count), 10))
|
||||||
|
if r.ConnLimit.PerSource {
|
||||||
|
b.WriteString("/src")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Priority (only when nonzero and thus meaningful).
|
||||||
|
if r.Priority != 0 {
|
||||||
|
b.WriteString(" prio=")
|
||||||
|
b.WriteString(strconv.Itoa(r.Priority))
|
||||||
|
}
|
||||||
|
// Comment (informational).
|
||||||
|
if r.Comment != "" {
|
||||||
|
b.WriteString(" # ")
|
||||||
|
b.WriteString(r.Comment)
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// describeNATRule renders a NAT rule as a compact single-line summary.
|
||||||
|
func describeNATRule(r *fw.NATRule) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.WriteString(r.Kind.String())
|
||||||
|
if r.Family != fw.FamilyAny {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
b.WriteString(r.Family.String())
|
||||||
|
}
|
||||||
|
b.WriteByte(' ')
|
||||||
|
if r.Proto == fw.ProtocolAny {
|
||||||
|
b.WriteString("any")
|
||||||
|
} else {
|
||||||
|
b.WriteString(r.Proto.String())
|
||||||
|
}
|
||||||
|
if r.Interface != "" {
|
||||||
|
b.WriteString(" if=")
|
||||||
|
b.WriteString(r.Interface)
|
||||||
|
}
|
||||||
|
if r.Source != "" {
|
||||||
|
b.WriteString(" from ")
|
||||||
|
b.WriteString(r.Source)
|
||||||
|
}
|
||||||
|
if r.Destination != "" {
|
||||||
|
b.WriteString(" to ")
|
||||||
|
b.WriteString(r.Destination)
|
||||||
|
}
|
||||||
|
if ports := r.PortSpecs(); len(ports) > 0 {
|
||||||
|
b.WriteString(" port ")
|
||||||
|
b.WriteString(fw.FormatPortRanges(ports, ","))
|
||||||
|
}
|
||||||
|
// Translation target.
|
||||||
|
switch r.Kind {
|
||||||
|
case fw.DNAT:
|
||||||
|
b.WriteString(" -> ")
|
||||||
|
b.WriteString(r.ToAddress)
|
||||||
|
if r.ToPort != 0 {
|
||||||
|
b.WriteByte(':')
|
||||||
|
b.WriteString(strconv.FormatUint(uint64(r.ToPort), 10))
|
||||||
|
}
|
||||||
|
case fw.Redirect:
|
||||||
|
b.WriteString(" -> ")
|
||||||
|
b.WriteString(strconv.FormatUint(uint64(r.ToPort), 10))
|
||||||
|
case fw.SNAT:
|
||||||
|
b.WriteString(" -> ")
|
||||||
|
b.WriteString(r.ToAddress)
|
||||||
|
case fw.Masquerade:
|
||||||
|
b.WriteString(" (dynamic)")
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// printRules writes a rule list to w as a numbered table. The index is the
|
||||||
|
// 1-based position returned by GetRules, which is what RemoveRule/MoveRule
|
||||||
|
// refer to (since those methods match by rule identity, not index, the index is
|
||||||
|
// informational — it just helps the user pick the right line).
|
||||||
|
func printRules(w io.Writer, rules []*fw.Rule) {
|
||||||
|
tw := newTable(w)
|
||||||
|
defer func() { _ = tw.Flush() }()
|
||||||
|
_, _ = fmt.Fprintln(tw, "#\tPREFIX\tACTION\tDESCRIPTION")
|
||||||
|
for i, r := range rules {
|
||||||
|
_, _ = fmt.Fprintf(tw, "%d\t%s\t%s\t%s\n", i+1, hasPrefixFlag(r.HasPrefix), r.Action.String(), describeRule(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printNATRules writes a NAT rule list to w as a numbered table.
|
||||||
|
func printNATRules(w io.Writer, rules []*fw.NATRule) {
|
||||||
|
tw := newTable(w)
|
||||||
|
defer func() { _ = tw.Flush() }()
|
||||||
|
_, _ = fmt.Fprintln(tw, "#\tPREFIX\tKIND\tDESCRIPTION")
|
||||||
|
for i, r := range rules {
|
||||||
|
_, _ = fmt.Fprintf(tw, "%d\t%s\t%s\t%s\n", i+1, hasPrefixFlag(r.HasPrefix), r.Kind.String(), describeNATRule(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPrefixFlag renders whether a rule carries the configured prefix, for the
|
||||||
|
// PREFIX column of a rule listing.
|
||||||
|
func hasPrefixFlag(hasPrefix bool) string {
|
||||||
|
if hasPrefix {
|
||||||
|
return "yes"
|
||||||
|
}
|
||||||
|
return "no"
|
||||||
|
}
|
||||||
|
|
||||||
|
// setType renders an address set's type, defaulting the zero value to hash:ip
|
||||||
|
// the way the library does when a set is created without an explicit type.
|
||||||
|
func setType(s *fw.AddressSet) string {
|
||||||
|
if s.Type == 0 {
|
||||||
|
return fw.SetHashIP.String()
|
||||||
|
}
|
||||||
|
return s.Type.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// printSet writes one address set's header line followed by its entries, one per
|
||||||
|
// line. An empty set prints "(no entries)" so the output is never ambiguous.
|
||||||
|
func printSet(w io.Writer, s *fw.AddressSet) {
|
||||||
|
_, _ = fmt.Fprintf(w, "%s family=%s type=%s entries=%d\n", s.Name, s.Family.String(), setType(s), len(s.Entries))
|
||||||
|
if len(s.Entries) == 0 {
|
||||||
|
_, _ = fmt.Fprintln(w, " (no entries)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, e := range s.Entries {
|
||||||
|
_, _ = fmt.Fprintf(w, " %s\n", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// printSets writes an address-set list to w as a table.
|
||||||
|
func printSets(w io.Writer, sets []*fw.AddressSet) {
|
||||||
|
tw := newTable(w)
|
||||||
|
defer func() { _ = tw.Flush() }()
|
||||||
|
_, _ = fmt.Fprintln(tw, "NAME\tFAMILY\tTYPE\tENTRIES")
|
||||||
|
for _, s := range sets {
|
||||||
|
_, _ = fmt.Fprintf(tw, "%s\t%s\t%s\t%d\n", s.Name, s.Family.String(), setType(s), len(s.Entries))
|
||||||
|
}
|
||||||
|
}
|
||||||
48
cmd/go-firewall/parse.go
Normal file
48
cmd/go-firewall/parse.go
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
fw "github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds a small parser the firewall library keeps private
|
||||||
|
// (parseRateToken) but that the CLI needs to turn flag strings into library
|
||||||
|
// types. It intentionally mirrors the library's own logic so a CLI-authored rule
|
||||||
|
// is accepted identically to one the library writes. ICMP-type resolution is
|
||||||
|
// delegated to the library's exported fw.ParseICMPType so the CLI cannot drift
|
||||||
|
// from the backends' own name tables.
|
||||||
|
|
||||||
|
// parseRateToken parses a "<rate>/<unit>" token (e.g. "10/minute") into its
|
||||||
|
// numeric rate and unit. It is the flag-facing counterpart of RateLimit.String.
|
||||||
|
func parseRateToken(tok string) (uint, fw.RateUnit, error) {
|
||||||
|
num, unitStr, ok := strings.Cut(strings.TrimSpace(tok), "/")
|
||||||
|
if !ok {
|
||||||
|
return 0, 0, fmt.Errorf("invalid rate %q (want <rate>/<unit>, e.g. 10/minute)", tok)
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseUint(strings.TrimSpace(num), 10, 32)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, fmt.Errorf("invalid rate %q: %w", tok, err)
|
||||||
|
}
|
||||||
|
unit, err := fw.ParseRateUnit(unitStr)
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, err
|
||||||
|
}
|
||||||
|
return uint(n), unit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseProto resolves a --proto flag to a library Protocol, erroring on an
|
||||||
|
// unrecognized token instead of silently widening to "any". The library's
|
||||||
|
// GetProtocol returns ProtocolAny for both an empty value (the legitimate
|
||||||
|
// "match any protocol" default) and an unknown one, so a typo like "tpc" would
|
||||||
|
// otherwise pass through as an any-protocol rule — broader than the user asked
|
||||||
|
// for. An empty value, or the explicit spelling "any", is accepted as ProtocolAny.
|
||||||
|
func parseProto(tok string) (fw.Protocol, error) {
|
||||||
|
p := fw.GetProtocol(tok)
|
||||||
|
if p == fw.ProtocolAny && strings.TrimSpace(tok) != "" && !strings.EqualFold(strings.TrimSpace(tok), "any") {
|
||||||
|
return 0, fmt.Errorf("unknown protocol %q", tok)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
117
cmd/go-firewall/policy.go
Normal file
117
cmd/go-firewall/policy.go
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
fw "github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds the default-policy command group: get and set. A default
|
||||||
|
// policy is the action applied to packets that match no rule, per direction
|
||||||
|
// (input/output/forward). Not every backend exposes every direction, and some
|
||||||
|
// expose none at all (they return ErrUnsupportedPolicy).
|
||||||
|
|
||||||
|
// PolicyCmd is the top-level "policy" command group.
|
||||||
|
type PolicyCmd struct {
|
||||||
|
Get PolicyGetCmd `cmd:"" help:"Show the default policy (default)."`
|
||||||
|
Set PolicySetCmd `cmd:"" help:"Set the default policy for one or more directions."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicyGetCmd prints the default policy. Directions the backend cannot express
|
||||||
|
// are shown as "-" (the library returns ActionInvalid for those).
|
||||||
|
type PolicyGetCmd struct {
|
||||||
|
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
|
||||||
|
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run prints the default policy for each direction.
|
||||||
|
func (c *PolicyGetCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pol, err := mgr.GetDefaultPolicy(context.Background(), zone)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("get default policy: %w", err)
|
||||||
|
}
|
||||||
|
return g.emit(pol, func() error {
|
||||||
|
printPolicy("input", pol.Input)
|
||||||
|
printPolicy("output", pol.Output)
|
||||||
|
printPolicy("forward", pol.Forward)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// printPolicy renders one direction's action, collapsing ActionInvalid to "-"
|
||||||
|
// so a get on a backend that doesn't expose a direction reads cleanly.
|
||||||
|
func printPolicy(dir string, a fw.Action) {
|
||||||
|
s := a.String()
|
||||||
|
if a == fw.ActionInvalid {
|
||||||
|
s = "-"
|
||||||
|
}
|
||||||
|
fmt.Printf("%-8s %s\n", dir, s)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PolicySetCmd sets the default policy. Each direction is optional: an unset
|
||||||
|
// flag leaves that direction unchanged (the library treats ActionInvalid that
|
||||||
|
// way on Set), so `policy set --input drop` changes only the input direction.
|
||||||
|
type PolicySetCmd struct {
|
||||||
|
Zone string `name:"zone" short:"z" help:"Zone name or empty for the default."`
|
||||||
|
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface."`
|
||||||
|
Input string `name:"input" help:"accept|reject|drop for the input direction. Unset leaves it unchanged."`
|
||||||
|
Output string `name:"output" help:"accept|reject|drop for the output direction. Unset leaves it unchanged."`
|
||||||
|
Forward string `name:"forward" help:"accept|reject|drop for the forward direction. Unset leaves it unchanged."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run sets the default policy for the directions whose flags were given.
|
||||||
|
func (c *PolicySetCmd) Run(g *Globals) error {
|
||||||
|
// Parse each direction up front; an empty flag stays ActionInvalid, which
|
||||||
|
// the library interprets as "leave unchanged" on Set. Doing this before
|
||||||
|
// opening the manager means a bad action token fails fast.
|
||||||
|
pol := &fw.DefaultPolicy{}
|
||||||
|
if c.Input != "" {
|
||||||
|
a, err := fw.ParseAction(c.Input)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pol.Input = a
|
||||||
|
}
|
||||||
|
if c.Output != "" {
|
||||||
|
a, err := fw.ParseAction(c.Output)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pol.Output = a
|
||||||
|
}
|
||||||
|
if c.Forward != "" {
|
||||||
|
a, err := fw.ParseAction(c.Forward)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
pol.Forward = a
|
||||||
|
}
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mgr.SetDefaultPolicy(context.Background(), zone, pol); err != nil {
|
||||||
|
return fmt.Errorf("set default policy: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("ok")
|
||||||
|
}
|
||||||
350
cmd/go-firewall/rule.go
Normal file
350
cmd/go-firewall/rule.go
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
fw "github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds the rule command group: list, add, remove, insert, move.
|
||||||
|
// ruleFlags is a shared struct embedded by add/remove/insert/move so that the
|
||||||
|
// Rule field set is described once and stays in sync with the library's Rule.
|
||||||
|
|
||||||
|
// RuleCmd is the top-level "rule" command group. It only has subcommands of its
|
||||||
|
// own; running it bare prints help.
|
||||||
|
type RuleCmd struct {
|
||||||
|
List RuleListCmd `cmd:"" help:"List filter rules (default)."`
|
||||||
|
Add RuleAddCmd `cmd:"" help:"Add a filter rule."`
|
||||||
|
Remove RuleRemoveCmd `cmd:"" help:"Remove a filter rule matching the given fields."`
|
||||||
|
Insert RuleInsertCmd `cmd:"" help:"Insert a filter rule at a 1-based position."`
|
||||||
|
Move RuleMoveCmd `cmd:"" help:"Move an existing filter rule to a 1-based position."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleListCmd lists every filter rule in a zone. The PREFIX column reports which
|
||||||
|
// rules carry the configured prefix (HasPrefix); filtering is left to the caller
|
||||||
|
// rather than hidden here.
|
||||||
|
type RuleListCmd struct {
|
||||||
|
zoneMixin
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run lists the filter rules in the resolved zone.
|
||||||
|
func (c *RuleListCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rules, err := mgr.GetRules(context.Background(), zone)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing rules: %w", err)
|
||||||
|
}
|
||||||
|
return g.emit(rules, func() error {
|
||||||
|
if len(rules) == 0 {
|
||||||
|
fmt.Println("(no rules)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
printRules(os.Stdout, rules)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleFlags carries every fw.Rule field that add/remove/insert/move accept. It
|
||||||
|
// is embedded by each so the flag surface is identical across them (the match
|
||||||
|
// key for remove equals the spec for add). Field-for-field mapping of the
|
||||||
|
// library's Rule struct, minus the read-only counters.
|
||||||
|
type ruleFlags struct {
|
||||||
|
// Direction.
|
||||||
|
Output bool `name:"out" help:"Outbound (output) rule. Default is inbound (input)."`
|
||||||
|
Forward bool `name:"forward" help:"Forward (routing) rule, for traffic routed through the host. Mutually exclusive with --out."`
|
||||||
|
Both bool `name:"both" help:"Both directions (input and output). The address/port roles swap on the outbound half. Mutually exclusive with --out/--forward."`
|
||||||
|
|
||||||
|
// Match: addresses, protocol, ports.
|
||||||
|
Action string `name:"action" short:"a" help:"accept|reject|drop (default accept)." default:"accept"`
|
||||||
|
Family string `name:"family" short:"f" help:"any|ipv4|ipv6 (default any)." default:"any"`
|
||||||
|
Proto string `name:"proto" help:"any|tcp|udp|icmp|icmpv6|sctp|gre|esp|ah (default any)." default:"any"`
|
||||||
|
Source string `name:"source" short:"s" help:"Source address/CIDR. Prefix '!' to negate where supported."`
|
||||||
|
Destination string `name:"destination" short:"d" help:"Destination address/CIDR. Prefix '!' to negate where supported."`
|
||||||
|
Port uint16 `name:"port" short:"p" help:"Destination port (single). Requires tcp/udp/sctp."`
|
||||||
|
Ports string `name:"ports" help:"Destination port list/ranges, e.g. \"80,443,1000-2000\". Overrides --port."`
|
||||||
|
SourcePort uint16 `name:"source-port" help:"Source port (single). Requires tcp/udp/sctp."`
|
||||||
|
SourcePorts string `name:"source-ports" help:"Source port list/ranges. Overrides --source-port."`
|
||||||
|
ICMPType string `name:"icmp-type" help:"ICMP type (number or name like echo-request). Requires icmp/icmpv6."`
|
||||||
|
|
||||||
|
// Modifiers.
|
||||||
|
State string `name:"state" help:"Connection states to match, comma-joined: new,established,related,invalid."`
|
||||||
|
InInterface string `name:"in-interface" help:"Inbound interface to match (input rules)."`
|
||||||
|
OutInterface string `name:"out-interface" help:"Outbound interface to match (output rules)."`
|
||||||
|
Log bool `name:"log" help:"Log matched packets before the action."`
|
||||||
|
LogPrefix string `name:"log-prefix" help:"Label attached to log lines (where supported)."`
|
||||||
|
RateLimit string `name:"rate-limit" help:"Rate cap as <rate>/<unit> (e.g. 10/minute). Implies a logged drop on overflow."`
|
||||||
|
RateBurst uint `name:"rate-burst" help:"Burst allowance for --rate-limit."`
|
||||||
|
ConnLimit uint `name:"conn-limit" help:"Cap concurrent connections."`
|
||||||
|
ConnPerSrc bool `name:"conn-per-source" help:"Apply --conn-limit per source address."`
|
||||||
|
Priority int `name:"priority" help:"Rule priority (where the backend supports it, e.g. firewalld rich rules)."`
|
||||||
|
Comment string `name:"comment" short:"c" help:"Human-readable label carried where supported (see 'status')."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// btoi returns 1 for true and 0 for false, so exclusive boolean flags can be
|
||||||
|
// counted.
|
||||||
|
func btoi(b bool) int {
|
||||||
|
if b {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// toRule assembles the ruleFlags into a *fw.Rule, parsing and validating the
|
||||||
|
// string-typed flags via the library's own Parse* helpers so the CLI never
|
||||||
|
// reinvents rule semantics. Returns an error for a malformed flag rather than a
|
||||||
|
// silent default.
|
||||||
|
func (f *ruleFlags) toRule() (*fw.Rule, error) {
|
||||||
|
action, err := fw.ParseAction(f.Action)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
family, err := fw.ParseFamily(f.Family)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
proto, err := parseProto(f.Proto)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if btoi(f.Output)+btoi(f.Forward)+btoi(f.Both) > 1 {
|
||||||
|
return nil, fmt.Errorf("--out, --forward and --both are mutually exclusive")
|
||||||
|
}
|
||||||
|
direction := fw.DirInput
|
||||||
|
switch {
|
||||||
|
case f.Forward:
|
||||||
|
direction = fw.DirForward
|
||||||
|
case f.Both:
|
||||||
|
direction = fw.DirAny
|
||||||
|
case f.Output:
|
||||||
|
direction = fw.DirOutput
|
||||||
|
}
|
||||||
|
// Interface matches are direction-specific: an inbound interface is invalid on
|
||||||
|
// an output rule and an outbound interface on an input rule; a forward or
|
||||||
|
// both-directions rule may match either (a DirAny rule's interfaces swap sides
|
||||||
|
// on its outbound half).
|
||||||
|
if direction == fw.DirOutput && f.InInterface != "" {
|
||||||
|
return nil, fmt.Errorf("--in-interface is only valid on an inbound, forward or both-directions rule (omit --out)")
|
||||||
|
}
|
||||||
|
if direction == fw.DirInput && f.OutInterface != "" {
|
||||||
|
return nil, fmt.Errorf("--out-interface is only valid on an outbound, forward or both-directions rule (--out, --forward or --both)")
|
||||||
|
}
|
||||||
|
rule := &fw.Rule{
|
||||||
|
Direction: direction,
|
||||||
|
Priority: f.Priority,
|
||||||
|
Action: action,
|
||||||
|
Family: family,
|
||||||
|
Source: f.Source,
|
||||||
|
Destination: f.Destination,
|
||||||
|
Port: f.Port,
|
||||||
|
SourcePort: f.SourcePort,
|
||||||
|
Proto: proto,
|
||||||
|
InInterface: f.InInterface,
|
||||||
|
OutInterface: f.OutInterface,
|
||||||
|
Log: f.Log,
|
||||||
|
LogPrefix: f.LogPrefix,
|
||||||
|
Comment: f.Comment,
|
||||||
|
}
|
||||||
|
if f.Ports != "" {
|
||||||
|
ranges, err := fw.ParsePortRanges(f.Ports, ",")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rule.Ports = ranges
|
||||||
|
}
|
||||||
|
if f.SourcePorts != "" {
|
||||||
|
ranges, err := fw.ParsePortRanges(f.SourcePorts, ",")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rule.SourcePorts = ranges
|
||||||
|
}
|
||||||
|
if f.ICMPType != "" {
|
||||||
|
t, ok := fw.ParseICMPType(f.ICMPType, rule.Proto == fw.ICMPv6)
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("unknown icmp type %q", f.ICMPType)
|
||||||
|
}
|
||||||
|
rule.ICMPType = fw.Ptr[uint8](t)
|
||||||
|
}
|
||||||
|
if f.State != "" {
|
||||||
|
state, err := fw.ParseConnState(f.State)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rule.State = state
|
||||||
|
}
|
||||||
|
if f.RateLimit != "" {
|
||||||
|
rate, unit, err := parseRateToken(f.RateLimit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rule.RateLimit = &fw.RateLimit{Rate: rate, Unit: unit, Burst: f.RateBurst}
|
||||||
|
}
|
||||||
|
if f.ConnLimit != 0 {
|
||||||
|
rule.ConnLimit = &fw.ConnLimit{Count: f.ConnLimit, PerSource: f.ConnPerSrc}
|
||||||
|
}
|
||||||
|
return rule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// zoneMixin is shared by list/add/remove/insert/move: they all optionally target
|
||||||
|
// a zone, defaulting to the backend's default zone when neither is given. An
|
||||||
|
// explicit --zone wins; --interface is resolved only when --zone is empty.
|
||||||
|
type zoneMixin struct {
|
||||||
|
Zone string `name:"zone" short:"z" help:"Zone name (firewalld) or empty for the default. Takes precedence over --interface."`
|
||||||
|
Interface string `name:"interface" short:"i" help:"Resolve the zone for this interface. Used only when --zone is empty."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleAddCmd adds a filter rule.
|
||||||
|
type RuleAddCmd struct {
|
||||||
|
zoneMixin
|
||||||
|
ruleFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run validates the flags and adds the filter rule.
|
||||||
|
func (c *RuleAddCmd) Run(g *Globals) error {
|
||||||
|
// Validate the rule spec before touching the firewall: a malformed flag
|
||||||
|
// (bad action, family, port range, ...) should fail fast here rather
|
||||||
|
// than after a backend connection has been opened.
|
||||||
|
rule, err := c.toRule()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mgr.AddRule(context.Background(), zone, rule); err != nil {
|
||||||
|
return fmt.Errorf("add rule: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("added")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleRemoveCmd removes a filter rule. The flags describe the rule to match
|
||||||
|
// (identity is rule.Equal, so counters and comment are ignored).
|
||||||
|
type RuleRemoveCmd struct {
|
||||||
|
zoneMixin
|
||||||
|
ruleFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run removes the filter rule matching the given flags.
|
||||||
|
func (c *RuleRemoveCmd) Run(g *Globals) error {
|
||||||
|
rule, err := c.toRule()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mgr.RemoveRule(context.Background(), zone, rule); err != nil {
|
||||||
|
return fmt.Errorf("remove rule: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleInsertCmd inserts a filter rule at a 1-based position (1 = first; a
|
||||||
|
// position past the end appends). Only ordered backends support this.
|
||||||
|
type RuleInsertCmd struct {
|
||||||
|
zoneMixin
|
||||||
|
Position int `arg:"" name:"position" help:"1-based position to insert at (1 = first; past the end appends)."`
|
||||||
|
ruleFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run inserts the filter rule at the requested 1-based position.
|
||||||
|
func (c *RuleInsertCmd) Run(g *Globals) error {
|
||||||
|
rule, err := c.toRule()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mgr.InsertRule(context.Background(), zone, c.Position, rule); err != nil {
|
||||||
|
return fmt.Errorf("insert rule: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("inserted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RuleMoveCmd moves an existing rule to a new 1-based position. The flags
|
||||||
|
// describe the rule to move (matched by identity).
|
||||||
|
type RuleMoveCmd struct {
|
||||||
|
zoneMixin
|
||||||
|
Position int `arg:"" name:"position" help:"1-based position to move the rule to."`
|
||||||
|
ruleFlags
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run moves the matched filter rule to the requested 1-based position.
|
||||||
|
func (c *RuleMoveCmd) Run(g *Globals) error {
|
||||||
|
rule, err := c.toRule()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
zone, err := resolveZone(mgr, c.Zone, c.Interface)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := mgr.MoveRule(context.Background(), zone, rule, c.Position); err != nil {
|
||||||
|
return fmt.Errorf("move rule: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("moved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveZone picks the zone to operate on: an explicit --zone wins, otherwise
|
||||||
|
// the zone for --interface is resolved (which is empty on zoneless backends,
|
||||||
|
// which is correct), otherwise the empty default.
|
||||||
|
func resolveZone(mgr fw.Manager, zone, iface string) (string, error) {
|
||||||
|
if zone != "" {
|
||||||
|
return zone, nil
|
||||||
|
}
|
||||||
|
if iface != "" {
|
||||||
|
return mgr.GetZone(context.Background(), iface)
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
189
cmd/go-firewall/set.go
Normal file
189
cmd/go-firewall/set.go
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
fw "github.com/grmrgecko/firewall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// This file holds the address-set command group: list, create, remove,
|
||||||
|
// add-entry, remove-entry. Address sets are named collections of addresses
|
||||||
|
// (ipset / nftables set / pf table) that rules can match against; they are
|
||||||
|
// managed separately from filter and NAT rules.
|
||||||
|
|
||||||
|
// SetCmd is the top-level "set" command group.
|
||||||
|
type SetCmd struct {
|
||||||
|
List SetListCmd `cmd:"" help:"List address sets (default)."`
|
||||||
|
Show SetShowCmd `cmd:"" help:"Show a single address set and its entries."`
|
||||||
|
Create SetCreateCmd `cmd:"" help:"Create an address set."`
|
||||||
|
Remove SetRemoveCmd `cmd:"" help:"Remove an address set."`
|
||||||
|
AddEntry SetAddEntryCmd `cmd:"" help:"Add an entry to an address set."`
|
||||||
|
RemoveEntry SetRemoveEntryCmd `cmd:"" help:"Remove an entry from an address set."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetShowCmd prints one address set's metadata and every entry it holds. The
|
||||||
|
// list command shows only an entry count; this is how the actual addresses are
|
||||||
|
// inspected in text mode (the --json list already carries them). The library
|
||||||
|
// has no get-by-name method, so it filters GetAddressSets by name.
|
||||||
|
type SetShowCmd struct {
|
||||||
|
Name string `arg:"" name:"name" help:"Set name to show."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run prints one address set's metadata and every entry it holds.
|
||||||
|
func (c *SetShowCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sets, err := mgr.GetAddressSets(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing address sets: %w", err)
|
||||||
|
}
|
||||||
|
var set *fw.AddressSet
|
||||||
|
for _, s := range sets {
|
||||||
|
if s.Name == c.Name {
|
||||||
|
set = s
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if set == nil {
|
||||||
|
return fmt.Errorf("address set %q not found", c.Name)
|
||||||
|
}
|
||||||
|
return g.emit(set, func() error {
|
||||||
|
printSet(os.Stdout, set)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetListCmd lists every address set the backend manages.
|
||||||
|
type SetListCmd struct{}
|
||||||
|
|
||||||
|
// Run lists every address set the backend manages.
|
||||||
|
func (c *SetListCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
sets, err := mgr.GetAddressSets(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("listing address sets: %w", err)
|
||||||
|
}
|
||||||
|
return g.emit(sets, func() error {
|
||||||
|
if len(sets) == 0 {
|
||||||
|
fmt.Println("(no address sets)")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
printSets(os.Stdout, sets)
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCreateCmd creates an address set. Adding a set that already exists by name
|
||||||
|
// is a no-op in the library.
|
||||||
|
type SetCreateCmd struct {
|
||||||
|
Name string `arg:"" name:"name" help:"Set name."`
|
||||||
|
Family string `name:"family" short:"f" help:"any|ipv4|ipv6 (default any; some backends resolve 'any' to ipv4)." default:"any"`
|
||||||
|
Type string `name:"type" short:"t" help:"hash:ip|hash:net (default hash:ip)." default:"hash:ip"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run creates an address set from the parsed flags.
|
||||||
|
func (c *SetCreateCmd) Run(g *Globals) error {
|
||||||
|
// Parse the enum flags before opening the manager so a bad token fails
|
||||||
|
// fast instead of after a backend connection.
|
||||||
|
family, err := fw.ParseFamily(c.Family)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
typ, err := fw.ParseSetType(c.Type)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
set := &fw.AddressSet{Name: c.Name, Family: family, Type: typ}
|
||||||
|
if err := mgr.AddAddressSet(context.Background(), set); err != nil {
|
||||||
|
return fmt.Errorf("create address set: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("created")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRemoveCmd removes an address set by name.
|
||||||
|
type SetRemoveCmd struct {
|
||||||
|
Name string `arg:"" name:"name" help:"Set name to remove."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run removes an address set by name.
|
||||||
|
func (c *SetRemoveCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := mgr.RemoveAddressSet(context.Background(), c.Name); err != nil {
|
||||||
|
return fmt.Errorf("remove address set: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetAddEntryCmd adds an entry (IP or CIDR) to a set.
|
||||||
|
type SetAddEntryCmd struct {
|
||||||
|
Name string `arg:"" name:"name" help:"Set name."`
|
||||||
|
Entry string `arg:"" name:"entry" help:"Address or CIDR to add."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run adds an entry (IP or CIDR) to a set.
|
||||||
|
func (c *SetAddEntryCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := mgr.AddAddressSetEntry(context.Background(), c.Name, c.Entry); err != nil {
|
||||||
|
return fmt.Errorf("add entry: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("added")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRemoveEntryCmd removes an entry from a set.
|
||||||
|
type SetRemoveEntryCmd struct {
|
||||||
|
Name string `arg:"" name:"name" help:"Set name."`
|
||||||
|
Entry string `arg:"" name:"entry" help:"Address or CIDR to remove."`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run removes an entry from a set.
|
||||||
|
func (c *SetRemoveEntryCmd) Run(g *Globals) error {
|
||||||
|
mgr, cleanup, err := g.manager()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
if err := mgr.RemoveAddressSetEntry(context.Background(), c.Name, c.Entry); err != nil {
|
||||||
|
return fmt.Errorf("remove entry: %w", err)
|
||||||
|
}
|
||||||
|
if err := g.reload(mgr); err != nil {
|
||||||
|
return fmt.Errorf("reload: %w", err)
|
||||||
|
}
|
||||||
|
return g.emitStatus("removed")
|
||||||
|
}
|
||||||
118
configfile.go
Normal file
118
configfile.go
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// atomicFile stages an atomic replacement of a config file. It creates a temp
|
||||||
|
// file in the destination's directory and captures the destination's ownership
|
||||||
|
// and permission mode up front; Commit restores that metadata onto the temp
|
||||||
|
// file before renaming it into place. A destination that does not yet exist
|
||||||
|
// falls back to defaultMode with the temp file's default ownership.
|
||||||
|
//
|
||||||
|
// The struct embeds *os.File so callers stream into it with the usual Write and
|
||||||
|
// fmt.Fprintln calls, whether they buffer the whole file or scan the original
|
||||||
|
// and rewrite line by line.
|
||||||
|
type atomicFile struct {
|
||||||
|
*os.File
|
||||||
|
dst string
|
||||||
|
tmp string
|
||||||
|
mode os.FileMode
|
||||||
|
uid, gid int
|
||||||
|
chown bool // True only when the destination already existed.
|
||||||
|
done bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAtomicFile opens a temp file next to dst for streaming. The caller writes
|
||||||
|
// to the returned handle, then calls Commit to install it or Abort to discard
|
||||||
|
// it. Capturing the destination's mode and ownership here means a later
|
||||||
|
// scan-and-rewrite of the original still commits with the original's metadata.
|
||||||
|
func newAtomicFile(dst string, defaultMode os.FileMode) (*atomicFile, error) {
|
||||||
|
mode := defaultMode
|
||||||
|
var uid, gid int
|
||||||
|
var chown bool
|
||||||
|
if fi, err := os.Stat(dst); err == nil {
|
||||||
|
mode = fi.Mode().Perm()
|
||||||
|
uid, gid, chown = statOwner(fi)
|
||||||
|
}
|
||||||
|
fd, err := os.CreateTemp(filepath.Dir(dst), filepath.Base(dst)+".tmp.*")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &atomicFile{
|
||||||
|
File: fd,
|
||||||
|
dst: dst,
|
||||||
|
tmp: fd.Name(),
|
||||||
|
mode: mode,
|
||||||
|
uid: uid,
|
||||||
|
gid: gid,
|
||||||
|
chown: chown,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commit flushes, applies the captured mode and ownership to the temp file, and
|
||||||
|
// renames it over the destination. Ownership is best-effort: a caller that can
|
||||||
|
// write the file but cannot chown it keeps the temp file's owner and still gets
|
||||||
|
// the original mode.
|
||||||
|
func (a *atomicFile) Commit() error {
|
||||||
|
if a.done {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// Apply mode and ownership through the fd so the staged file is never
|
||||||
|
// briefly installed with the wrong permissions.
|
||||||
|
if err := a.File.Chmod(a.mode); err != nil {
|
||||||
|
return a.fail(err)
|
||||||
|
}
|
||||||
|
if a.chown {
|
||||||
|
if err := a.File.Chown(a.uid, a.gid); err != nil && !errors.Is(err, os.ErrPermission) {
|
||||||
|
return a.fail(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := a.File.Close(); err != nil {
|
||||||
|
return a.fail(err)
|
||||||
|
}
|
||||||
|
// Install the staged file.
|
||||||
|
if err := os.Rename(a.tmp, a.dst); err != nil {
|
||||||
|
_ = os.Remove(a.tmp)
|
||||||
|
a.done = true
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
a.done = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Abort discards the temp file. It is a no-op after a successful Commit, so a
|
||||||
|
// caller may defer Abort immediately after opening.
|
||||||
|
func (a *atomicFile) Abort() {
|
||||||
|
if a.done {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = a.File.Close()
|
||||||
|
_ = os.Remove(a.tmp)
|
||||||
|
a.done = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// fail closes and removes the temp file, then returns the triggering error.
|
||||||
|
func (a *atomicFile) fail(err error) error {
|
||||||
|
_ = a.File.Close()
|
||||||
|
_ = os.Remove(a.tmp)
|
||||||
|
a.done = true
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConfigFile atomically replaces path with data for callers that already
|
||||||
|
// hold the whole file in memory, preserving the existing file's mode and
|
||||||
|
// ownership (falling back to defaultMode for a file that does not yet exist).
|
||||||
|
func writeConfigFile(path string, data []byte, defaultMode os.FileMode) error {
|
||||||
|
af, err := newAtomicFile(path, defaultMode)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer af.Abort()
|
||||||
|
if _, err := af.Write(data); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return af.Commit()
|
||||||
|
}
|
||||||
11
configfile_other.go
Normal file
11
configfile_other.go
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
//go:build !unix
|
||||||
|
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// statOwner reports no ownership on platforms without Unix stat data, so Commit
|
||||||
|
// leaves the temp file's ownership unchanged.
|
||||||
|
func statOwner(fi os.FileInfo) (uid, gid int, ok bool) {
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
157
configfile_test.go
Normal file
157
configfile_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// noTempLeftover asserts the staging directory holds no uncommitted temp files.
|
||||||
|
func noTempLeftover(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, e := range entries {
|
||||||
|
require.NotContains(t, e.Name(), ".tmp.", "a staged temp file was left behind")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtomicFilePreservesMode confirms a rewrite of an existing file keeps its
|
||||||
|
// non-default permission mode rather than resetting to the caller's default.
|
||||||
|
func TestAtomicFilePreservesMode(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "conf")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("old\n"), 0600))
|
||||||
|
require.NoError(t, os.Chmod(path, 0640))
|
||||||
|
|
||||||
|
af, err := newAtomicFile(path, 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = af.Write([]byte("new\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, af.Commit())
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, os.FileMode(0640), fi.Mode().Perm(), "existing mode must be preserved")
|
||||||
|
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "new\n", string(got))
|
||||||
|
noTempLeftover(t, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtomicFileNewFileUsesDefaultMode confirms a file that does not yet exist
|
||||||
|
// is created with the supplied default mode.
|
||||||
|
func TestAtomicFileNewFileUsesDefaultMode(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "new-conf")
|
||||||
|
|
||||||
|
af, err := newAtomicFile(path, 0600)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = af.Write([]byte("content\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, af.Commit())
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, os.FileMode(0600), fi.Mode().Perm())
|
||||||
|
noTempLeftover(t, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtomicFileAbortLeavesOriginal confirms Abort discards the staged rewrite
|
||||||
|
// and leaves the original file and its content untouched.
|
||||||
|
func TestAtomicFileAbortLeavesOriginal(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "conf")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("original\n"), 0644))
|
||||||
|
|
||||||
|
af, err := newAtomicFile(path, 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = af.Write([]byte("discarded\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
af.Abort()
|
||||||
|
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "original\n", string(got), "Abort must not modify the original")
|
||||||
|
noTempLeftover(t, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtomicFileAbortAfterCommitIsNoOp confirms a deferred Abort after a
|
||||||
|
// successful Commit does not remove the installed file.
|
||||||
|
func TestAtomicFileAbortAfterCommitIsNoOp(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "conf")
|
||||||
|
|
||||||
|
af, err := newAtomicFile(path, 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = af.Write([]byte("kept\n"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, af.Commit())
|
||||||
|
af.Abort() // Must be inert.
|
||||||
|
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "kept\n", string(got))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteConfigFilePreservesMode confirms the buffer-all convenience wrapper
|
||||||
|
// preserves an existing file's mode.
|
||||||
|
func TestWriteConfigFilePreservesMode(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "conf")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("x"), 0600))
|
||||||
|
require.NoError(t, os.Chmod(path, 0640))
|
||||||
|
|
||||||
|
require.NoError(t, writeConfigFile(path, []byte("y\n"), 0644))
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, os.FileMode(0640), fi.Mode().Perm())
|
||||||
|
got, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "y\n", string(got))
|
||||||
|
noTempLeftover(t, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtomicFilePreservesOwner confirms a rewrite restores the original file's
|
||||||
|
// ownership. It requires root to assign a foreign owner, so it is skipped for an
|
||||||
|
// unprivileged run.
|
||||||
|
func TestAtomicFilePreservesOwner(t *testing.T) {
|
||||||
|
if os.Geteuid() != 0 {
|
||||||
|
t.Skip("requires root to chown to a foreign owner")
|
||||||
|
}
|
||||||
|
const uid, gid = 65534, 65534 // nobody/nogroup on most systems.
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "conf")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("old\n"), 0644))
|
||||||
|
require.NoError(t, os.Chown(path, uid, gid))
|
||||||
|
|
||||||
|
require.NoError(t, writeConfigFile(path, []byte("new\n"), 0644))
|
||||||
|
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
st, ok := fi.Sys().(*syscall.Stat_t)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.Equal(t, uint32(uid), st.Uid, "owner uid must be preserved")
|
||||||
|
require.Equal(t, uint32(gid), st.Gid, "owner gid must be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAtomicFileStagesInDestinationDir confirms the temp file is created next to
|
||||||
|
// the destination so the final rename is atomic within one filesystem.
|
||||||
|
func TestAtomicFileStagesInDestinationDir(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "conf")
|
||||||
|
|
||||||
|
af, err := newAtomicFile(path, 0644)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, dir, filepath.Dir(af.tmp))
|
||||||
|
require.True(t, strings.HasPrefix(filepath.Base(af.tmp), "conf.tmp."))
|
||||||
|
af.Abort()
|
||||||
|
}
|
||||||
18
configfile_unix.go
Normal file
18
configfile_unix.go
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
//go:build unix
|
||||||
|
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"syscall"
|
||||||
|
)
|
||||||
|
|
||||||
|
// statOwner extracts the uid and gid from a Unix FileInfo. The final return is
|
||||||
|
// false when the underlying stat data is unavailable, leaving ownership
|
||||||
|
// unchanged on Commit.
|
||||||
|
func statOwner(fi os.FileInfo) (uid, gid int, ok bool) {
|
||||||
|
if st, ok := fi.Sys().(*syscall.Stat_t); ok {
|
||||||
|
return int(st.Uid), int(st.Gid), true
|
||||||
|
}
|
||||||
|
return 0, 0, false
|
||||||
|
}
|
||||||
1947
csf_linux.go
Normal file
1947
csf_linux.go
Normal file
File diff suppressed because it is too large
Load diff
888
csf_linux_test.go
Normal file
888
csf_linux_test.go
Normal file
|
|
@ -0,0 +1,888 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A port-only IPv6 deny carries no address (its ::/0 is synthesized on write),
|
||||||
|
// so the IPV6-disabled gate must key on the implied family alone. Otherwise the
|
||||||
|
// line is written, csf.pl drops it as a v6-resolving line under IPV6!=1, and the
|
||||||
|
// port stays open while GetRules reports it blocked.
|
||||||
|
func TestCSFIPv6UnavailablePortOnlyDeny(t *testing.T) {
|
||||||
|
fw := new(CSF) // ipv6Enabled defaults to false — the IPV6-disabled case.
|
||||||
|
|
||||||
|
// The regression: a port-only IPv6 deny (no Source/Destination) must be caught.
|
||||||
|
require.True(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop}),
|
||||||
|
"port-only IPv6 deny slipped past the IPV6-disabled gate")
|
||||||
|
|
||||||
|
// A v6-address rule was already caught and must stay caught.
|
||||||
|
require.True(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Drop}))
|
||||||
|
|
||||||
|
// Not caught: a family-agnostic port-only deny (its 0.0.0.0/0 twin is enforced),
|
||||||
|
// a plain IPv4 rule, and ICMPv6 (which routes through the raw-iptables hook).
|
||||||
|
require.False(t, fw.ipv6Unavailable(&Rule{Proto: TCP, Port: 8080, Action: Drop}))
|
||||||
|
require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop}))
|
||||||
|
require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}))
|
||||||
|
|
||||||
|
// With IPv6 enabled the gate never fires.
|
||||||
|
fw.ipv6Enabled = true
|
||||||
|
require.False(t, fw.ipv6Unavailable(&Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ProtocolAny port-only reject must be written to csf.deny as explicit tcp and
|
||||||
|
// udp advanced lines: csf's linefilter defaults a protocol-less line to -p tcp,
|
||||||
|
// so a single protocol-less line would leave udp open while the library reported
|
||||||
|
// the port blocked for all protocols.
|
||||||
|
func TestCSFProtocolAnyRejectFansOut(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fw := new(CSF)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "csf.deny")
|
||||||
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
||||||
|
|
||||||
|
reject := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 80, Action: Reject}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Reject, reject, false))
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
text := string(data)
|
||||||
|
require.Contains(t, text, "tcp|in|d=80|s=0.0.0.0/0", "tcp line must be present")
|
||||||
|
require.Contains(t, text, "udp|in|d=80|s=0.0.0.0/0", "udp line must be present so udp is actually blocked")
|
||||||
|
// No protocol-less line (which csf would silently treat as tcp only).
|
||||||
|
for _, line := range strings.Split(text, "\n") {
|
||||||
|
require.False(t, strings.HasPrefix(strings.TrimSpace(line), "in|"),
|
||||||
|
"a protocol-less advanced line silently means tcp-only in csf: %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port-only deny whose action is Drop (csf.conf's default DROP for an inbound
|
||||||
|
// deny) must still be written: the placeholder branch keys on "not an accept",
|
||||||
|
// not on Reject specifically. Before the fix it keyed on Reject, so a Drop deny
|
||||||
|
// wrote nothing while AddRule reported success — the port stayed open.
|
||||||
|
func TestCSFPortOnlyDropDenyIsWritten(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fw := new(CSF)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "csf.deny")
|
||||||
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
||||||
|
|
||||||
|
drop := &Rule{Family: IPv4, Proto: TCP, Port: 3306, Action: Drop}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, drop, false))
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(data), "tcp|in|d=3306|s=0.0.0.0/0",
|
||||||
|
"a port-only Drop deny must be written with the any-network placeholder")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ProtocolAny port deny is written as a tcp line and a udp line, so it must be
|
||||||
|
// idempotent on re-add, read back as a single ProtocolAny rule, and be fully
|
||||||
|
// removed by one RemoveRule. Before the fix the add/remove matcher compared
|
||||||
|
// ProtocolAny against the concrete-protocol lines exactly, so re-adds duplicated
|
||||||
|
// the pair and removal was a silent no-op.
|
||||||
|
func TestCSFProtocolAnyPortDenyRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fw := new(CSF)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "csf.deny")
|
||||||
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
||||||
|
|
||||||
|
deny := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 80, Action: Drop}
|
||||||
|
|
||||||
|
// Add fans the ProtocolAny deny out to a tcp and a udp line.
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=80|s=0.0.0.0/0"))
|
||||||
|
require.Equal(t, 1, strings.Count(string(data), "udp|in|d=80|s=0.0.0.0/0"))
|
||||||
|
|
||||||
|
// Re-adding is idempotent: neither line is duplicated.
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=80|s=0.0.0.0/0"),
|
||||||
|
"re-adding a ProtocolAny deny must not duplicate its tcp line")
|
||||||
|
require.Equal(t, 1, strings.Count(string(data), "udp|in|d=80|s=0.0.0.0/0"),
|
||||||
|
"re-adding a ProtocolAny deny must not duplicate its udp line")
|
||||||
|
|
||||||
|
// The fanned lines read back and collapse to one ProtocolAny rule.
|
||||||
|
parsed, err := fw.ParseIPList(path, Drop)
|
||||||
|
require.NoError(t, err)
|
||||||
|
merged := fw.mergeProtocols(mergeFamilies(parsed))
|
||||||
|
require.Len(t, merged, 1, "the tcp+udp deny lines must merge to one ProtocolAny rule")
|
||||||
|
require.Equal(t, ProtocolAny, merged[0].Proto)
|
||||||
|
require.True(t, merged[0].EqualBase(deny, true), "the merged rule must equal the ProtocolAny deny that was added")
|
||||||
|
|
||||||
|
// A single RemoveRule must drop every fanned line.
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true))
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(data), "d=80",
|
||||||
|
"removing a ProtocolAny deny must delete all of its fanned lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port-only deny fans out across family (and protocol), but the file may already
|
||||||
|
// hold a subset of those lines — a prior single-family add, or a manual edit. The
|
||||||
|
// add must heal the missing lines rather than treat the rule as fully present the
|
||||||
|
// moment one fan-out line matches: otherwise the other family/protocol stays open
|
||||||
|
// while the library reports the port blocked. Regression for the single-"exists"
|
||||||
|
// gate that skipped the whole fan-out.
|
||||||
|
func TestCSFPortOnlyDenyHealsMissingFamily(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fw := new(CSF)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "csf.deny")
|
||||||
|
|
||||||
|
// The file already has only the IPv4 fan-out line.
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("tcp|in|d=80|s=0.0.0.0/0\n"), 0644))
|
||||||
|
|
||||||
|
// Adding a FamilyAny port-80 TCP deny must add the missing IPv6 line (and not
|
||||||
|
// duplicate the existing IPv4 one).
|
||||||
|
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
text := string(data)
|
||||||
|
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"),
|
||||||
|
"the pre-existing IPv4 line must be preserved, not duplicated")
|
||||||
|
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=::/0"),
|
||||||
|
"the missing IPv6 line must be added so IPv6:80 is actually blocked")
|
||||||
|
|
||||||
|
// A ProtocolAny deny whose udp line already exists must add the missing tcp line.
|
||||||
|
path2 := filepath.Join(dir, "csf.deny2")
|
||||||
|
require.NoError(t, os.WriteFile(path2, []byte("udp|in|d=53|s=0.0.0.0/0\n"), 0644))
|
||||||
|
anyDeny := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 53, Action: Drop}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path2, Drop, anyDeny, false))
|
||||||
|
data2, err := os.ReadFile(path2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, strings.Count(string(data2), "udp|in|d=53|s=0.0.0.0/0"),
|
||||||
|
"the pre-existing udp line must be preserved")
|
||||||
|
require.Equal(t, 1, strings.Count(string(data2), "tcp|in|d=53|s=0.0.0.0/0"),
|
||||||
|
"the missing tcp line must be added so tcp:53 is actually blocked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A csf advanced rule with an address, a port, and ProtocolAny cannot be
|
||||||
|
// expressed as a single line: csf.pl defaults a protocol-less line to tcp, so
|
||||||
|
// udp would be silently left open. MarshalAdvRule must reject it rather than
|
||||||
|
// under-apply it (the port-only form fans out instead, but that path has no
|
||||||
|
// address to place).
|
||||||
|
func TestCSFAdvRuleProtocolAnyWithAddressRejected(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
_, err := fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: ProtocolAny, Port: 443, Source: "192.0.2.10", Action: Drop})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported,
|
||||||
|
"an address+port rule with ProtocolAny must be rejected, not written tcp-only")
|
||||||
|
|
||||||
|
// A concrete protocol on the same shape is fine.
|
||||||
|
_, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Drop})
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A csf tcp/udp advanced rule with an address but no port cannot be expressed:
|
||||||
|
// csf.pl's linefilter reads the port-flow field by position before the address
|
||||||
|
// field, so the address shifts into the port slot, gets parsed as a garbage
|
||||||
|
// --sport/--dport value, and the rule is silently dropped (linefilter requires
|
||||||
|
// both an address and a port match to install anything). MarshalAdvRule must
|
||||||
|
// reject this shape rather than emit an unenforceable line.
|
||||||
|
func TestCSFAdvRuleAddressWithoutPortRejected(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
_, err := fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: TCP, Source: "192.0.2.10", Action: Drop})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported,
|
||||||
|
"a tcp advanced rule with an address and no port must be rejected")
|
||||||
|
_, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: UDP, Destination: "192.0.2.10", Action: Accept})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported,
|
||||||
|
"a udp advanced rule with an address and no port must be rejected")
|
||||||
|
|
||||||
|
// An ICMP rule with an address and no port is unaffected by this guard (it
|
||||||
|
// already requires a concrete ICMP type via a separate check above).
|
||||||
|
_, err = fw.MarshalAdvRule(&Rule{Family: IPv4, Proto: ICMP, Source: "192.0.2.10", Action: Accept})
|
||||||
|
require.Error(t, err, "expected the existing icmp-without-type guard to fire, not this one")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSFParseAdvRuleIPv6(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
|
||||||
|
// An IPv6 source with a port must parse (the field separator is '|', so a
|
||||||
|
// colon in the value is an IPv6 address, not a range/list separator).
|
||||||
|
r := fw.ParseAdvRule("tcp|in|d=22|s=2001:db8::1", Accept)
|
||||||
|
require.NotNil(t, r, "expected IPv6 advanced rule to parse")
|
||||||
|
require.Equal(t, IPv6, r.Family, "expected IPv6 family")
|
||||||
|
require.Equal(t, "2001:db8::1", r.Source)
|
||||||
|
require.EqualValues(t, 22, r.Port)
|
||||||
|
require.Equal(t, TCP, r.Proto)
|
||||||
|
require.False(t, r.IsOutput())
|
||||||
|
|
||||||
|
// An IPv4 destination with a port still parses.
|
||||||
|
r = fw.ParseAdvRule("tcp|out|d=80|d=192.0.2.1", Accept)
|
||||||
|
require.NotNil(t, r, "expected IPv4 advanced rule to parse")
|
||||||
|
require.Equal(t, IPv4, r.Family)
|
||||||
|
require.Equal(t, "192.0.2.1", r.Destination)
|
||||||
|
require.EqualValues(t, 80, r.Port)
|
||||||
|
|
||||||
|
// A destination port range is neither a valid IP nor a single port, so it
|
||||||
|
// must still be rejected.
|
||||||
|
require.Nil(t, fw.ParseAdvRule("tcp|in|d=1000:2000", Accept),
|
||||||
|
"expected a port range to be rejected")
|
||||||
|
|
||||||
|
// A comma-separated address list is still rejected.
|
||||||
|
require.Nil(t, fw.ParseAdvRule("tcp|in|s=192.0.2.1,192.0.2.2", Accept),
|
||||||
|
"expected a multi-address rule to be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSFFeatureRules(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
|
||||||
|
// Advanced-rule encodings.
|
||||||
|
cases := []struct {
|
||||||
|
rule *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|d=80,443|s=1.2.3.4"},
|
||||||
|
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 2000, End: 3000}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|d=2000_3000|s=1.2.3.4"},
|
||||||
|
{&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "44.33.22.11", Family: IPv4, Action: Accept}, "icmp|in|d=8|s=44.33.22.11"},
|
||||||
|
{&Rule{Direction: DirOutput, Proto: UDP, Port: 53, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "udp|out|d=53|d=192.0.2.1"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := fw.MarshalAdvRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
||||||
|
|
||||||
|
parsed := fw.ParseAdvRule(got, c.rule.Action)
|
||||||
|
require.NotNil(t, parsed, "failed to parse %q", got)
|
||||||
|
require.True(t, parsed.Equal(c.rule, true),
|
||||||
|
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ICMP type given by name resolves to its number.
|
||||||
|
r := fw.ParseAdvRule("icmp|in|d=ping|s=44.33.22.11", Accept)
|
||||||
|
require.NotNil(t, r, "expected icmp type 8 from name ping")
|
||||||
|
require.NotNil(t, r.ICMPType, "expected icmp type 8 from name ping")
|
||||||
|
require.EqualValues(t, 8, *r.ICMPType, "expected icmp type 8 from name ping")
|
||||||
|
|
||||||
|
// csf reuses the port position for the ICMP type in BOTH the s= and d= fields
|
||||||
|
// (csf.pl maps `s=<n>` to `--icmp-type <n>` for an icmp rule, just like `d=`).
|
||||||
|
// A foreign rule that carries the type in s= must therefore read back as the
|
||||||
|
// ICMP type, not as a nonsensical source port.
|
||||||
|
r = fw.ParseAdvRule("icmp|in|s=8|d=44.33.22.11", Accept)
|
||||||
|
require.NotNil(t, r, "expected icmp rule with type in s= to parse")
|
||||||
|
require.NotNil(t, r.ICMPType, "expected s=8 to be read as icmp type 8")
|
||||||
|
require.EqualValues(t, 8, *r.ICMPType, "expected icmp type 8 from s=8")
|
||||||
|
require.False(t, r.HasSourcePorts(), "an icmp type must not be read as a source port")
|
||||||
|
require.Equal(t, "44.33.22.11", r.Destination)
|
||||||
|
|
||||||
|
// A colon range in an advanced rule is invalid (csf uses underscores there).
|
||||||
|
require.Nil(t, fw.ParseAdvRule("tcp|in|d=1000:2000|s=1.2.3.4", Accept),
|
||||||
|
"expected colon range in advanced rule to be rejected")
|
||||||
|
|
||||||
|
// csf.conf port lists parse single ports and colon ranges.
|
||||||
|
rules := fw.ParsePorts("20,21,30000:35000", IPv4, TCP, false)
|
||||||
|
require.Len(t, rules, 3, "expected 3 port rules")
|
||||||
|
require.Len(t, rules[2].Ports, 1)
|
||||||
|
require.Equal(t, PortRange{Start: 30000, End: 35000}, rules[2].Ports[0],
|
||||||
|
"expected a 30000-35000 range rule")
|
||||||
|
|
||||||
|
// EditRulePort adds a colon range token to the matching csf.conf port list.
|
||||||
|
require.Equal(t, `TCP_IN = "22,2000:3000"`,
|
||||||
|
fw.EditRulePort(`TCP_IN = "22"`, "TCP_IN", "22",
|
||||||
|
&Rule{Proto: TCP, Ports: []PortRange{{Start: 2000, End: 3000}}, Action: Accept}, false),
|
||||||
|
"unexpected csf.conf port edit")
|
||||||
|
|
||||||
|
// MarshalAdvRule rejects rules csf cannot express as advanced rules.
|
||||||
|
_, err := fw.MarshalAdvRule(&Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept})
|
||||||
|
require.Error(t, err, "expected error marshalling an icmpv6 advanced rule")
|
||||||
|
_, err = fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Action: Accept})
|
||||||
|
require.Error(t, err, "expected error marshalling an advanced rule without an address")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSFSourcePorts(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
|
||||||
|
// Source ports round-trip through the s= port-flow field, including a
|
||||||
|
// multiport list and an underscore range.
|
||||||
|
cases := []struct {
|
||||||
|
rule *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{&Rule{Proto: TCP, SourcePort: 1234, Destination: "192.0.2.1", Family: IPv4, Action: Accept}, "tcp|in|s=1234|d=192.0.2.1"},
|
||||||
|
{&Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 80}, {Start: 443}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "udp|in|s=80,443|s=1.2.3.4"},
|
||||||
|
{&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 2000, End: 3000}}, Source: "1.2.3.4", Family: IPv4, Action: Accept}, "tcp|in|s=2000_3000|s=1.2.3.4"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := fw.MarshalAdvRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
||||||
|
|
||||||
|
parsed := fw.ParseAdvRule(got, c.rule.Action)
|
||||||
|
require.NotNil(t, parsed, "failed to parse %q", got)
|
||||||
|
require.True(t, parsed.Equal(c.rule, true),
|
||||||
|
"round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matching both a source and a destination port is not representable.
|
||||||
|
_, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 22, SourcePort: 1234, Source: "1.2.3.4", Action: Accept})
|
||||||
|
require.Error(t, err, "expected error matching both source and destination ports")
|
||||||
|
|
||||||
|
// A source-port rule with no address has nowhere to go.
|
||||||
|
require.Error(t, fw.checkSourcePort(&Rule{Proto: TCP, SourcePort: 1234, Action: Accept}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ICMP advanced rule that carries an address but no concrete type must be
|
||||||
|
// rejected: csf's linefilter would consume the address as the icmp-type field
|
||||||
|
// and silently drop the rule, while the library reported it enforced.
|
||||||
|
func TestCSFICMPRequiresType(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
|
||||||
|
// Address but no type: rejected by both the validator and the marshaller.
|
||||||
|
noType := &Rule{Proto: ICMP, Source: "1.2.3.4", Action: Accept}
|
||||||
|
require.Error(t, fw.checkICMP(noType))
|
||||||
|
_, err := fw.MarshalAdvRule(noType)
|
||||||
|
require.Error(t, err, "must not emit an ICMP advanced line with the address in the type field")
|
||||||
|
|
||||||
|
// With a concrete type it is a valid advanced rule and round-trips.
|
||||||
|
typed := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Source: "1.2.3.4", Action: Accept}
|
||||||
|
require.NoError(t, fw.checkICMP(typed))
|
||||||
|
line, err := fw.MarshalAdvRule(typed)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "icmp|in|d=8|s=1.2.3.4", line)
|
||||||
|
parsed := fw.ParseAdvRule(line, Accept)
|
||||||
|
require.NotNil(t, parsed)
|
||||||
|
require.True(t, parsed.Equal(typed, true), "round-trip mismatch: %q -> %+v", line, parsed)
|
||||||
|
|
||||||
|
// An address-less ICMP rule is still rejected (advanced rules need an address).
|
||||||
|
require.Error(t, fw.checkICMP(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSFConnLimit(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
|
||||||
|
// A csf.conf CONNLIMIT value parses into per-port reject rules carrying a
|
||||||
|
// per-source connection cap. csf's CONNLIMIT chain rejects the excess with a
|
||||||
|
// TCP reset (-j REJECT --reject-with tcp-reset), so the action is Reject.
|
||||||
|
rules := fw.ParseConnLimit("22;5,80;20")
|
||||||
|
require.Len(t, rules, 2)
|
||||||
|
require.Equal(t, TCP, rules[1].Proto)
|
||||||
|
require.EqualValues(t, 80, rules[1].Port)
|
||||||
|
require.Equal(t, Reject, rules[1].Action)
|
||||||
|
require.NotNil(t, rules[1].ConnLimit)
|
||||||
|
require.EqualValues(t, 20, rules[1].ConnLimit.Count)
|
||||||
|
require.True(t, rules[1].ConnLimit.PerSource)
|
||||||
|
|
||||||
|
// Editing the CONNLIMIT list adds, removes, and updates a port's entry.
|
||||||
|
require.Equal(t, `CONNLIMIT = "22;5,80;20"`, fw.editConnLimit("22;5", 80, 20, false))
|
||||||
|
require.Equal(t, `CONNLIMIT = "80;20"`, fw.editConnLimit("22;5,80;20", 22, 5, true))
|
||||||
|
require.Equal(t, `CONNLIMIT = "80;50"`, fw.editConnLimit("80;20", 80, 50, false))
|
||||||
|
|
||||||
|
// Only a single inbound tcp port, address-less, per-source, reject rule maps.
|
||||||
|
require.True(t, fw.isConnLimitRule(&Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}))
|
||||||
|
bad := []*Rule{
|
||||||
|
{Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // wrong action (csf rejects, not drops)
|
||||||
|
{Proto: UDP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // udp
|
||||||
|
{Proto: TCP, Port: 80, Source: "1.2.3.4", Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // address
|
||||||
|
{Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}, // range
|
||||||
|
{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}, // global
|
||||||
|
}
|
||||||
|
for _, r := range bad {
|
||||||
|
require.Error(t, fw.checkConnLimit(r), "expected reject for %+v", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseConnLimit's reported Family must follow csf.conf's IPV6 setting: csf.pl
|
||||||
|
// only installs the ip6tables CONNLIMIT rule when IPV6 is enabled, so on the
|
||||||
|
// shipped default (IPV6="0") CONNLIMIT protects IPv4 only, not both families.
|
||||||
|
func TestCSFConnLimitFamily(t *testing.T) {
|
||||||
|
disabled := &CSF{ipv6Enabled: false}
|
||||||
|
rules := disabled.ParseConnLimit("22;5")
|
||||||
|
require.Len(t, rules, 1)
|
||||||
|
require.Equal(t, IPv4, rules[0].Family,
|
||||||
|
"CONNLIMIT must report IPv4-only when csf.conf IPV6 is off")
|
||||||
|
|
||||||
|
enabled := &CSF{ipv6Enabled: true}
|
||||||
|
rules = enabled.ParseConnLimit("22;5")
|
||||||
|
require.Len(t, rules, 1)
|
||||||
|
require.Equal(t, FamilyAny, rules[0].Family,
|
||||||
|
"CONNLIMIT must report dual-stack (FamilyAny) when csf.conf IPV6 is on")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSFRedirectNAT(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
rule *NATRule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// A local port redirect (IPy = "*").
|
||||||
|
{&NATRule{Kind: Redirect, Proto: TCP, Port: 666, ToPort: 25}, "*|666|*|25|tcp"},
|
||||||
|
// A forward to another host with a fixed destination address.
|
||||||
|
{&NATRule{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", Port: 666, ToAddress: "10.0.0.1", ToPort: 25, Family: IPv4}, "192.168.254.62|666|10.0.0.1|25|tcp"},
|
||||||
|
// A full-IP forward, all ports (portA/portB unset).
|
||||||
|
{&NATRule{Kind: DNAT, Proto: TCP, Destination: "192.168.254.62", ToAddress: "10.0.0.1", Family: IPv4}, "192.168.254.62|*|10.0.0.1|*|tcp"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := fw.MarshalNATRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
||||||
|
|
||||||
|
parsed := fw.UnmarshalNATRule(got)
|
||||||
|
require.NotNil(t, parsed, "failed to parse %q", got)
|
||||||
|
require.True(t, parsed.EqualBase(c.rule), "round-trip mismatch: input %+v, line %q, output %+v", *c.rule, got, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// csf.redirect cannot express source NAT, port ranges, source matching, or
|
||||||
|
// non-tcp/udp protocols.
|
||||||
|
bad := []*NATRule{
|
||||||
|
{Kind: SNAT, ToAddress: "1.2.3.4"},
|
||||||
|
{Kind: Masquerade},
|
||||||
|
{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80, End: 90}}, ToAddress: "1.2.3.4"},
|
||||||
|
{Kind: DNAT, Proto: ICMP, ToAddress: "1.2.3.4"},
|
||||||
|
{Kind: DNAT, Proto: TCP, Source: "1.2.3.4", ToAddress: "5.6.7.8"},
|
||||||
|
}
|
||||||
|
for _, r := range bad {
|
||||||
|
_, err := fw.MarshalNATRule(r)
|
||||||
|
require.Error(t, err, "expected error marshalling %+v", *r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A malformed csf.redirect line is ignored by the parser.
|
||||||
|
require.Nil(t, fw.UnmarshalNATRule("nonsense|line"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCSFIPListComment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "csf.allow")
|
||||||
|
fw := &CSF{rulePrefix: "myapp"}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(
|
||||||
|
"# myapp trusted office\n"+
|
||||||
|
"tcp|in|d=22|s=10.0.0.0/24\n"+
|
||||||
|
"\n"+
|
||||||
|
"# unrelated note\n"+
|
||||||
|
"# separated by blank\n"+
|
||||||
|
"192.0.2.5\n"+
|
||||||
|
"2001:db8::1 # inline ignored\n",
|
||||||
|
), 0644))
|
||||||
|
|
||||||
|
rules, err := fw.ParseIPList(path, Accept)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Advanced rule keeps the comment immediately above it.
|
||||||
|
adv := rules[0]
|
||||||
|
require.Equal(t, "trusted office", adv.Comment)
|
||||||
|
require.Equal(t, "10.0.0.0/24", adv.Source)
|
||||||
|
require.EqualValues(t, 22, adv.Port)
|
||||||
|
|
||||||
|
// A bare IPv4 line is one bidirectional DirAny rule carrying the accumulated comment.
|
||||||
|
host := rules[1]
|
||||||
|
require.Equal(t, DirAny, host.Direction)
|
||||||
|
require.Equal(t, "192.0.2.5", host.Source)
|
||||||
|
require.Equal(t, "unrelated note separated by blank", host.Comment)
|
||||||
|
|
||||||
|
// Inline comment is ignored, not treated as a rule comment.
|
||||||
|
v6 := rules[2]
|
||||||
|
require.Equal(t, DirAny, v6.Direction)
|
||||||
|
require.Equal(t, "", v6.Comment)
|
||||||
|
require.Equal(t, "2001:db8::1", v6.Source)
|
||||||
|
|
||||||
|
// Add a rule with a comment: a prefixed full-line comment is written above it.
|
||||||
|
add := &Rule{Proto: TCP, Port: 443, Source: "192.0.2.10", Action: Accept, Comment: "web"}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, false))
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(data), "# myapp web\n")
|
||||||
|
require.Contains(t, string(data), "tcp|in|d=443|s=192.0.2.10")
|
||||||
|
|
||||||
|
// Removing the rule drops the comment line above it as well.
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, add, true))
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(data), "# myapp web")
|
||||||
|
require.NotContains(t, string(data), "192.0.2.10")
|
||||||
|
|
||||||
|
// A port-only rule has nowhere to go in an IP-list file; no dangling
|
||||||
|
// comment line should be written even when a comment is supplied.
|
||||||
|
portOnly := &Rule{Proto: TCP, Port: 8080, Action: Accept, Comment: "not-stored"}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, portOnly, false))
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(data), "not-stored")
|
||||||
|
|
||||||
|
// A rule appended after instructional header comments must still report
|
||||||
|
// HasPrefix: the prefix tag starts a fresh comment block so header
|
||||||
|
// comments are not absorbed into the rule's comment.
|
||||||
|
headerPath := filepath.Join(dir, "header_csf.allow")
|
||||||
|
require.NoError(t, os.WriteFile(headerPath, []byte(
|
||||||
|
"# This is the csf.allow file.\n"+
|
||||||
|
"# Add hosts/rules below, one per line.\n"+
|
||||||
|
"# Format: proto|flow|port|ip\n",
|
||||||
|
), 0644))
|
||||||
|
appendRule := &Rule{Proto: TCP, Port: 3456, Source: "192.0.2.10/32", Action: Accept}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, headerPath, Accept, appendRule, false))
|
||||||
|
parsed, err := fw.ParseIPList(headerPath, Accept)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, parsed, 1)
|
||||||
|
require.True(t, parsed[0].HasPrefix, "rule after header comments must be flagged with the prefix")
|
||||||
|
require.Equal(t, "", parsed[0].Comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCSFRemovePreservesForeignHeader verifies that removing a managed rule keeps
|
||||||
|
// a foreign section header sitting directly above its prefix tag. ParseIPList
|
||||||
|
// treats the tag as starting a fresh comment block, so the header is not part of
|
||||||
|
// the rule's comment; removal must mirror that and not delete it.
|
||||||
|
func TestCSFRemovePreservesForeignHeader(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "csf.allow")
|
||||||
|
fw := &CSF{rulePrefix: "myapp"}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(
|
||||||
|
"# Section: web servers\n"+
|
||||||
|
"# myapp trusted\n"+
|
||||||
|
"192.0.2.50\n",
|
||||||
|
), 0644))
|
||||||
|
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Accept, &Rule{Source: "192.0.2.50", Action: Accept}, true))
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got := string(data)
|
||||||
|
require.NotContains(t, got, "192.0.2.50", "the managed rule must be removed")
|
||||||
|
require.NotContains(t, got, "# myapp trusted", "the rule's own tag comment is removed with it")
|
||||||
|
require.Contains(t, got, "# Section: web servers", "the foreign section header must be preserved")
|
||||||
|
}
|
||||||
|
|
||||||
|
// csf.pl's linefilter drops a csf.allow/csf.deny line (plain or advanced) that
|
||||||
|
// resolves to an IPv6 address whenever csf.conf's IPV6 is not "1" (the shipped
|
||||||
|
// default). ipv6Unavailable must flag exactly that shape, and only when
|
||||||
|
// ipv6Enabled is false; ICMPv6 (always hook-routed) must never be flagged.
|
||||||
|
func TestCSFIPv6UnavailableGate(t *testing.T) {
|
||||||
|
disabled := &CSF{ipv6Enabled: false}
|
||||||
|
enabled := &CSF{ipv6Enabled: true}
|
||||||
|
|
||||||
|
bareV6Host := &Rule{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept}
|
||||||
|
require.True(t, disabled.ipv6Unavailable(bareV6Host),
|
||||||
|
"an IPv6 rule must be blocked when csf.conf IPV6 is off")
|
||||||
|
require.False(t, enabled.ipv6Unavailable(bareV6Host),
|
||||||
|
"an IPv6 rule must be allowed when csf.conf IPV6 is on")
|
||||||
|
|
||||||
|
bareV4Host := &Rule{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept}
|
||||||
|
require.False(t, disabled.ipv6Unavailable(bareV4Host),
|
||||||
|
"an IPv4 rule must never be blocked by the IPv6 gate")
|
||||||
|
|
||||||
|
// ICMPv6 always routes through the raw-iptables hook, which runs outside
|
||||||
|
// csf's own IPV6-gated logic, so it must not be blocked either way.
|
||||||
|
icmpv6 := &Rule{Proto: ICMPv6, Source: "2001:db8::1", Action: Accept}
|
||||||
|
require.False(t, disabled.ipv6Unavailable(icmpv6),
|
||||||
|
"an icmpv6 rule must not be blocked by the IPv6 gate")
|
||||||
|
}
|
||||||
|
|
||||||
|
// csf.deny encodes no action of its own, so a rule added with Action Drop must be
|
||||||
|
// found and removed by the same Drop rule rather than leaking.
|
||||||
|
func TestCSFDropRuleRemovable(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "csf.deny")
|
||||||
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
||||||
|
fw := new(CSF)
|
||||||
|
drop := &Rule{Proto: TCP, Port: 3306, Source: "1.2.3.4", Action: Drop}
|
||||||
|
|
||||||
|
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false))
|
||||||
|
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, false))
|
||||||
|
require.NoError(t, fw.EditIPList(context.Background(), path, Reject, drop, true))
|
||||||
|
data, _ := os.ReadFile(path)
|
||||||
|
require.NotContains(t, string(data), "1.2.3.4", "a Drop rule must be removable by the same Drop rule")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ported csf advanced rule that matches both a source and a destination address
|
||||||
|
// cannot be expressed (a csf advanced rule holds a single address field) and must
|
||||||
|
// be rejected rather than silently dropping the destination. The portless bare
|
||||||
|
// form is not tested here: AddRule diverts it to the raw-iptables hook
|
||||||
|
// (hostNeedsHook), so the writer never sees it.
|
||||||
|
func TestCSFDualAddressRejected(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
_, err := fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept})
|
||||||
|
require.Error(t, err, "a dual-address csf advanced rule must be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// csf's port lists are TCP_IN/UDP_IN; a concrete non-tcp/udp protocol (sctp) would
|
||||||
|
// wrongly be written into both, so it must error. ProtocolAny is allowed (it maps
|
||||||
|
// to both lists as the faithful "any" expansion).
|
||||||
|
func TestCSFPortProtoGuard(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
require.Error(t, fw.checkPortProto(&Rule{Proto: SCTP, Port: 80, Action: Accept}), "sctp port must be rejected")
|
||||||
|
require.NoError(t, fw.checkPortProto(&Rule{Port: 80, Action: Accept}), "ProtocolAny port is allowed for csf")
|
||||||
|
require.NoError(t, fw.checkPortProto(&Rule{Proto: TCP, Port: 80, Action: Accept}))
|
||||||
|
require.NoError(t, fw.checkPortProto(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// csf.conf's CONNLIMIT is a single dual-stack config key (it caps both v4 and v6
|
||||||
|
// connections; there is no v6 variant to merge), so a connection-limit rule read
|
||||||
|
// from it must be FamilyAny — not IPv4 — or a FamilyAny desired connlimit rule
|
||||||
|
// (the natural shape: no address, so no family is implied) never matches its own
|
||||||
|
// read-back and Sync removes-and-re-adds it every reconcile, firing csf -r each
|
||||||
|
// time. This mirrors the APF behavior above.
|
||||||
|
func TestCSFConnLimitFamilyIsAny(t *testing.T) {
|
||||||
|
// csf.pl only installs the ip6tables CONNLIMIT rule when csf.conf's IPV6 is
|
||||||
|
// enabled; only then does a dual-stack FamilyAny read-back (and the
|
||||||
|
// FamilyAny-desired-rule match below) hold. See TestCSFConnLimitFamily for
|
||||||
|
// the IPV6-disabled (stock default) case, where CONNLIMIT is IPv4-only.
|
||||||
|
f := &CSF{ipv6Enabled: true}
|
||||||
|
rules := f.ParseConnLimit("80;20")
|
||||||
|
require.Len(t, rules, 1)
|
||||||
|
require.Equal(t, FamilyAny, rules[0].Family,
|
||||||
|
"a dual-stack CONNLIMIT entry must read back as FamilyAny when csf.conf IPV6 is on")
|
||||||
|
|
||||||
|
desired := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 20, PerSource: true}}
|
||||||
|
require.True(t, desired.Equal(rules[0], true),
|
||||||
|
"FamilyAny connlimit must equal the CSF read-back or Sync churns")
|
||||||
|
}
|
||||||
|
|
||||||
|
// csf.redirect only expresses a DNAT with a concrete matched destination and
|
||||||
|
// either both a matched and target port or neither. MarshalNATRule must reject
|
||||||
|
// the shapes csf would refuse rather than emit a line that aborts the redirect
|
||||||
|
// load — a round-trip test alone would not catch it.
|
||||||
|
func TestCSFDNATMarshalRejectsUnexpressible(t *testing.T) {
|
||||||
|
f := new(CSF)
|
||||||
|
bad := []struct {
|
||||||
|
name string
|
||||||
|
rule *NATRule
|
||||||
|
}{
|
||||||
|
{"empty destination (ipx=*)", &NATRule{Kind: DNAT, Proto: TCP, Port: 666, ToAddress: "10.0.0.1", ToPort: 25}},
|
||||||
|
{"matched port but no target port", &NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", Port: 80, ToAddress: "5.6.7.8"}},
|
||||||
|
{"target port but no matched port", &NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", ToAddress: "5.6.7.8", ToPort: 8080}},
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
_, err := f.MarshalNATRule(c.rule)
|
||||||
|
require.Errorf(t, err, "csf.redirect should reject %s", c.name)
|
||||||
|
// The rejection must carry ErrUnsupportedNAT so callers (and the integration
|
||||||
|
// harness's roundTripNATVariants) treat the shape as unexpressible and skip
|
||||||
|
// it rather than seeing a hard error.
|
||||||
|
require.ErrorIsf(t, err, ErrUnsupportedNAT, "%s should be reported as unsupported NAT", c.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two shapes csf accepts still marshal: a full-IP forward (no ports) and a
|
||||||
|
// concrete port forward.
|
||||||
|
full, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", ToAddress: "5.6.7.8"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "1.2.3.4|*|5.6.7.8|*|tcp", full)
|
||||||
|
fwd, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Destination: "1.2.3.4", Port: 80, ToAddress: "5.6.7.8", ToPort: 8080})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "1.2.3.4|80|5.6.7.8|8080|tcp", fwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCSFBareProtocolRoutesToHook guards that a rule with no port and no address
|
||||||
|
// (a bare protocol match) is injected through the pre-hook rather than rejected or
|
||||||
|
// silently written nowhere. csf keys every native rule on a port or an address, so
|
||||||
|
// such a rule maps to no csf construct; iptables expresses it directly, so addRule
|
||||||
|
// diverts it to the hook (bareProtoNeedsHook). ICMP keeps its own handling.
|
||||||
|
func TestCSFBareProtocolRoutesToHook(t *testing.T) {
|
||||||
|
for _, r := range []*Rule{
|
||||||
|
{Proto: TCP, Action: Accept},
|
||||||
|
{Proto: ProtocolAny, Action: Accept},
|
||||||
|
{Proto: UDP, Action: Drop},
|
||||||
|
} {
|
||||||
|
require.True(t, bareProtoNeedsHook(r),
|
||||||
|
"a portless, addressless rule must route to the hook, not be rejected: %+v", r)
|
||||||
|
}
|
||||||
|
require.False(t, bareProtoNeedsHook(&Rule{Proto: ICMP, Action: Accept}),
|
||||||
|
"an ICMP rule keeps its own handling and is excluded from the bare-protocol hook route")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port-only reject (no address) must be written so csf actually enforces it.
|
||||||
|
// csf's advanced-rule handler only emits an iptables rule when the line carries a
|
||||||
|
// source/destination IP alongside the port, so a bare "d=80" was parsed by csf
|
||||||
|
// and then silently never applied — the port stayed open while the library
|
||||||
|
// reported it blocked. The rule must be written with the "any" network as the
|
||||||
|
// address (so csf enforces it) and must still round-trip and remove: parseAddr
|
||||||
|
// normalizes the "any" network back to an empty address, and mergeFamilies
|
||||||
|
// collapses the v4/v6 pair a family-neutral rule writes.
|
||||||
|
func TestCSFPortOnlyRejectRoundTrip(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
for _, rule := range []*Rule{
|
||||||
|
{Action: Reject, Proto: TCP, Port: 80},
|
||||||
|
{Action: Reject, Proto: TCP, Port: 80, Family: IPv4},
|
||||||
|
{Action: Reject, Proto: TCP, Port: 8080, Family: IPv6},
|
||||||
|
{Action: Reject, Proto: TCP, Port: 443, Direction: DirOutput},
|
||||||
|
} {
|
||||||
|
deny := filepath.Join(t.TempDir(), "csf.deny")
|
||||||
|
require.NoError(t, os.WriteFile(deny, nil, 0o644))
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, deny, Reject, rule, false))
|
||||||
|
|
||||||
|
// The written line must carry an address, or csf never applies it.
|
||||||
|
raw, err := os.ReadFile(deny)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, strings.Contains(string(raw), "0.0.0.0/0") || strings.Contains(string(raw), "::/0"),
|
||||||
|
"port-only reject (%s) must be written with an address so csf enforces it; got:\n%s", rule.Family, raw)
|
||||||
|
|
||||||
|
// GetRules applies mergeFamilies to the parsed list; mirror it here.
|
||||||
|
got, err := fw.ParseIPList(deny, Reject)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got = mergeFamilies(got)
|
||||||
|
require.Len(t, got, 1, "port-only reject (%s) must round-trip to one rule", rule.Family)
|
||||||
|
require.True(t, rule.Equal(got[0], false), "read-back rule must equal the written one: %+v", got[0])
|
||||||
|
|
||||||
|
// It must also be removable (matched back on delete).
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, deny, Reject, rule, true))
|
||||||
|
got, err = fw.ParseIPList(deny, Reject)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 0, "rule (%s) must be fully removed", rule.Family)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare all-protocol host rule (address, no port) is the one portless address
|
||||||
|
// shape csf.allow/csf.deny express, written as the plain address line. The
|
||||||
|
// inexpressible shapes — a concrete-protocol host or a source+destination pair —
|
||||||
|
// are diverted to the hook by AddRule (hostNeedsHook) and never reach this
|
||||||
|
// writer, so only the legitimate write is exercised here.
|
||||||
|
func TestCSFBareHostWritten(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
list := filepath.Join(t.TempDir(), "csf.allow")
|
||||||
|
require.NoError(t, os.WriteFile(list, nil, 0o644))
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, list, Accept, &Rule{Source: "1.2.3.4", Action: Accept}, false))
|
||||||
|
got, err := os.ReadFile(list)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(got), "1.2.3.4", "an any-protocol host rule must be written as a plain address")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port-only "any"-source deny is written to csf.deny as a family-specific
|
||||||
|
// placeholder line (0.0.0.0/0 for IPv4, ::/0 for IPv6). The two lines cover
|
||||||
|
// different families, so adding the IPv6 twin while the IPv4 line already exists
|
||||||
|
// must write it — advMatch matches with EqualForDedup, so without the family
|
||||||
|
// coverage gate the IPv6 add was silently dropped as a false duplicate, leaving
|
||||||
|
// IPv6 open and making Sync churn forever.
|
||||||
|
func TestCSFCrossFamilyAdvDenyBothWritten(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fw := new(CSF)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "csf.deny")
|
||||||
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
||||||
|
|
||||||
|
v4 := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop}
|
||||||
|
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 80, Action: Drop}
|
||||||
|
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, v4, false))
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, v6, false))
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
text := string(data)
|
||||||
|
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"), "IPv4 deny line must be present")
|
||||||
|
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=::/0"), "IPv6 deny line must be present, not dropped as a false duplicate")
|
||||||
|
|
||||||
|
// Removing only the IPv6 twin must leave the IPv4 line intact (family-scoped
|
||||||
|
// removal must not delete the other family's line).
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, v6, true))
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
text = string(data)
|
||||||
|
require.Equal(t, 1, strings.Count(text, "tcp|in|d=80|s=0.0.0.0/0"), "removing IPv6 must not drop the IPv4 line")
|
||||||
|
require.Equal(t, 0, strings.Count(text, "tcp|in|d=80|s=::/0"), "the IPv6 line must be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A FamilyAny port-only deny writes both placeholder lines and must still be
|
||||||
|
// idempotent on re-add and fully removable — the EqualForDedup/EqualForRemoval
|
||||||
|
// gate in advMatch must not disturb the FamilyAny case.
|
||||||
|
func TestCSFFamilyAnyAdvDenyRoundTrip(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fw := new(CSF)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "csf.deny")
|
||||||
|
require.NoError(t, os.WriteFile(path, nil, 0644))
|
||||||
|
|
||||||
|
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Drop}
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
||||||
|
// Re-add is idempotent.
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=22|s=0.0.0.0/0"))
|
||||||
|
require.Equal(t, 1, strings.Count(string(data), "tcp|in|d=22|s=::/0"))
|
||||||
|
// One removal clears both family lines.
|
||||||
|
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, true))
|
||||||
|
data, err = os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, string(data), "d=22", "a FamilyAny removal must clear both placeholder lines")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCSFMergeProtocolsRespectsFamily guards mergeProtocols against collapsing a
|
||||||
|
// tcp/udp pair that differ in IP family. CSF expresses IPv4 and IPv6 opens through
|
||||||
|
// separate config keys (TCP_IN vs TCP6_IN), so a `TCP_IN="53"` + `UDP6_IN="53"`
|
||||||
|
// config produces a tcp/IPv4 rule and a udp/IPv6 rule. Those cover different
|
||||||
|
// families and must NOT merge into one ProtocolAny rule — doing so drops the IPv6
|
||||||
|
// coverage from the read-back and makes Sync churn forever.
|
||||||
|
func TestCSFMergeProtocolsRespectsFamily(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
// tcp/IPv4 and udp/IPv6, both port 53 inbound — same in every field but proto
|
||||||
|
// and family.
|
||||||
|
tcpV4 := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}
|
||||||
|
udpV6 := &Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept}
|
||||||
|
|
||||||
|
out := fw.mergeProtocols([]*Rule{tcpV4, udpV6})
|
||||||
|
|
||||||
|
if len(out) != 2 {
|
||||||
|
t.Fatalf("a cross-family tcp/udp pair must not merge: got %d rules %+v, want 2", len(out), out)
|
||||||
|
}
|
||||||
|
// Both families must still be represented.
|
||||||
|
var haveV4, haveV6 bool
|
||||||
|
for _, r := range out {
|
||||||
|
switch r.impliedFamily() {
|
||||||
|
case IPv4:
|
||||||
|
haveV4 = true
|
||||||
|
case IPv6:
|
||||||
|
haveV6 = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !haveV4 || !haveV6 {
|
||||||
|
t.Fatalf("both families must survive: haveV4=%v haveV6=%v (%+v)", haveV4, haveV6, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestCSFMergeProtocolsSameFamily confirms the intended merge still happens for a
|
||||||
|
// same-family tcp/udp pair (the fanned-out form of one ProtocolAny port rule).
|
||||||
|
func TestCSFMergeProtocolsSameFamily(t *testing.T) {
|
||||||
|
fw := new(CSF)
|
||||||
|
tcp := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}
|
||||||
|
udp := &Rule{Family: IPv4, Proto: UDP, Port: 53, Action: Accept}
|
||||||
|
|
||||||
|
out := fw.mergeProtocols([]*Rule{tcp, udp})
|
||||||
|
if len(out) != 1 {
|
||||||
|
t.Fatalf("a same-family tcp/udp pair should merge to one rule: got %d %+v", len(out), out)
|
||||||
|
}
|
||||||
|
if out[0].Proto != ProtocolAny {
|
||||||
|
t.Fatalf("merged rule should be ProtocolAny, got %v", out[0].Proto)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRules reports both the library's own rules and foreign ones, each tagged
|
||||||
|
// with HasPrefix and with the configured prefix stripped from the surfaced comment.
|
||||||
|
func TestCSFHasPrefixFlag(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "csf.allow")
|
||||||
|
fw := &CSF{rulePrefix: "myapp"}
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(
|
||||||
|
"# myapp web\n"+
|
||||||
|
"tcp|in|d=443|s=192.0.2.10\n"+
|
||||||
|
"\n"+
|
||||||
|
"# hand-added by an admin\n"+
|
||||||
|
"tcp|in|d=22|s=10.0.0.0/24\n",
|
||||||
|
), 0644))
|
||||||
|
|
||||||
|
rules, err := fw.ParseIPList(path, Accept)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rules, 2)
|
||||||
|
|
||||||
|
// Our rule: prefix stripped from the comment, flagged as carrying the prefix.
|
||||||
|
require.Equal(t, "web", rules[0].Comment)
|
||||||
|
require.True(t, rules[0].HasPrefix, "prefixed comment sets HasPrefix")
|
||||||
|
|
||||||
|
// The admin's rule: comment surfaces unchanged, no prefix.
|
||||||
|
require.Equal(t, "hand-added by an admin", rules[1].Comment)
|
||||||
|
require.False(t, rules[1].HasPrefix, "a comment without the prefix is not flagged")
|
||||||
|
}
|
||||||
2268
firewall.go
Normal file
2268
firewall.go
Normal file
File diff suppressed because it is too large
Load diff
1379
firewall_test.go
Normal file
1379
firewall_test.go
Normal file
File diff suppressed because it is too large
Load diff
1611
firewalld_linux.go
Normal file
1611
firewalld_linux.go
Normal file
File diff suppressed because it is too large
Load diff
576
firewalld_linux_test.go
Normal file
576
firewalld_linux_test.go
Normal file
|
|
@ -0,0 +1,576 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
firewalld "github.com/grmrgecko/go-firewalld"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestFirewallDZonePortRulesSkipsUnmodeledProto verifies a zone port on an
|
||||||
|
// unmodeled protocol (dccp) is not surfaced. Such a rule would read back as
|
||||||
|
// ProtocolAny, which RemoveRule and MarshalRichRule reject, so Sync could never
|
||||||
|
// reconcile it away. Modeled protocols (tcp/udp/sctp) still surface normally.
|
||||||
|
func TestFirewallDZonePortRulesSkipsUnmodeledProto(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
ports := []firewalld.Port{
|
||||||
|
{Port: "443", Protocol: "dccp"},
|
||||||
|
{Port: "22", Protocol: "tcp"},
|
||||||
|
{Port: "49152-49215", Protocol: "udp"},
|
||||||
|
}
|
||||||
|
rules := fw.zonePortRules(ports, false)
|
||||||
|
require.Len(t, rules, 2, "the dccp port must be skipped, only tcp/udp surface")
|
||||||
|
require.Equal(t, TCP, rules[0].Proto)
|
||||||
|
require.EqualValues(t, 22, rules[0].Port)
|
||||||
|
require.Equal(t, UDP, rules[1].Proto)
|
||||||
|
require.Equal(t, []PortRange{{Start: 49152, End: 49215}}, rules[1].Ports)
|
||||||
|
|
||||||
|
// Source ports bind to the source-port fields and likewise skip dccp.
|
||||||
|
src := fw.zonePortRules([]firewalld.Port{{Port: "53", Protocol: "dccp"}, {Port: "53", Protocol: "udp"}}, true)
|
||||||
|
require.Len(t, src, 1, "the dccp source port must be skipped")
|
||||||
|
require.EqualValues(t, 53, src[0].SourcePort)
|
||||||
|
require.EqualValues(t, 0, src[0].Port, "a source-port rule must not set the destination port")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirewallDRichRules(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
// Parse a rule that is expected to parse right.
|
||||||
|
rule, err := fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log limit value="1/m" audit accept`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Re-encode the rule which should result in expected rich rule. The log and
|
||||||
|
// rate limit now round-trip (audit is still not modeled and is dropped).
|
||||||
|
richRule, err := fw.MarshalRichRule(rule)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, `rule family="ipv4" source address="192.168.0.0/24" port port="23" protocol="udp" log level="info" accept limit value="1/m"`, richRule,
|
||||||
|
"the rich rule did not encode as expected")
|
||||||
|
|
||||||
|
// Try encoding a bunch of invalid rules.
|
||||||
|
invalidRules := []string{
|
||||||
|
`rule family=ipv4 source address="192.168.0.0/24" service name=ftp reject`,
|
||||||
|
`family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp accept`,
|
||||||
|
`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp`,
|
||||||
|
`rule family="ipv6" source address="1:2:3:4:6::" forward-port to-addr="1::2:3:4:7" to-port="4012" protocol="tcp" port="4011"`,
|
||||||
|
// A port on a protocol this library does not model (dccp) reads back as
|
||||||
|
// ProtocolAny, which MarshalRichRule cannot re-emit, so parsing must reject
|
||||||
|
// it rather than surface a rule Restore would choke on. The same applies to a
|
||||||
|
// port on a modeled but portless protocol (gre) and to a source-port element.
|
||||||
|
`rule family="ipv4" port port="80" protocol="dccp" accept`,
|
||||||
|
`rule family="ipv4" port port="80" protocol="gre" accept`,
|
||||||
|
`rule family="ipv4" source-port port="1024" protocol="dccp" accept`,
|
||||||
|
}
|
||||||
|
for _, richRule := range invalidRules {
|
||||||
|
_, err := fw.UnmarshalRichRule(richRule)
|
||||||
|
require.Error(t, err, "this rich rule was parsed when it should be invalid: %s", richRule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test rules we typically set.
|
||||||
|
validRules := []string{
|
||||||
|
`rule priority="10" family="ipv6" port port="4789" protocol="udp" accept`,
|
||||||
|
`rule priority="10" family="ipv4" source address="67.227.233.116" port port="4789" protocol="tcp" accept`,
|
||||||
|
`rule priority="10" family="ipv4" destination address="67.227.233.116" port port="4791" protocol="tcp" accept`,
|
||||||
|
}
|
||||||
|
for _, richRule := range validRules {
|
||||||
|
_, err := fw.UnmarshalRichRule(richRule)
|
||||||
|
require.NoError(t, err, "this rich rule was not parsed when it should be valid: %s", richRule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port without a concrete protocol cannot be expressed as a rich rule
|
||||||
|
// (protocol="any" is invalid), so marshalling must error rather than emit a
|
||||||
|
// rule firewalld will refuse.
|
||||||
|
_, err = fw.MarshalRichRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept})
|
||||||
|
require.Error(t, err, "expected error marshalling a port with no protocol")
|
||||||
|
|
||||||
|
// firewalld's zone/rich-rule model has no forward chain, so a forward rule is
|
||||||
|
// rejected with the ErrUnsupportedForward sentinel.
|
||||||
|
_, err = fw.MarshalRichRule(&Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected")
|
||||||
|
require.False(t, fw.Capabilities().Forward, "firewalld does not advertise forward support")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirewallDFeatureRules(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
// Confirm representative encodings.
|
||||||
|
cases := []struct {
|
||||||
|
rule *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// A bare (untyped) ICMP/ICMPv6 protocol match needs no family qualifier:
|
||||||
|
// firewalld accepts a familyless `protocol value="ipv6-icmp"` rule just
|
||||||
|
// like any other protocol, and the protocol value string alone tells the
|
||||||
|
// read path ICMP from ICMPv6. An explicit Family is still honored verbatim.
|
||||||
|
{&Rule{Proto: ICMP, Action: Accept}, `rule protocol value="icmp" accept`},
|
||||||
|
{&Rule{Proto: ICMPv6, Action: Accept}, `rule protocol value="ipv6-icmp" accept`},
|
||||||
|
{&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept}, `rule family="ipv6" protocol value="ipv6-icmp" accept`},
|
||||||
|
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, `rule port port="1000-2000" protocol="tcp" accept`},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := fw.MarshalRichRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip ICMP and port-range rules.
|
||||||
|
rules := []*Rule{
|
||||||
|
{Proto: ICMP, Action: Accept},
|
||||||
|
{Family: IPv6, Proto: ICMPv6, Action: Drop},
|
||||||
|
{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
|
||||||
|
{Family: IPv4, Source: "192.168.0.0/24", Proto: UDP, Port: 23, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
rich, err := fw.MarshalRichRule(r)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *r)
|
||||||
|
|
||||||
|
parsed, err := fw.UnmarshalRichRule(rich)
|
||||||
|
require.NoError(t, err, "failed to parse %q", rich)
|
||||||
|
require.True(t, parsed.Equal(r, false),
|
||||||
|
"round-trip mismatch: input %+v, rich %q, output %+v", *r, rich, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features a rich rule cannot express are rejected.
|
||||||
|
unsupported := []*Rule{
|
||||||
|
{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
|
||||||
|
{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range unsupported {
|
||||||
|
_, err := fw.MarshalRichRule(r)
|
||||||
|
require.Error(t, err, "expected error marshalling unsupported rule %+v", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirewallDICMPType(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
// A specific ICMP type encodes to an icmp-type element, resolved by family:
|
||||||
|
// echo-request is type 8 for IPv4 and 128 for IPv6, but the same firewalld
|
||||||
|
// name is used for both.
|
||||||
|
cases := []struct {
|
||||||
|
rule *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
`rule family="ipv4" icmp-type name="echo-request" accept`},
|
||||||
|
{&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
|
||||||
|
`rule family="ipv6" icmp-type name="echo-request" accept`},
|
||||||
|
{&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](136), Action: Drop},
|
||||||
|
`rule family="ipv6" icmp-type name="neighbour-advertisement" drop`},
|
||||||
|
// The ICMP protocol pins the family, so an unset Family is derived from it
|
||||||
|
// (ICMP => ipv4) rather than rejected.
|
||||||
|
{&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
`rule family="ipv4" icmp-type name="echo-request" accept`},
|
||||||
|
// ICMPv6 => ipv6.
|
||||||
|
{&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
|
||||||
|
`rule family="ipv6" icmp-type name="echo-request" accept`},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := fw.MarshalRichRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
||||||
|
|
||||||
|
parsed, err := fw.UnmarshalRichRule(got)
|
||||||
|
require.NoError(t, err, "failed to parse %q", got)
|
||||||
|
require.True(t, parsed.Equal(c.rule, false),
|
||||||
|
"round-trip mismatch: input %+v, rich %q, output %+v", *c.rule, got, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rules a firewalld icmp-type element cannot express are rejected.
|
||||||
|
unsupported := []*Rule{
|
||||||
|
// A type with no firewalld name in that family cannot be expressed.
|
||||||
|
{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](200), Action: Accept},
|
||||||
|
// echo-request is 128 in IPv6; the IPv4 number 8 has no IPv6 name.
|
||||||
|
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
// An ICMP type only applies to an ICMP/ICMPv6 protocol.
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 80, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range unsupported {
|
||||||
|
_, err := fw.MarshalRichRule(r)
|
||||||
|
require.Error(t, err, "expected error marshalling %+v", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewalld's destination grammar accepts an ipset (like the source), so a rule
|
||||||
|
// matching a destination ipset must marshal to `destination ipset="..."` and
|
||||||
|
// round-trip, rather than being rejected or read back as invisible. Unlike a
|
||||||
|
// source ipset, firewalld rejects a familyless destination ipset with
|
||||||
|
// MISSING_FAMILY (verified against the real firewalld.core.rich parser), so the
|
||||||
|
// caller must supply a concrete Family.
|
||||||
|
func TestFirewallDDestinationIPSet(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
rule := &Rule{Family: IPv4, Destination: "myset", Proto: TCP, Port: 443, Action: Accept}
|
||||||
|
got, err := fw.MarshalRichRule(rule)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, got, `destination ipset="myset"`, "unexpected marshal: %q", got)
|
||||||
|
|
||||||
|
parsed, err := fw.UnmarshalRichRule(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "myset", parsed.Destination)
|
||||||
|
require.True(t, parsed.IsOutput(), "a destination match is an output rule")
|
||||||
|
|
||||||
|
// A negated destination ipset round-trips too.
|
||||||
|
neg := &Rule{Family: IPv4, Destination: "!badset", Proto: TCP, Port: 443, Action: Drop}
|
||||||
|
got, err = fw.MarshalRichRule(neg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, got, `destination NOT ipset="badset"`, "unexpected marshal: %q", got)
|
||||||
|
parsed, err = fw.UnmarshalRichRule(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "!badset", parsed.Destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A destination ipset with no explicit Family is rejected rather than marshaled
|
||||||
|
// into a rule firewalld refuses at apply time. A source ipset, by contrast,
|
||||||
|
// needs no family.
|
||||||
|
func TestFirewallDDestinationIPSetRequiresFamily(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
_, err := fw.MarshalRichRule(&Rule{Destination: "myset", Proto: TCP, Port: 443, Action: Accept})
|
||||||
|
require.Error(t, err, "a familyless destination ipset must be rejected")
|
||||||
|
|
||||||
|
got, err := fw.MarshalRichRule(&Rule{Source: "myset", Proto: TCP, Port: 443, Action: Accept})
|
||||||
|
require.NoError(t, err, "a familyless source ipset is valid and must not be rejected")
|
||||||
|
require.Contains(t, got, `source ipset="myset"`, "unexpected marshal: %q", got)
|
||||||
|
require.NotContains(t, got, "family=", "a familyless source ipset must not gain a family attribute")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFirewallDSourcePort(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
// Source-port matches encode to the source-port rich-rule element.
|
||||||
|
cases := []struct {
|
||||||
|
rule *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{&Rule{Proto: TCP, SourcePort: 1024, Action: Accept},
|
||||||
|
`rule source-port port="1024" protocol="tcp" accept`},
|
||||||
|
{&Rule{Family: IPv4, Proto: TCP, SourcePort: 1024, Action: Accept},
|
||||||
|
`rule family="ipv4" source-port port="1024" protocol="tcp" accept`},
|
||||||
|
{&Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
|
||||||
|
`rule source-port port="1000-2000" protocol="udp" accept`},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := fw.MarshalRichRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
||||||
|
|
||||||
|
parsed, err := fw.UnmarshalRichRule(got)
|
||||||
|
require.NoError(t, err, "failed to parse %q", got)
|
||||||
|
require.True(t, parsed.Equal(c.rule, false),
|
||||||
|
"round-trip mismatch: input %+v, rich %q, output %+v", *c.rule, got, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewalld rich rules carry a single port element, and a source-port takes a
|
||||||
|
// single port or range — so these cannot be expressed.
|
||||||
|
unsupported := []*Rule{
|
||||||
|
// A destination port and a source port cannot coexist in one rule.
|
||||||
|
{Proto: TCP, Port: 80, SourcePort: 1024, Action: Accept},
|
||||||
|
// A source-port list is not expressible.
|
||||||
|
{Proto: TCP, SourcePorts: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
|
||||||
|
// A source port needs a concrete tcp/udp protocol.
|
||||||
|
{SourcePort: 1024, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range unsupported {
|
||||||
|
_, err := fw.MarshalRichRule(r)
|
||||||
|
require.Error(t, err, "expected error marshalling %+v", *r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The two source-port-specific rejections report the source-port sentinel.
|
||||||
|
_, err := fw.MarshalRichRule(&Rule{Proto: TCP, Port: 80, SourcePort: 1024, Action: Accept})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedSourcePort)
|
||||||
|
_, err = fw.MarshalRichRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedSourcePort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A concrete-family bare-port accept is stored as a rich rule (zoneEntryEligible
|
||||||
|
// requires FamilyAny), so a v4/v6 pair becomes two rich rules that GetRules merges
|
||||||
|
// into one FamilyAny rule. RemoveRule must locate that merged rule against the
|
||||||
|
// stored rich rules with EqualForRemoval — the family-strict Equal it used
|
||||||
|
// before matched neither, so the port stayed open. Regression for the firewalld
|
||||||
|
// merged-family remove no-op surfaced by the integration suite.
|
||||||
|
func TestFirewallDMergedRichRuleRemovable(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
v4, err := fw.UnmarshalRichRule(`rule family="ipv4" port port="3492" protocol="tcp" accept`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
v6, err := fw.UnmarshalRichRule(`rule family="ipv6" port port="3492" protocol="tcp" accept`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
merged := mergeFamiliesCopy([]*Rule{v4, v6})
|
||||||
|
require.Len(t, merged, 1, "the v4/v6 rich-rule twin must collapse into one rule")
|
||||||
|
m := merged[0]
|
||||||
|
require.Equal(t, FamilyAny, m.impliedFamily())
|
||||||
|
|
||||||
|
// The old family-strict matcher found neither stored rich rule.
|
||||||
|
require.False(t, m.Equal(v4, false))
|
||||||
|
require.False(t, m.Equal(v6, false))
|
||||||
|
|
||||||
|
// The new matcher (EqualForRemoval) finds both, so RemoveRule clears both rich
|
||||||
|
// rules for a merged read-back rule.
|
||||||
|
require.True(t, v4.EqualForRemoval(m, false))
|
||||||
|
require.True(t, v6.EqualForRemoval(m, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rich rule cannot express a specific rate-limit burst, but the netfilter default
|
||||||
|
// burst (5) is treated as "unset" everywhere else in the library and reads back as 0
|
||||||
|
// from nft/iptables. MarshalRichRule must therefore accept a Burst=5 rule (rendering
|
||||||
|
// the bare rate) instead of rejecting it — otherwise a desired set that is portable
|
||||||
|
// across nft/iptables/firewalld aborts Sync on firewalld alone. Regression for the
|
||||||
|
// raw-burst guard that ignored normBurst.
|
||||||
|
func TestFirewallDRateLimitDefaultBurstAccepted(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
// Burst=5 (the netfilter default) must marshal to the bare rate and round-trip.
|
||||||
|
def := &Rule{Proto: TCP, Port: 22, Action: Accept,
|
||||||
|
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: netfilterDefaultBurst}}
|
||||||
|
got, err := fw.MarshalRichRule(def)
|
||||||
|
require.NoError(t, err, "a default-burst rate limit must be accepted")
|
||||||
|
require.Contains(t, got, `limit value="10/m"`)
|
||||||
|
|
||||||
|
// It round-trips: the read-back rule (Burst 0) still equals the desired rule, so
|
||||||
|
// Sync does not churn.
|
||||||
|
parsed, err := fw.UnmarshalRichRule(got)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, parsed.Equal(def, false),
|
||||||
|
"a default-burst rule must equal its read-back so Sync is stable")
|
||||||
|
|
||||||
|
// A genuinely non-default burst is still unexpressible and rejected.
|
||||||
|
_, err = fw.MarshalRichRule(&Rule{Proto: TCP, Port: 22, Action: Accept,
|
||||||
|
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 20}})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedRateLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A FamilyAny rule that matches a concrete IP address must be marshaled with a
|
||||||
|
// family attribute: firewalld requires one whenever a rich rule uses an address
|
||||||
|
// (it rejects a familyless address rule) and stores the rule under that family.
|
||||||
|
// The rule must then still reconcile against firewalld's family-normalized
|
||||||
|
// read-back under the family-sensitive Equal that Sync/RemoveRule use.
|
||||||
|
func TestFirewallDFamilyAnyAddressGetsFamily(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
orig := &Rule{Source: "10.0.0.1", Proto: TCP, Port: 22, Action: Accept}
|
||||||
|
rich, err := fw.MarshalRichRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, rich, `family="ipv4"`, "a rich rule with an IP address must declare its family")
|
||||||
|
|
||||||
|
// firewalld lists the rule back with the family it stored it under.
|
||||||
|
canon := `rule family="ipv4" source address="10.0.0.1" port port="22" protocol="tcp" accept`
|
||||||
|
got, err := fw.UnmarshalRichRule(canon)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, orig.Equal(got, false),
|
||||||
|
"a FamilyAny address rule must reconcile with its family-normalized read-back")
|
||||||
|
|
||||||
|
// A bare (addressless) rule stays unqualified — firewalld applies it to both
|
||||||
|
// families and needs no family attribute.
|
||||||
|
bare, err := fw.MarshalRichRule(&Rule{Proto: TCP, Port: 22, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, bare, "family=", "an addressless rule must not be pinned to a family")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A log prefix (or address) containing a space must survive the rich-rule parse,
|
||||||
|
// which needs a quote-aware tokenizer.
|
||||||
|
func TestFirewallDLogPrefixWithSpaces(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "drop ssh"}
|
||||||
|
rich, err := fw.MarshalRichRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, err := fw.UnmarshalRichRule(rich)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got.Log)
|
||||||
|
require.Equal(t, "drop ssh", got.LogPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// lone range must take the zone-entry (AddPort/RemovePort) path — only a genuine
|
||||||
|
// multi-element list forces the rich-rule path. Gating on HasPortSet (true for a
|
||||||
|
// single range) left a foreign zone-port range unremovable, so Sync could never
|
||||||
|
// converge on it.
|
||||||
|
func TestFirewalldZoneEntryEligibleRange(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
eligible := []struct {
|
||||||
|
name string
|
||||||
|
rule *Rule
|
||||||
|
}{
|
||||||
|
{"single port", &Rule{Proto: TCP, Port: 22, Action: Accept}},
|
||||||
|
{"single port in slice", &Rule{Proto: TCP, Ports: []PortRange{{Start: 22, End: 22}}, Action: Accept}},
|
||||||
|
{"single contiguous range", &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}},
|
||||||
|
{"single source-port range", &Rule{Proto: UDP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}},
|
||||||
|
{"bare source", &Rule{Source: "10.0.0.0/24", Action: Accept}},
|
||||||
|
}
|
||||||
|
for _, c := range eligible {
|
||||||
|
require.Truef(t, fw.zoneEntryEligible(c.rule), "%s must use the zone-entry path", c.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
ineligible := []struct {
|
||||||
|
name string
|
||||||
|
rule *Rule
|
||||||
|
}{
|
||||||
|
{"multi-port list", &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}},
|
||||||
|
{"port list with a range", &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 1000, End: 2000}}, Action: Accept}},
|
||||||
|
{"multi source-port list", &Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80, End: 80}, {Start: 90, End: 90}}, Action: Accept}},
|
||||||
|
{"drop action", &Rule{Proto: TCP, Port: 22, Action: Drop}},
|
||||||
|
{"logging", &Rule{Proto: TCP, Port: 22, Action: Accept, Log: true}},
|
||||||
|
{"rate limit", &Rule{Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}},
|
||||||
|
{"concrete family", &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}},
|
||||||
|
{"icmp", &Rule{Proto: ICMP, Action: Accept}},
|
||||||
|
}
|
||||||
|
for _, c := range ineligible {
|
||||||
|
require.Falsef(t, fw.zoneEntryEligible(c.rule), "%s must use the rich-rule path", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFirewallDBareICMPFamilyPinned guards that a bare (untyped) ICMP/ICMPv6 rule
|
||||||
|
// is left unqualified by family and still round-trips correctly. Live testing
|
||||||
|
// against firewalld 2.4.3 confirmed it accepts a familyless rich rule carrying
|
||||||
|
// `protocol value="ipv6-icmp"` just like any other protocol (its rich.py check()
|
||||||
|
// only requires a family for a source/destination address, never for a bare
|
||||||
|
// protocol match), and the protocol value string alone ("icmp" vs "ipv6-icmp")
|
||||||
|
// tells the read path ICMP from ICMPv6 without needing a family qualifier.
|
||||||
|
func TestFirewallDBareICMPFamilyPinned(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
v6, err := fw.MarshalRichRule(&Rule{Proto: ICMPv6, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, v6, `family=`, "a bare icmpv6 rule needs no family qualifier")
|
||||||
|
require.Contains(t, v6, `value="ipv6-icmp"`)
|
||||||
|
|
||||||
|
v4, err := fw.MarshalRichRule(&Rule{Proto: ICMP, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, v4, `family=`, "a bare icmp rule needs no family qualifier")
|
||||||
|
|
||||||
|
// The rich rule must round-trip back to an equal rule.
|
||||||
|
parsed, err := fw.UnmarshalRichRule(v6)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, parsed.Equal(&Rule{Proto: ICMPv6, Action: Accept}, false),
|
||||||
|
"the familyless icmpv6 rich rule should round-trip to the original rule")
|
||||||
|
|
||||||
|
// An explicit Family is still honored verbatim.
|
||||||
|
explicit, err := fw.MarshalRichRule(&Rule{Family: IPv6, Proto: ICMPv6, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, explicit, `family="ipv6"`, "an explicit Family must still be emitted")
|
||||||
|
|
||||||
|
// A *typed* ICMP match still needs the family qualifier: firewalld resolves an
|
||||||
|
// icmp-type name without a protocol element, so this library's own read path
|
||||||
|
// depends on the family to disambiguate an ICMPv4 name from the identically
|
||||||
|
// named ICMPv6 one.
|
||||||
|
typed, err := fw.MarshalRichRule(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, typed, `family="ipv6"`, "a typed icmpv6 rule must still pin family=ipv6")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFirewallDSourceProtoNotZoneSource guards that a source combined with a
|
||||||
|
// concrete protocol is NOT routed to a bare zone source (which would drop the
|
||||||
|
// protocol and widen the rule to every protocol). Such a rule must take the
|
||||||
|
// rich-rule path, which preserves the protocol match.
|
||||||
|
func TestFirewallDSourceProtoNotZoneSource(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
// A plain source with no protocol is a zone source.
|
||||||
|
require.True(t, fw.sourceZoneShape(&Rule{Source: "1.2.3.4", Action: Accept}),
|
||||||
|
"a bare source with no protocol should map to a zone source")
|
||||||
|
|
||||||
|
// A source with a concrete protocol must not: it is a rich rule.
|
||||||
|
require.False(t, fw.sourceZoneShape(&Rule{Source: "1.2.3.4", Proto: TCP, Action: Accept}),
|
||||||
|
"a source+protocol rule must not be encoded as a bare zone source")
|
||||||
|
|
||||||
|
// A negated source is a rich rule too.
|
||||||
|
require.False(t, fw.sourceZoneShape(&Rule{Source: "!1.2.3.4", Action: Accept}))
|
||||||
|
|
||||||
|
// The rich rule that AddRule now falls through to keeps the protocol match.
|
||||||
|
rich, err := fw.MarshalRichRule(&Rule{Source: "1.2.3.4", Proto: TCP, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, rich, `source address="1.2.3.4"`)
|
||||||
|
require.Contains(t, rich, `protocol value="tcp"`, "the protocol match must survive on the rich-rule path")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A firewalld rich rule limit is a bare rate with no burst allowance. A non-zero
|
||||||
|
// Burst must be rejected rather than silently dropped: a dropped burst reads back
|
||||||
|
// as 0, so Rule.Equal never matches the desired rule and Sync churns forever. A
|
||||||
|
// zero Burst (backend default) round-trips.
|
||||||
|
func TestFirewalldRateBurstRejected(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
r0 := &Rule{Action: Accept, Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}}
|
||||||
|
rr, err := fw.MarshalRichRule(r0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
back, err := fw.UnmarshalRichRule(rr)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, r0.Equal(back, false), "burst-0 rate limit must round-trip; got %+v", back.RateLimit)
|
||||||
|
|
||||||
|
rb := &Rule{Action: Accept, Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 20}}
|
||||||
|
_, err = fw.MarshalRichRule(rb)
|
||||||
|
require.Error(t, err, "a non-zero burst must be rejected, not silently dropped")
|
||||||
|
require.True(t, errors.Is(err, ErrUnsupportedRateLimit), "error should wrap ErrUnsupportedRateLimit, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewalld expresses the portless protocols as a bare protocol element and
|
||||||
|
// SCTP as a port protocol; both round-trip through a rich rule.
|
||||||
|
func TestFirewallDProtocolExtras(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
cases := []*Rule{
|
||||||
|
{Proto: GRE, Action: Accept},
|
||||||
|
{Proto: ESP, Action: Accept},
|
||||||
|
{Family: IPv4, Source: "192.168.0.0/24", Proto: SCTP, Port: 9000, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, orig := range cases {
|
||||||
|
rich, err := fw.MarshalRichRule(orig)
|
||||||
|
require.NoError(t, err, "%+v", orig)
|
||||||
|
got, err := fw.UnmarshalRichRule(rich)
|
||||||
|
require.NoError(t, err, "rich %q", rich)
|
||||||
|
require.True(t, got.EqualBase(orig, true), "rich %q: want %+v got %+v", rich, orig, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardPort builds a firewalld ForwardPort from a NAT rule and rejects the
|
||||||
|
// shapes firewalld's forward-port model cannot express.
|
||||||
|
func TestFWForwardPort(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
// DNAT to another host: ToAddr is set.
|
||||||
|
fp, err := fw.forwardPort(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, firewalld.ForwardPort{Port: "80", Protocol: "tcp", ToAddr: "10.0.0.5", ToPort: "8080"}, fp)
|
||||||
|
|
||||||
|
// Redirect to a local port: ToAddr is empty.
|
||||||
|
fp, err = fw.forwardPort(&NATRule{Kind: Redirect, Proto: UDP, Port: 53, ToPort: 5353})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, firewalld.ForwardPort{Port: "53", Protocol: "udp", ToAddr: "", ToPort: "5353"}, fp)
|
||||||
|
|
||||||
|
// A single contiguous range is allowed (a list is not, below).
|
||||||
|
fp, err = fw.forwardPort(&NATRule{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, ToAddress: "10.0.0.5", ToPort: 3000})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "1000-2000", fp.Port)
|
||||||
|
|
||||||
|
bad := []struct {
|
||||||
|
name string
|
||||||
|
rule *NATRule
|
||||||
|
}{
|
||||||
|
{"non tcp/udp", &NATRule{Kind: DNAT, Proto: ICMP, Port: 80, ToAddress: "10.0.0.5"}},
|
||||||
|
{"no port", &NATRule{Kind: DNAT, Proto: TCP, ToAddress: "10.0.0.5"}},
|
||||||
|
{"port list", &NATRule{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, ToAddress: "10.0.0.5", ToPort: 8080}},
|
||||||
|
{"interface", &NATRule{Kind: DNAT, Proto: TCP, Port: 80, Interface: "eth0", ToAddress: "10.0.0.5", ToPort: 8080}},
|
||||||
|
{"source match", &NATRule{Kind: DNAT, Proto: TCP, Port: 80, Source: "1.2.3.4", ToAddress: "10.0.0.5", ToPort: 8080}},
|
||||||
|
}
|
||||||
|
for _, c := range bad {
|
||||||
|
_, err := fw.forwardPort(c.rule)
|
||||||
|
require.Errorf(t, err, "forwardPort should reject %s", c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// firewalld's ICMPv6 icmp-type table was missing destination-unreachable (type
|
||||||
|
// 1), which real firewalld defines for both ipv4 and ipv6. A rich rule using that
|
||||||
|
// name on IPv6 failed to resolve on read and was silently dropped from
|
||||||
|
// GetRules/Backup (and unmarshalling failed). It must round-trip.
|
||||||
|
func TestFirewalldICMPv6DestinationUnreachable(t *testing.T) {
|
||||||
|
fw := new(FirewallD)
|
||||||
|
|
||||||
|
n, ok := fw.icmpTypeNumber(true, "destination-unreachable")
|
||||||
|
require.True(t, ok, "destination-unreachable must be a known ICMPv6 type")
|
||||||
|
require.Equal(t, uint8(1), n, "ICMPv6 destination-unreachable is type 1")
|
||||||
|
|
||||||
|
r := &Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](1), Action: Reject}
|
||||||
|
rr, err := fw.MarshalRichRule(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, rr, "destination-unreachable", "rich rule should carry the type name")
|
||||||
|
back, err := fw.UnmarshalRichRule(rr)
|
||||||
|
require.NoError(t, err, "an icmpv6 destination-unreachable rich rule must parse")
|
||||||
|
require.True(t, r.Equal(back, false), "icmpv6 destination-unreachable must round-trip: got %+v", back)
|
||||||
|
}
|
||||||
26
go.mod
Normal file
26
go.mod
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
module github.com/grmrgecko/go-firewall
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
replace github.com/coreos/go-systemd => github.com/coreos/go-systemd/v22 v22.5.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
|
||||||
|
github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf
|
||||||
|
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb
|
||||||
|
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847
|
||||||
|
github.com/stretchr/testify v1.11.1
|
||||||
|
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
|
github.com/go-ole/go-ole v1.2.6 // indirect
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||||
|
github.com/google/cabbie v1.0.2 // indirect
|
||||||
|
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d // indirect
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
|
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e // indirect
|
||||||
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
)
|
||||||
120
go.sum
Normal file
120
go.sum
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
bitbucket.org/creachadair/stringset v0.0.9/go.mod h1:t+4WcQ4+PXTa8aQdNKe40ZP6iwesoMFWAxPGd3UGjyY=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/StackExchange/wmi v1.2.0/go.mod h1:3eOhrUMpNV+6aFIbp5/iudMxNCF27Vw2OZgy4xEx0Fg=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||||
|
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||||
|
github.com/capnspacehook/taskmaster v0.0.0-20210519235353-1629df7c85e9/go.mod h1:257CYs3Wd/CTlLQ3c72jKv+fFE2MV3WPNnV5jiroYUU=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/creachadair/staticfile v0.1.3/go.mod h1:a3qySzCIXEprDGxk6tSxSI+dBBdLzqeBOMhZ+o2d3pM=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||||
|
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||||
|
github.com/go-ole/go-ole v1.2.4/go.mod h1:XCwSNxSkXRo4vlyPy93sltvi/qJq0jqQhjqQNIwKuxM=
|
||||||
|
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
|
||||||
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
|
github.com/godbus/dbus v4.1.0+incompatible/go.mod h1:/YcGZj5zSblfDWMMoOzV4fas9FZnQYTkDnsGvmh2Grw=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||||
|
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||||
|
github.com/golang/glog v0.0.0-20210429001901-424d2337a529/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/google/aukera v0.0.0-20201117230544-d145c8357fea/go.mod h1:oXqTZORBzdwQ6L32YjJmaPajqIV/hoGEouwpFMf4cJE=
|
||||||
|
github.com/google/cabbie v1.0.2 h1:UtB+Nn6fPB43wGg5xs4tgU+P3hTZ6KsulgtaHtqZZfs=
|
||||||
|
github.com/google/cabbie v1.0.2/go.mod h1:6MmHaUrgfabehCHAIaxdrbmvHSxUVXj3Abs08FMABSo=
|
||||||
|
github.com/google/glazier v0.0.0-20210617205946-bf91b619f5d4/go.mod h1:g7oyIhindbeebnBh0hbFua5rv6XUt/nweDwIWdvxirg=
|
||||||
|
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d h1:GBIF4RkD4E9USvSRT4O4tBCT77JExIr+qnruI9nkJQo=
|
||||||
|
github.com/google/glazier v0.0.0-20211029225403-9f766cca891d/go.mod h1:h2R3DLUecGbLSyi6CcxBs5bdgtJhgK+lIffglvAcGKg=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/logger v1.1.0/go.mod h1:w7O8nrRr0xufejBlQMI83MXqRusvREoJdaAxV+CoAB4=
|
||||||
|
github.com/google/logger v1.1.1/go.mod h1:BkeJZ+1FhQ+/d087r4dzojEg1u2ZX+ZqG1jTUrLM+zQ=
|
||||||
|
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||||
|
github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/google/winops v0.0.0-20210803215038-c8511b84de2b/go.mod h1:ShbX8v8clPm/3chw9zHVwtW3QhrFpL8mXOwNxClt4pg=
|
||||||
|
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb h1:2wDo4vmBRWk2n3W5EsEpMQ2t8Sx0diVXdZjJTlLCBzc=
|
||||||
|
github.com/grmrgecko/go-firewalld v0.0.0-20260702144632-5eb6ba8201bb/go.mod h1:PrxtlI/xoBCOT8ugAoxeuE++VGq/D7jxbz5URoeV7ow=
|
||||||
|
github.com/groob/plist v0.0.0-20210519001750-9f754062e6d6/go.mod h1:itkABA+w2cw7x5nYUS/pLRef6ludkZKOigbROmCTaFw=
|
||||||
|
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||||
|
github.com/iamacarpet/go-win64api v0.0.0-20210311141720-fe38760bed28/go.mod h1:oGJx9dz0Ny7HC7U55RZ0Smd6N9p3hXP/+hOFtuYrAxM=
|
||||||
|
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847 h1:cRHZFGwIDgQlr9abL/P93JXR7pYxzvf0xAIt0xzwrh0=
|
||||||
|
github.com/iamacarpet/go-win64api v0.0.0-20240507095429-873e84e85847/go.mod h1:B7zFQPAznj+ujXel5X+LUoK3LgY6VboCdVYHZNn7gpg=
|
||||||
|
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||||
|
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||||
|
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||||
|
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||||
|
github.com/onsi/gomega v1.10.2/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/rickb777/date v1.14.2/go.mod h1:swmf05C+hN+m8/Xh7gEq3uB6QJDNc5pQBWojKdHetOs=
|
||||||
|
github.com/rickb777/plural v1.2.2/go.mod h1:xyHbelv4YvJE51gjMnHvk+U2e9zIysg6lTnSQK8XUYA=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e h1:+/AzLkOdIXEPrAQtwAeWOBnPQ0BnYlBW0aCZmSb47u4=
|
||||||
|
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e/go.mod h1:9Tc1SKnfACJb9N7cw2eyuI6xzy845G7uZONBsi5uPEA=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
go4.org/intern v0.0.0-20211027215823-ae77deb06f29 h1:UXLjNohABv4S58tHmeuIZDO6e3mHpW2Dx33gaNt03LE=
|
||||||
|
go4.org/intern v0.0.0-20211027215823-ae77deb06f29/go.mod h1:cS2ma+47FKrLPdXFpr7CuxiTW3eyJbWew4qx0qtQWDA=
|
||||||
|
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf h1:IdwJUzqoIo5lkr2EOyKoe5qipUaEjbOKKY5+fzPBZ3A=
|
||||||
|
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf/go.mod h1:+QXzaoURFd0rGDIjDNpyIkv+F9R7EmeKorvlKRnhqgA=
|
||||||
|
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760 h1:FyBZqvoA/jbNzuAWLQE2kG820zMAkcilx6BMjGbL/E4=
|
||||||
|
go4.org/unsafe/assume-no-moving-gc v0.0.0-20220617031537-928513b29760/go.mod h1:FftLjUGFEDu5k8lt0ddY+HcrH/qU/0qk+H8j9/nTl3E=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||||
|
golang.org/x/net v0.0.0-20201006153459-a7d1128ccaa0/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200622182413-4b0db7f3f76b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210601080250-7ecdf8ef093b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20211107104306-e0b2ad06fe42/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
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/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||||
|
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||||
|
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
393
hooks_linux.go
Normal file
393
hooks_linux.go
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/anmitsu/go-shlex"
|
||||||
|
)
|
||||||
|
|
||||||
|
// hookScript injects raw iptables/ip6tables commands into a CSF or APF hook that
|
||||||
|
// the firewall sources at (re)load time. It lets those backends express filter
|
||||||
|
// rules their native config cannot — connection-state, per-rule interface,
|
||||||
|
// logging, rate limiting, ICMPv6, and the transport protocols SCTP, GRE, ESP and
|
||||||
|
// AH — by reusing the iptables rule marshaller/parser and writing the resulting
|
||||||
|
// commands directly into the firewall's documented hook.
|
||||||
|
//
|
||||||
|
// The command lines live in the hook file itself (hookPath) rather than in a
|
||||||
|
// separate library-owned script: edit touches only the exact command lines it
|
||||||
|
// manages, leaving any user-authored hook content in place, and getRules parses
|
||||||
|
// every iptables/ip6tables line the hook carries. Reading foreign, user-authored
|
||||||
|
// hook rules is intended — the library manages the actual firewall state, and
|
||||||
|
// HasPrefix (derived from the rule's comment tag) is the only signal of what it
|
||||||
|
// created.
|
||||||
|
type hookScript struct {
|
||||||
|
// rulePrefix tags each injected rule with an iptables comment so it can be
|
||||||
|
// told apart from other rules.
|
||||||
|
rulePrefix string
|
||||||
|
// hookPath is the firewall hook this library writes its command lines into. It
|
||||||
|
// runs before the firewall adds its own rules, so injected rules sit at the top
|
||||||
|
// of the INPUT/OUTPUT chains.
|
||||||
|
hookPath string
|
||||||
|
// hookPerm is the mode the hook file must carry to be executed (0700 for CSF,
|
||||||
|
// 0750 for APF).
|
||||||
|
hookPerm os.FileMode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleNeedsHook reports whether a rule requires a feature that CSF/APF cannot
|
||||||
|
// express in their native config and so must be injected as a raw iptables rule
|
||||||
|
// through the hook: a forward-chain (routed) rule, connection-state matching,
|
||||||
|
// per-rule interface matching, logging, rate limiting, ICMPv6, or a transport
|
||||||
|
// protocol their native config does not model (SCTP and the portless IP protocols
|
||||||
|
// GRE, ESP and AH).
|
||||||
|
func ruleNeedsHook(r *Rule) bool {
|
||||||
|
return r.IsForward() || r.State != 0 || r.InInterface != "" || r.OutInterface != "" ||
|
||||||
|
r.Log || r.RateLimit != nil || r.Proto == ICMPv6 || hookOnlyProto(r.Proto)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bareHostShape reports whether a rule has the shape a plain csf.allow/apf
|
||||||
|
// allow_hosts line expresses: exactly one source or destination address, no ports,
|
||||||
|
// and the any-protocol match. Its direction is not considered — a DirAny bare host
|
||||||
|
// is the single bidirectional plain line, while a concrete-direction one is one-way
|
||||||
|
// (see bareHostOneWay). Shared by CSF and APF.
|
||||||
|
func bareHostShape(r *Rule) bool {
|
||||||
|
if r.HasPorts() || r.HasSourcePorts() || r.Proto != ProtocolAny {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return (r.Source != "") != (r.Destination != "")
|
||||||
|
}
|
||||||
|
|
||||||
|
// bareHostOneWay reports whether a rule is a ONE-WAY bare-address host allow/deny:
|
||||||
|
// the bare host shape with a concrete input or output direction. A plain line matches
|
||||||
|
// a host in BOTH directions, and neither backend's advanced-rule format can carry an
|
||||||
|
// address without a port, so a one-way bare host rule is expressed through the
|
||||||
|
// raw-iptables hook instead.
|
||||||
|
func bareHostOneWay(r *Rule) bool {
|
||||||
|
return bareHostShape(r) && (r.Direction == DirInput || r.Direction == DirOutput)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dirAnyPlainLine reports whether a DirAny rule maps to a single bidirectional plain
|
||||||
|
// csf.allow/apf line: a bare host carrying no feature that would force the
|
||||||
|
// raw-iptables hook (connection state, interface, logging, etc.). Every other DirAny
|
||||||
|
// rule fans out into a concrete input rule plus its role-swapped output rule on
|
||||||
|
// add/remove, since csf/apf have no single native both-directions construct for it.
|
||||||
|
func dirAnyPlainLine(r *Rule) bool {
|
||||||
|
return r.Direction == DirAny && bareHostShape(r) && !ruleNeedsHook(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostNeedsHook reports whether an address-bearing rule with no port must be
|
||||||
|
// injected through the raw-iptables hook because a csf/apf trust file has no form
|
||||||
|
// for it: a source+destination pair (both store a single address) or a host
|
||||||
|
// pinned to a concrete tcp/udp protocol (the plain line is all-protocol and the
|
||||||
|
// advanced rule requires a port). Both are expressed natively as an iptables
|
||||||
|
// rule. A single-address all-protocol host is not covered here — a one-way one is
|
||||||
|
// routed by bareHostOneWay, a bidirectional one is a native plain line — and ICMP
|
||||||
|
// keeps its own handling (checkICMP/apfCheckICMP), so it is excluded. Shared by
|
||||||
|
// CSF and APF.
|
||||||
|
func hostNeedsHook(r *Rule) bool {
|
||||||
|
if r.Proto.IsICMP() || r.HasPorts() || r.HasSourcePorts() {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A source+destination pair has no single-address advanced/plain form.
|
||||||
|
if r.Source != "" && r.Destination != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// A single-address host pinned to tcp/udp has no portless form.
|
||||||
|
if r.Source != "" || r.Destination != "" {
|
||||||
|
return r.Proto == TCP || r.Proto == UDP
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// bareProtoNeedsHook reports whether a rule is a bare protocol match — a non-ICMP
|
||||||
|
// transport with no address and no port — that CSF/APF cannot express in their
|
||||||
|
// native config (the trust files key on an address and the conf lists key on a
|
||||||
|
// port or icmp type) but iptables applies directly (`-p tcp -j ACCEPT`, or a bare
|
||||||
|
// `-j ACCEPT`). Such a rule is injected through the raw-iptables hook rather than
|
||||||
|
// rejected. ICMP/ICMPv6 keep their own native/hook handling (checkICMP/apfCheckICMP,
|
||||||
|
// nativeICMPv6, ruleNeedsHook), so they are excluded here. Shared by CSF and APF.
|
||||||
|
func bareProtoNeedsHook(r *Rule) bool {
|
||||||
|
return r.Source == "" && r.Destination == "" && !r.HasPorts() && !r.HasSourcePorts() && !r.Proto.IsICMP()
|
||||||
|
}
|
||||||
|
|
||||||
|
// hookOnlyProto reports whether a protocol has no representation in CSF's or
|
||||||
|
// APF's native config and so can only be applied through the raw-iptables hook.
|
||||||
|
func hookOnlyProto(p Protocol) bool {
|
||||||
|
switch p {
|
||||||
|
case SCTP, GRE, ESP, AH:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// hookRuleProtos lists the transport protocols a rule is written for in the hook.
|
||||||
|
// A ProtocolAny rule that carries a port has no single iptables form — a --dport/
|
||||||
|
// --sport match requires a concrete -p tcp/udp — so it fans out into a tcp line and
|
||||||
|
// a udp line, mirroring the tcp+udp fan-out csf/apf write for a ProtocolAny port
|
||||||
|
// rule in their native config. A portless ProtocolAny rule is a valid protocol-
|
||||||
|
// agnostic match (a bare `-j ACCEPT`), so it keeps its own protocol and is not fanned.
|
||||||
|
func hookRuleProtos(r *Rule) []Protocol {
|
||||||
|
if r.Proto == ProtocolAny && (r.HasPorts() || r.HasSourcePorts()) {
|
||||||
|
return []Protocol{TCP, UDP}
|
||||||
|
}
|
||||||
|
return []Protocol{r.Proto}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hookRuleFamilies lists the address families a rule is written for: a rule
|
||||||
|
// pinned to a family (by address or an ICMP protocol) touches only that command,
|
||||||
|
// a family-agnostic rule (e.g. a bare state match) is written for both v4 and v6.
|
||||||
|
func hookRuleFamilies(r *Rule) []Family {
|
||||||
|
switch r.impliedFamily() {
|
||||||
|
case IPv4:
|
||||||
|
return []Family{IPv4}
|
||||||
|
case IPv6:
|
||||||
|
return []Family{IPv6}
|
||||||
|
default:
|
||||||
|
return []Family{IPv4, IPv6}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hookCommand returns the iptables command name for a family.
|
||||||
|
func hookCommand(fam Family) string {
|
||||||
|
if fam == IPv6 {
|
||||||
|
return "ip6tables"
|
||||||
|
}
|
||||||
|
return "iptables"
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellSafeToken quotes a token so /bin/sh passes it through verbatim. The
|
||||||
|
// iptables marshaller quotes free-text fields (a comment, a log prefix) with
|
||||||
|
// strconv.Quote — Go double-quoting, which is right for an iptables-restore file
|
||||||
|
// but NOT for the hook script, which /bin/sh sources: inside double quotes the
|
||||||
|
// shell still expands $var, $(...) and backticks. A token made of ordinary
|
||||||
|
// argument characters is returned bare for readability; anything else is wrapped
|
||||||
|
// in single quotes (with any embedded single quote escaped), which the shell
|
||||||
|
// treats as a literal. shlex.Split reverses either form on read-back.
|
||||||
|
func shellSafeToken(tok string) string {
|
||||||
|
safe := tok != ""
|
||||||
|
for _, r := range tok {
|
||||||
|
if r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' ||
|
||||||
|
strings.ContainsRune("_./:=,+-@%", r) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
safe = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if safe {
|
||||||
|
return tok
|
||||||
|
}
|
||||||
|
return "'" + strings.ReplaceAll(tok, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// rulesToLines encodes a rule as the raw command line(s) to inject: one iptables
|
||||||
|
// (or ip6tables) command per underlying iptables line and per family. A logged
|
||||||
|
// rule yields a LOG line followed by its action line, as with the iptables
|
||||||
|
// backend. Each marshalled line is re-tokenized and re-quoted shell-safely,
|
||||||
|
// because the hook script is sourced by /bin/sh rather than exec'd argv-style.
|
||||||
|
func (h *hookScript) rulesToLines(r *Rule) ([]string, error) {
|
||||||
|
var out []string
|
||||||
|
for _, proto := range hookRuleProtos(r) {
|
||||||
|
for _, fam := range hookRuleFamilies(r) {
|
||||||
|
rc := *r
|
||||||
|
rc.Proto = proto
|
||||||
|
rc.Family = fam
|
||||||
|
ipt := &IPTables{rulePrefix: h.rulePrefix}
|
||||||
|
base, err := ipt.marshalRuleLines(&rc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
cmd := hookCommand(fam)
|
||||||
|
for _, line := range base {
|
||||||
|
tokens, terr := shlex.Split(line, true)
|
||||||
|
if terr != nil {
|
||||||
|
return nil, terr
|
||||||
|
}
|
||||||
|
for i, t := range tokens {
|
||||||
|
tokens[i] = shellSafeToken(t)
|
||||||
|
}
|
||||||
|
out = append(out, cmd+" "+strings.Join(tokens, " "))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ruleMatchesAny reports whether e — a rule parsed from a hook line — is the same
|
||||||
|
// underlying rule as any of targets, honoring direction (the hook emits explicit
|
||||||
|
// -A INPUT/-A OUTPUT lines) but ignoring the comment, which is not part of rule
|
||||||
|
// identity. It backs comment-agnostic hook removal.
|
||||||
|
func ruleMatchesAny(e *Rule, targets []*Rule) bool {
|
||||||
|
for _, t := range targets {
|
||||||
|
if e.Equal(t, true) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLine decodes an injected command line back into the rule it represents
|
||||||
|
// (one line, so a LOG line yields a rule with Log set and no action), reporting
|
||||||
|
// whether the line is one this backend recognizes.
|
||||||
|
func (h *hookScript) parseLine(line string) (*Rule, bool) {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
var fam Family
|
||||||
|
var rest string
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(line, "iptables "):
|
||||||
|
fam, rest = IPv4, strings.TrimPrefix(line, "iptables ")
|
||||||
|
case strings.HasPrefix(line, "ip6tables "):
|
||||||
|
fam, rest = IPv6, strings.TrimPrefix(line, "ip6tables ")
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
r, err := unmarshalIPTablesRule(rest, fam)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
// The iptables parser captures the prefixed comment; strip the prefix (a tag)
|
||||||
|
// so only the user-facing comment surfaces, and record whether the injected
|
||||||
|
// rule carried the prefix so HasPrefix reflects it just like the iptables
|
||||||
|
// backend.
|
||||||
|
text, hasPrefix := prefixedComment(h.rulePrefix, r.Comment)
|
||||||
|
r.Comment = text
|
||||||
|
r.HasPrefix = hasPrefix
|
||||||
|
return r, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// commandLines returns the iptables/ip6tables command lines currently in the
|
||||||
|
// hook (a missing hook contributes none). Every such line is returned, including
|
||||||
|
// any a user authored by hand, so the library reconciles the hook's real state.
|
||||||
|
func (h *hookScript) commandLines() ([]string, error) {
|
||||||
|
lines, _, err := h.readHookLines()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var cmds []string
|
||||||
|
for _, line := range lines {
|
||||||
|
t := strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(t, "iptables ") || strings.HasPrefix(t, "ip6tables ") {
|
||||||
|
cmds = append(cmds, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cmds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getRules parses the hook into logical rules, coalescing each LOG line with the
|
||||||
|
// action line that follows it. Family merging is left to the caller, which
|
||||||
|
// unions these with the backend's native rules.
|
||||||
|
func (h *hookScript) getRules() ([]*Rule, error) {
|
||||||
|
cmds, err := h.commandLines()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var rules []*Rule
|
||||||
|
for _, line := range cmds {
|
||||||
|
if r, ok := h.parseLine(line); ok {
|
||||||
|
rules = append(rules, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return coalesceLoggedRules(rules), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readHookLines returns the hook's lines and whether the hook file exists. A
|
||||||
|
// single trailing newline is trimmed so a rewrite does not accrue a blank line;
|
||||||
|
// a missing hook yields no lines.
|
||||||
|
func (h *hookScript) readHookLines() ([]string, bool, error) {
|
||||||
|
data, err := os.ReadFile(h.hookPath)
|
||||||
|
if err != nil {
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
content := strings.TrimSuffix(string(data), "\n")
|
||||||
|
if content == "" {
|
||||||
|
return nil, true, nil
|
||||||
|
}
|
||||||
|
return strings.Split(content, "\n"), true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeHook atomically replaces the hook with lines, giving a freshly created
|
||||||
|
// hook a shebang, and keeps it executable so the firewall can source it.
|
||||||
|
func (h *hookScript) writeHook(lines []string, existed bool) error {
|
||||||
|
var b strings.Builder
|
||||||
|
if !existed {
|
||||||
|
b.WriteString("#!/bin/sh\n")
|
||||||
|
}
|
||||||
|
for _, l := range lines {
|
||||||
|
b.WriteString(l)
|
||||||
|
b.WriteByte('\n')
|
||||||
|
}
|
||||||
|
// Preserve an existing hook's mode and ownership; a freshly created hook gets
|
||||||
|
// the executable hookPerm so the firewall can source it.
|
||||||
|
if err := writeConfigFile(h.hookPath, []byte(b.String()), h.hookPerm); err != nil {
|
||||||
|
return fmt.Errorf("failed to move updated hook into place: %s", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// edit adds or removes a rule's command line(s) directly in the hook. Adding
|
||||||
|
// matches on the exact deterministic line the marshaller emits, so the library's
|
||||||
|
// own tagged line is written once and re-adds are idempotent. Removal instead
|
||||||
|
// matches on the underlying rule: a line is dropped when the rule it encodes is
|
||||||
|
// the same as one r resolves to, ignoring the comment tag, so a copy of the rule a
|
||||||
|
// customer added under a different comment (or none) is cleared too — the comment
|
||||||
|
// is not part of rule identity. A logged rule's LOG and action lines are matched
|
||||||
|
// independently, exactly as they were written. It preserves every other hook
|
||||||
|
// line — user-authored shell and rules alike — and reports whether the hook
|
||||||
|
// changed. Adding to an absent hook creates it; removing from one is a no-op.
|
||||||
|
func (h *hookScript) edit(r *Rule, remove bool) (bool, error) {
|
||||||
|
desired, err := h.rulesToLines(r)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
lines, existed, err := h.readHookLines()
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
changed := false
|
||||||
|
if remove {
|
||||||
|
// Parse each desired line back into the rule it encodes; a hook line whose own
|
||||||
|
// rule matches one of these (comment ignored, see Rule.Equal) is dropped. A line
|
||||||
|
// that is not a rule the hook recognizes (foreign shell, a comment) never parses
|
||||||
|
// and is preserved.
|
||||||
|
var targets []*Rule
|
||||||
|
for _, l := range desired {
|
||||||
|
if tr, ok := h.parseLine(l); ok {
|
||||||
|
targets = append(targets, tr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
next := lines[:0:0]
|
||||||
|
for _, l := range lines {
|
||||||
|
if er, ok := h.parseLine(l); ok && ruleMatchesAny(er, targets) {
|
||||||
|
changed = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
next = append(next, l)
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
lines = next
|
||||||
|
} else {
|
||||||
|
present := make(map[string]bool, len(lines))
|
||||||
|
for _, l := range lines {
|
||||||
|
present[strings.TrimSpace(l)] = true
|
||||||
|
}
|
||||||
|
for _, l := range desired {
|
||||||
|
if !present[l] {
|
||||||
|
lines = append(lines, l)
|
||||||
|
present[l] = true
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, h.writeHook(lines, existed)
|
||||||
|
}
|
||||||
448
hooks_linux_test.go
Normal file
448
hooks_linux_test.go
Normal file
|
|
@ -0,0 +1,448 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRuleNeedsHook(t *testing.T) {
|
||||||
|
// Features with no native CSF/APF config path route through the hook.
|
||||||
|
needs := []*Rule{
|
||||||
|
{Proto: TCP, Port: 22, Action: Accept, State: StateNew},
|
||||||
|
{Proto: TCP, Port: 80, Action: Accept, Log: true},
|
||||||
|
{Proto: TCP, Port: 80, Action: Accept, InInterface: "eth0"},
|
||||||
|
{Direction: DirOutput, Proto: TCP, Port: 80, Action: Accept, OutInterface: "eth0"},
|
||||||
|
{Proto: TCP, Port: 25, Action: Drop, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}},
|
||||||
|
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
|
||||||
|
// A forward rule has no native CSF/APF config path, so it routes through the
|
||||||
|
// raw-iptables hook (which emits an -A FORWARD rule).
|
||||||
|
{Direction: DirForward, Proto: TCP, Port: 8080, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range needs {
|
||||||
|
require.True(t, ruleNeedsHook(r), "expected %+v to need the hook", *r)
|
||||||
|
}
|
||||||
|
// Natively expressible rules do not.
|
||||||
|
native := []*Rule{
|
||||||
|
{Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
{Proto: TCP, Port: 22, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}},
|
||||||
|
}
|
||||||
|
for _, r := range native {
|
||||||
|
require.False(t, ruleNeedsHook(r), "expected %+v to stay native", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bareProtoNeedsHook routes a portless, addressless non-ICMP match to the hook —
|
||||||
|
// the shape CSF/APF cannot express natively but iptables applies directly — while
|
||||||
|
// leaving every rule that carries an address, a port, or an ICMP protocol on its
|
||||||
|
// own native/ICMP path.
|
||||||
|
func TestBareProtoNeedsHook(t *testing.T) {
|
||||||
|
// Bare protocol matches with no address and no port go to the hook.
|
||||||
|
needs := []*Rule{
|
||||||
|
{Proto: TCP, Action: Accept},
|
||||||
|
{Proto: UDP, Action: Drop},
|
||||||
|
{Proto: ProtocolAny, Action: Accept},
|
||||||
|
{Proto: TCP, Direction: DirOutput, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range needs {
|
||||||
|
require.True(t, bareProtoNeedsHook(r), "expected %+v to route to the hook", *r)
|
||||||
|
}
|
||||||
|
// A port, an address, or an ICMP protocol keeps the rule off this path.
|
||||||
|
native := []*Rule{
|
||||||
|
{Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
{Proto: TCP, SourcePort: 1234, Action: Accept},
|
||||||
|
{Proto: TCP, Source: "1.2.3.4", Action: Accept},
|
||||||
|
{Proto: ProtocolAny, Destination: "1.2.3.4", Action: Accept},
|
||||||
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
{Proto: ICMPv6, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range native {
|
||||||
|
require.False(t, bareProtoNeedsHook(r), "expected %+v to stay off the hook route", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two rules that are Equal (port-set order is not part of rule identity) must
|
||||||
|
// inject the same command line, so a second add is a no-op and a remove using a
|
||||||
|
// reordered port set still finds the rule. The hook script matches on the exact
|
||||||
|
// marshalled line, so the marshaller must render Equal port sets identically.
|
||||||
|
func TestHookScriptPortOrderIdempotent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &hookScript{
|
||||||
|
rulePrefix: "go_firewall",
|
||||||
|
hookPath: filepath.Join(dir, "csfpre.sh"),
|
||||||
|
hookPerm: 0700,
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCTP has no native CSF/APF config path, so a multi-port SCTP rule routes
|
||||||
|
// through the hook. These two differ only in port order, so they are Equal.
|
||||||
|
a := &Rule{Family: IPv4, Proto: SCTP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}
|
||||||
|
b := &Rule{Family: IPv4, Proto: SCTP, Ports: []PortRange{{Start: 443}, {Start: 80}}, Action: Accept}
|
||||||
|
require.True(t, a.Equal(b, true), "the two rules must be Equal (order-independent)")
|
||||||
|
|
||||||
|
changed, err := h.edit(a, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
|
||||||
|
changed, err = h.edit(b, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, changed, "an Equal rule with reordered ports must not inject a duplicate")
|
||||||
|
|
||||||
|
// Removing via the reordered form must still find and drop the rule.
|
||||||
|
changed, err = h.edit(b, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed, "removing an Equal rule with reordered ports must drop it")
|
||||||
|
|
||||||
|
got, err := h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, got, "the rule must be gone after removal")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A ProtocolAny rule that carries a port has no single iptables form — a --dport
|
||||||
|
// match requires a concrete -p tcp/udp — so the hook fans it out into a tcp line and
|
||||||
|
// a udp line, mirroring the tcp+udp fan-out csf/apf write in their native config.
|
||||||
|
// Both add and remove must fan out and never reject the rule for want of a concrete
|
||||||
|
// protocol. Regression: csf's GetRules merges a same-port TCP_IN/UDP_IN pair back
|
||||||
|
// into one ProtocolAny rule, so a Backup captured it and Restore's hook-copy clear
|
||||||
|
// then failed to marshal it, breaking the whole restore.
|
||||||
|
func TestHookScriptProtocolAnyPortFansOut(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &hookScript{
|
||||||
|
rulePrefix: "go_firewall",
|
||||||
|
hookPath: filepath.Join(dir, "csfpre.sh"),
|
||||||
|
hookPerm: 0700,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding a ProtocolAny port rule injects a tcp line and a udp line.
|
||||||
|
any := &Rule{Family: IPv4, Proto: ProtocolAny, Port: 20, Action: Accept}
|
||||||
|
changed, err := h.edit(any, false)
|
||||||
|
require.NoError(t, err, "a ProtocolAny port rule must marshal, not be rejected")
|
||||||
|
require.True(t, changed)
|
||||||
|
|
||||||
|
got, err := h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2, "a ProtocolAny port rule fans out into a tcp and a udp hook line")
|
||||||
|
protos := map[Protocol]bool{}
|
||||||
|
for _, g := range got {
|
||||||
|
protos[g.Proto] = true
|
||||||
|
}
|
||||||
|
require.True(t, protos[TCP] && protos[UDP], "the fan-out must cover both tcp and udp: %+v", got)
|
||||||
|
|
||||||
|
// Removing the ProtocolAny form clears both concrete copies in one call, without
|
||||||
|
// erroring on the port-without-concrete-protocol shape.
|
||||||
|
changed, err = h.edit(any, true)
|
||||||
|
require.NoError(t, err, "removing a ProtocolAny port rule must not fail to marshal")
|
||||||
|
require.True(t, changed, "the ProtocolAny remove must clear the tcp and udp copies")
|
||||||
|
|
||||||
|
got, err = h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, got, "both fanned-out copies must be gone after the ProtocolAny remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A deny whose action differs from the CSF/APF config's STOP action has no native
|
||||||
|
// form (deny_hosts/csf.deny encode no action of their own), so those backends
|
||||||
|
// inject it through the hook, whose iptables rule carries the exact action. The
|
||||||
|
// hook must marshal and read back the precise action, not coerce it — otherwise a
|
||||||
|
// Reject deny would read back as Drop and churn on every Sync.
|
||||||
|
func TestHookScriptCarriesExactDenyAction(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &hookScript{
|
||||||
|
rulePrefix: "go_firewall",
|
||||||
|
hookPath: filepath.Join(dir, "csfpre.sh"),
|
||||||
|
hookPerm: 0700,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, deny := range []*Rule{
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.31/32", Action: Reject},
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.32/32", Action: Drop},
|
||||||
|
} {
|
||||||
|
changed, err := h.edit(deny, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed, "the deny must be injected: %+v", deny)
|
||||||
|
|
||||||
|
got, err := h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
var match *Rule
|
||||||
|
for _, g := range got {
|
||||||
|
if g.Equal(deny, true) {
|
||||||
|
match = g
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, match, "the deny must read back from the hook: %+v", deny)
|
||||||
|
require.Equal(t, deny.Action, match.Action,
|
||||||
|
"the hook must carry the deny's exact action, not coerce it: %+v", deny)
|
||||||
|
|
||||||
|
changed, err = h.edit(deny, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed, "the deny must be removable: %+v", deny)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hook removal matches on the underlying rule, not the exact command line: a copy
|
||||||
|
// of a rule a customer added under a different comment (or a differently spelled
|
||||||
|
// address) must still be removed, since the comment is not part of rule identity.
|
||||||
|
func TestHookScriptRemoveIgnoresComment(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &hookScript{
|
||||||
|
rulePrefix: "go_firewall",
|
||||||
|
hookPath: filepath.Join(dir, "csfpre.sh"),
|
||||||
|
hookPerm: 0700,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plant a rule the way a customer would: same underlying match, a foreign comment,
|
||||||
|
// and an un-normalized address (no /32). A hookScript with a different prefix marks
|
||||||
|
// it as not ours.
|
||||||
|
foreign := &hookScript{rulePrefix: "acme", hookPath: h.hookPath, hookPerm: 0700}
|
||||||
|
planted := &Rule{Family: IPv4, Proto: TCP, Port: 4567, Source: "192.0.2.60", Action: Accept, Comment: "ticket-42"}
|
||||||
|
changed, err := foreign.edit(planted, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
|
||||||
|
// Remove the same underlying rule with no comment and the normalized address.
|
||||||
|
changed, err = h.edit(&Rule{Family: IPv4, Proto: TCP, Port: 4567, Source: "192.0.2.60/32", Action: Accept}, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed, "a rule with the same match but a different comment must still be removed")
|
||||||
|
|
||||||
|
got, err := h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Empty(t, got, "the customer's differently-commented copy must be gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHookScriptRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
h := &hookScript{
|
||||||
|
rulePrefix: "go_firewall",
|
||||||
|
hookPath: filepath.Join(dir, "csfpre.sh"),
|
||||||
|
hookPerm: 0700,
|
||||||
|
}
|
||||||
|
|
||||||
|
// A family-agnostic rule is injected for both v4 and v6.
|
||||||
|
lines, err := h.rulesToLines(&Rule{Proto: TCP, Port: 8080, Action: Accept, State: StateNew})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, lines, 2)
|
||||||
|
require.True(t, strings.HasPrefix(lines[0], "iptables "), "want iptables line, got %q", lines[0])
|
||||||
|
require.True(t, strings.HasPrefix(lines[1], "ip6tables "), "want ip6tables line, got %q", lines[1])
|
||||||
|
|
||||||
|
// Family-pinned rules covering each non-native feature round-trip through the
|
||||||
|
// hook.
|
||||||
|
rules := []*Rule{
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, State: StateNew | StateEstablished},
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, Log: true, LogPrefix: "web"},
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 443, Action: Accept, InInterface: "eth0"},
|
||||||
|
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 25, Action: Drop, RateLimit: &RateLimit{Rate: 5, Unit: PerMinute, Burst: 3}},
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
changed, err := h.edit(r, false)
|
||||||
|
require.NoError(t, err, "add %+v", *r)
|
||||||
|
require.True(t, changed, "expected add to change the script: %+v", *r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adding again is idempotent.
|
||||||
|
changed, err := h.edit(rules[0], false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, changed, "expected a duplicate add to be a no-op")
|
||||||
|
|
||||||
|
// The command lines live in the hook itself, under a single shebang.
|
||||||
|
hookData, err := os.ReadFile(h.hookPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, 1, strings.Count(string(hookData), "#!/bin/sh"), "hook should carry one shebang")
|
||||||
|
require.Contains(t, string(hookData), "iptables ")
|
||||||
|
|
||||||
|
// Every rule reads back equal (family ignored, as the hook stores per-family).
|
||||||
|
got, err := h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, len(rules))
|
||||||
|
for _, want := range rules {
|
||||||
|
found := false
|
||||||
|
for _, g := range got {
|
||||||
|
if g.EqualBase(want, true) {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.True(t, found, "rule not read back: %+v", *want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The logged rule round-trips with its prefix intact.
|
||||||
|
for _, g := range got {
|
||||||
|
if g.Port == 80 {
|
||||||
|
require.True(t, g.Log, "expected the port 80 rule to be logged")
|
||||||
|
require.Equal(t, "web", g.LogPrefix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing one drops it (both its LOG and action lines) and leaves the rest.
|
||||||
|
changed, err = h.edit(rules[1], true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
got, err = h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, len(rules)-1)
|
||||||
|
for _, g := range got {
|
||||||
|
require.False(t, g.EqualBase(rules[1], true), "removed rule still present")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Removing an absent rule is a no-op.
|
||||||
|
changed, err = h.edit(rules[1], true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, changed, "expected removing an absent rule to be a no-op")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writing command lines into the existing hook must leave user-authored content
|
||||||
|
// untouched: arbitrary shell survives an add and a remove, and an iptables rule a
|
||||||
|
// user added by hand both survives edits and surfaces in getRules (the library
|
||||||
|
// reconciles the hook's actual state, not just the lines it wrote).
|
||||||
|
func TestHookPreservesUserContent(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
hookPath := filepath.Join(dir, "csfpre.sh")
|
||||||
|
userContent := "#!/bin/sh\n" +
|
||||||
|
"# operator's own pre-hook logic\n" +
|
||||||
|
"logger firewall reloading\n" +
|
||||||
|
"iptables -A INPUT -p tcp --dport 2222 -j ACCEPT\n"
|
||||||
|
require.NoError(t, os.WriteFile(hookPath, []byte(userContent), 0700))
|
||||||
|
|
||||||
|
h := &hookScript{rulePrefix: "go_firewall", hookPath: hookPath, hookPerm: 0700}
|
||||||
|
|
||||||
|
// A hand-added iptables rule the library never wrote surfaces in getRules,
|
||||||
|
// reported as foreign (no prefix tag).
|
||||||
|
got, err := h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
require.Equal(t, uint16(2222), got[0].Port)
|
||||||
|
require.False(t, got[0].HasPrefix, "a user-authored rule must read back as foreign")
|
||||||
|
|
||||||
|
// Adding our rule keeps every user line in place.
|
||||||
|
added := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept, State: StateNew}
|
||||||
|
changed, err := h.edit(added, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
data, err := os.ReadFile(hookPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(data), "logger firewall reloading")
|
||||||
|
require.Contains(t, string(data), "iptables -A INPUT -p tcp --dport 2222 -j ACCEPT")
|
||||||
|
require.Equal(t, 1, strings.Count(string(data), "#!/bin/sh"), "must not add a second shebang")
|
||||||
|
|
||||||
|
// Removing our rule leaves the user's shell and rule behind.
|
||||||
|
changed, err = h.edit(added, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed)
|
||||||
|
data, err = os.ReadFile(hookPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(data), "logger firewall reloading")
|
||||||
|
require.Contains(t, string(data), "iptables -A INPUT -p tcp --dport 2222 -j ACCEPT")
|
||||||
|
|
||||||
|
// The user's rule still reads back after our churn.
|
||||||
|
got, err = h.getRules()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
require.Equal(t, uint16(2222), got[0].Port)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A hook line is sourced by /bin/sh, so a comment or log prefix containing $ or a
|
||||||
|
// backtick must be single-quoted (a literal), not left in strconv.Quote's double
|
||||||
|
// quotes where the shell would expand it. And it must still parse back intact.
|
||||||
|
func TestHookShellSafeLogPrefix(t *testing.T) {
|
||||||
|
require.Equal(t, "-A", shellSafeToken("-A"))
|
||||||
|
require.Equal(t, "INPUT", shellSafeToken("INPUT"))
|
||||||
|
require.Equal(t, `'web $USER'`, shellSafeToken("web $USER"))
|
||||||
|
require.Equal(t, `'a'\''b'`, shellSafeToken("a'b"))
|
||||||
|
|
||||||
|
h := &hookScript{rulePrefix: "myapp"}
|
||||||
|
lines, err := h.rulesToLines(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, Log: true, LogPrefix: "drop $x"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
joined := strings.Join(lines, "\n")
|
||||||
|
require.NotContains(t, joined, `"drop $x"`, "a $-bearing prefix must not stay double-quoted for the shell")
|
||||||
|
require.Contains(t, joined, `'drop $x'`)
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, l := range lines {
|
||||||
|
if r, ok := h.parseLine(l); ok && r.Log {
|
||||||
|
require.Equal(t, "drop $x", r.LogPrefix)
|
||||||
|
found = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.True(t, found, "the log line must parse back to the original prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A protocol CSF/APF cannot express natively (SCTP and the portless IP
|
||||||
|
// protocols) is routed through the raw-iptables hook and round-trips there.
|
||||||
|
func TestHookProtocolExtras(t *testing.T) {
|
||||||
|
for _, p := range []Protocol{SCTP, GRE, ESP, AH} {
|
||||||
|
require.True(t, hookOnlyProto(p), "%s should route through the hook", p)
|
||||||
|
require.True(t, ruleNeedsHook(&Rule{Proto: p, Action: Accept}))
|
||||||
|
}
|
||||||
|
require.False(t, hookOnlyProto(TCP))
|
||||||
|
require.False(t, ruleNeedsHook(&Rule{Proto: TCP, Port: 22, Action: Accept}))
|
||||||
|
|
||||||
|
h := &hookScript{hookPath: "/tmp/unused", rulePrefix: "go_firewall"}
|
||||||
|
cases := []*Rule{
|
||||||
|
{Family: IPv4, Proto: GRE, Action: Accept},
|
||||||
|
{Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, orig := range cases {
|
||||||
|
lines, err := h.rulesToLines(orig)
|
||||||
|
require.NoError(t, err, "%+v", orig)
|
||||||
|
require.NotEmpty(t, lines)
|
||||||
|
got, ok := h.parseLine(lines[len(lines)-1])
|
||||||
|
require.True(t, ok, "line %q", lines[len(lines)-1])
|
||||||
|
require.True(t, got.EqualBase(orig, true), "want %+v got %+v", orig, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// bareHostOneWay classifies a one-way bare-address host rule — a single address,
|
||||||
|
// no ports, any-protocol, a concrete direction — which csf/apf must route to the
|
||||||
|
// hook because a plain line is bidirectional and an advanced rule needs a port. A
|
||||||
|
// DirAny bare host (the bidirectional plain line) and any ported or protocol-pinned
|
||||||
|
// rule are excluded.
|
||||||
|
func TestBareHostOneWay(t *testing.T) {
|
||||||
|
yes := []*Rule{
|
||||||
|
{Direction: DirInput, Source: "1.2.3.4", Action: Accept},
|
||||||
|
{Direction: DirOutput, Destination: "1.2.3.4", Action: Accept},
|
||||||
|
{Direction: DirInput, Source: "10.0.0.0/8", Action: Drop},
|
||||||
|
}
|
||||||
|
for _, r := range yes {
|
||||||
|
require.Truef(t, bareHostOneWay(r), "expected one-way bare host: %+v", r)
|
||||||
|
}
|
||||||
|
no := []*Rule{
|
||||||
|
{Direction: DirAny, Source: "1.2.3.4", Action: Accept}, // bidirectional plain line
|
||||||
|
{Direction: DirForward, Source: "1.2.3.4", Action: Accept}, // forward is hooked separately
|
||||||
|
{Direction: DirInput, Source: "1.2.3.4", Proto: TCP, Port: 22, Action: Accept}, // has a port (advanced)
|
||||||
|
{Direction: DirInput, Source: "1.2.3.4", Proto: TCP, Action: Accept}, // pins a protocol
|
||||||
|
{Direction: DirInput, Action: Accept}, // no address
|
||||||
|
{Direction: DirInput, Source: "1.2.3.4", Destination: "5.6.7.8", Action: Accept}, // both addresses
|
||||||
|
}
|
||||||
|
for _, r := range no {
|
||||||
|
require.Falsef(t, bareHostOneWay(r), "expected NOT one-way bare host: %+v", r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hostNeedsHook selects the portless address rules a csf/apf trust file cannot
|
||||||
|
// express — a concrete tcp/udp host or a source+destination pair — so AddRule
|
||||||
|
// diverts them to the raw-iptables hook instead of rejecting them. An all-
|
||||||
|
// protocol single-address host (a native plain line or a bareHostOneWay hook
|
||||||
|
// rule), a port-bearing rule, an address-less rule, and ICMP stay off this path.
|
||||||
|
func TestHostNeedsHook(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rule *Rule
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"tcp host no port", &Rule{Proto: TCP, Source: "1.2.3.4"}, true},
|
||||||
|
{"udp host no port outbound", &Rule{Proto: UDP, Destination: "1.2.3.4", Direction: DirOutput}, true},
|
||||||
|
{"source and destination", &Rule{Source: "1.2.3.4", Destination: "5.6.7.8"}, true},
|
||||||
|
{"source and destination with proto", &Rule{Proto: TCP, Source: "1.2.3.4", Destination: "5.6.7.8"}, true},
|
||||||
|
{"all-protocol single host", &Rule{Source: "1.2.3.4"}, false},
|
||||||
|
{"tcp host with port", &Rule{Proto: TCP, Port: 22, Source: "1.2.3.4"}, false},
|
||||||
|
{"tcp host with source port", &Rule{Proto: TCP, SourcePort: 22, Source: "1.2.3.4"}, false},
|
||||||
|
{"address-less port rule", &Rule{Proto: TCP, Port: 22}, false},
|
||||||
|
{"icmp host", &Rule{Proto: ICMP, Source: "1.2.3.4"}, false},
|
||||||
|
{"icmpv6 source and destination", &Rule{Proto: ICMPv6, Source: "2001:db8::1", Destination: "2001:db8::2"}, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
require.Equal(t, c.want, hostNeedsHook(c.rule), c.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
integration_darwin_test.go
Normal file
19
integration_darwin_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegration runs the capability-driven suite against macOS's pf backend.
|
||||||
|
// macOS cannot be automated in a general VM (it needs Apple hardware), so this is
|
||||||
|
// run manually on a Mac: `sudo go test -tags integration -run TestIntegration`.
|
||||||
|
// The pf backend is shared with FreeBSD, which the VM runner covers automatically.
|
||||||
|
// See integration_test.go for the shared suite and runIntegration.
|
||||||
|
func TestIntegration(t *testing.T) {
|
||||||
|
runIntegration(t, []backendFactory{
|
||||||
|
{"pf", func(ctx context.Context, p string) (Manager, error) { return NewPF(ctx, p) }},
|
||||||
|
})
|
||||||
|
}
|
||||||
16
integration_freebsd_test.go
Normal file
16
integration_freebsd_test.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegration runs the capability-driven suite against FreeBSD's pf backend.
|
||||||
|
// See integration_test.go for the shared suite and runIntegration.
|
||||||
|
func TestIntegration(t *testing.T) {
|
||||||
|
runIntegration(t, []backendFactory{
|
||||||
|
{"pf", func(ctx context.Context, p string) (Manager, error) { return NewPF(ctx, p) }},
|
||||||
|
})
|
||||||
|
}
|
||||||
26
integration_linux_test.go
Normal file
26
integration_linux_test.go
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// linuxBackends lists every Linux backend in the same order NewManager probes them.
|
||||||
|
func linuxBackends() []backendFactory {
|
||||||
|
return []backendFactory{
|
||||||
|
{"firewalld", func(ctx context.Context, p string) (Manager, error) { return NewFirewallD(ctx, p) }},
|
||||||
|
{"ufw", func(ctx context.Context, p string) (Manager, error) { return NewUFW(ctx, p) }},
|
||||||
|
{"csf", func(ctx context.Context, p string) (Manager, error) { return NewCSF(ctx, p) }},
|
||||||
|
{"apf", func(ctx context.Context, p string) (Manager, error) { return NewAPF(ctx, p) }},
|
||||||
|
{"iptables", func(ctx context.Context, p string) (Manager, error) { return NewIPTables(ctx, p) }},
|
||||||
|
{"nft", func(ctx context.Context, p string) (Manager, error) { return NewNFT(ctx, p) }},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegration runs the capability-driven suite against the Linux backends.
|
||||||
|
// See integration_test.go for the shared suite and runIntegration.
|
||||||
|
func TestIntegration(t *testing.T) {
|
||||||
|
runIntegration(t, linuxBackends())
|
||||||
|
}
|
||||||
1848
integration_test.go
Normal file
1848
integration_test.go
Normal file
File diff suppressed because it is too large
Load diff
19
integration_windows_test.go
Normal file
19
integration_windows_test.go
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegration runs the capability-driven suite against the Windows Firewall
|
||||||
|
// (WFP) backend. It needs Administrator privileges and the Windows API, so it runs
|
||||||
|
// inside a Windows VM (see test/integration/host-windows-vm.sh) or manually on a
|
||||||
|
// Windows host: `go test -tags integration -run TestIntegration` from an elevated
|
||||||
|
// prompt. See integration_test.go for the shared suite and runIntegration.
|
||||||
|
func TestIntegration(t *testing.T) {
|
||||||
|
runIntegration(t, []backendFactory{
|
||||||
|
{"wf", func(ctx context.Context, p string) (Manager, error) { return NewWF(ctx, p) }},
|
||||||
|
})
|
||||||
|
}
|
||||||
3162
iptables_linux.go
Normal file
3162
iptables_linux.go
Normal file
File diff suppressed because it is too large
Load diff
1801
iptables_linux_test.go
Normal file
1801
iptables_linux_test.go
Normal file
File diff suppressed because it is too large
Load diff
17
manager_darwin.go
Normal file
17
manager_darwin.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewManager gets a firewall manager for this macOS host.
|
||||||
|
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
|
||||||
|
// macOS uses pf (Packet Filter) for its network firewall.
|
||||||
|
pf, err := NewPF(ctx, rulePrefix)
|
||||||
|
if err == nil {
|
||||||
|
return pf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no firewall manager found")
|
||||||
|
}
|
||||||
17
manager_freebsd.go
Normal file
17
manager_freebsd.go
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewManager gets a firewall manager for this FreeBSD host.
|
||||||
|
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
|
||||||
|
// FreeBSD uses pf (Packet Filter) for its network firewall.
|
||||||
|
pf, err := NewPF(ctx, rulePrefix)
|
||||||
|
if err == nil {
|
||||||
|
return pf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no firewall manager found")
|
||||||
|
}
|
||||||
51
manager_linux.go
Normal file
51
manager_linux.go
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewManager gets a firewall manager for this server. The context bounds the
|
||||||
|
// detection probes (each shells out or opens a D-Bus/systemd connection).
|
||||||
|
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
|
||||||
|
// First check for firewalld as there are no firewall managers that use it.
|
||||||
|
firewalld, err := NewFirewallD(ctx, rulePrefix)
|
||||||
|
if err == nil {
|
||||||
|
return firewalld, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Ubuntu's Uncomplicated Firewall (UFW) is installed.
|
||||||
|
ufw, err := NewUFW(ctx, rulePrefix)
|
||||||
|
if err == nil {
|
||||||
|
return ufw, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if ConfigServer Security & Firewall is installed.
|
||||||
|
csf, err := NewCSF(ctx, rulePrefix)
|
||||||
|
if err == nil {
|
||||||
|
return csf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if Advanced Policy Firewall is installed.
|
||||||
|
apf, err := NewAPF(ctx, rulePrefix)
|
||||||
|
if err == nil {
|
||||||
|
return apf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// After checking common firewall managers that use iptables, try iptables.
|
||||||
|
iptables, err := NewIPTables(ctx, rulePrefix)
|
||||||
|
if err == nil {
|
||||||
|
return iptables, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// As a last resort, manage nftables directly. This is tried last so that a
|
||||||
|
// higher-level manager (which may itself be backed by nftables) is preferred
|
||||||
|
// when one is present.
|
||||||
|
nft, err := NewNFT(ctx, rulePrefix)
|
||||||
|
if err == nil {
|
||||||
|
return nft, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no manager found via the known managers, fail.
|
||||||
|
return nil, fmt.Errorf("no firewall manager found")
|
||||||
|
}
|
||||||
16
manager_windows.go
Normal file
16
manager_windows.go
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
|
||||||
|
// Attempt to connect to WFP.
|
||||||
|
wf, err := NewWF(ctx, rulePrefix)
|
||||||
|
if err == nil {
|
||||||
|
return wf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("no firewall manager found")
|
||||||
|
}
|
||||||
2222
nft_linux.go
Normal file
2222
nft_linux.go
Normal file
File diff suppressed because it is too large
Load diff
661
nft_linux_test.go
Normal file
661
nft_linux_test.go
Normal file
|
|
@ -0,0 +1,661 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNFTRules(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
|
||||||
|
// Marshal a representative rule and confirm the expression.
|
||||||
|
chain, expr, err := fw.MarshalRule(&Rule{
|
||||||
|
Family: IPv4,
|
||||||
|
Source: "192.168.0.0/24",
|
||||||
|
Port: 23,
|
||||||
|
Proto: UDP,
|
||||||
|
Action: Accept,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "input", chain, "expected input chain")
|
||||||
|
require.Equal(t, "ip saddr 192.168.0.0/24 udp dport 23 counter accept", expr, "unexpected expression")
|
||||||
|
|
||||||
|
// The listing form (as produced by `nft -a`) must parse back to an
|
||||||
|
// equivalent rule, including the trailing handle.
|
||||||
|
rule, handle, err := fw.UnmarshalRule("ip saddr 192.168.0.0/24 udp dport 23 accept # handle 4", "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "4", handle, "expected handle 4")
|
||||||
|
want := &Rule{Family: IPv4, Source: "192.168.0.0/24", Port: 23, Proto: UDP, Action: Accept}
|
||||||
|
require.True(t, rule.Equal(want, true), "parsed rule does not match: got %+v", rule)
|
||||||
|
|
||||||
|
// Round-trip the rules we typically set across both directions and families.
|
||||||
|
rules := []*Rule{
|
||||||
|
{Family: IPv4, Port: 4789, Proto: UDP, Action: Accept},
|
||||||
|
{Direction: DirOutput, Family: IPv6, Port: 4789, Proto: UDP, Action: Accept},
|
||||||
|
{Family: IPv4, Source: "67.227.233.116", Port: 4789, Proto: TCP, Action: Accept},
|
||||||
|
{Direction: DirOutput, Family: IPv4, Destination: "67.227.233.116", Port: 4791, Proto: TCP, Action: Reject},
|
||||||
|
{Family: IPv6, Source: "!2001:db8::1", Action: Drop},
|
||||||
|
// A non-address Source/Destination names a set, referenced as @name.
|
||||||
|
{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop},
|
||||||
|
{Direction: DirOutput, Family: IPv6, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
chain, expr, err := fw.MarshalRule(r)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *r)
|
||||||
|
|
||||||
|
parsed, _, err := fw.UnmarshalRule(expr, chain)
|
||||||
|
require.NoError(t, err, "failed to parse %q", expr)
|
||||||
|
require.True(t, parsed.Equal(r, true),
|
||||||
|
"round-trip mismatch: input %+v, expr %q, output %+v", *r, expr, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-address Source/Destination is emitted as an nftables named-set reference
|
||||||
|
// (@name), negation included, and parses back to the bare set name.
|
||||||
|
func TestNFTSetReference(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
|
||||||
|
_, expr, err := fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, expr, "ip saddr @blocklist")
|
||||||
|
|
||||||
|
_, expr6, err := fw.MarshalRule(&Rule{Family: IPv6, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, expr6, "ip6 daddr != @allowlist")
|
||||||
|
|
||||||
|
got, _, err := fw.UnmarshalRule("ip saddr @blocklist tcp dport 22 counter drop # handle 7", "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "blocklist", got.Source)
|
||||||
|
|
||||||
|
gotNeg, _, err := fw.UnmarshalRule("ip6 daddr != @allowlist tcp dport 80 counter accept # handle 8", "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "!allowlist", gotNeg.Destination)
|
||||||
|
|
||||||
|
// A port without a concrete protocol cannot be expressed in nftables.
|
||||||
|
_, _, err = fw.MarshalRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept})
|
||||||
|
require.Error(t, err, "expected error marshalling a port with no protocol")
|
||||||
|
}
|
||||||
|
|
||||||
|
// nft rejects an unqualified `dnat to`/`snat to` in an inet table unless the rule
|
||||||
|
// already carries a same-family address match, so MarshalNATRule must emit the
|
||||||
|
// `ip`/`ip6` qualifier the read path (UnmarshalNATRule) already consumes.
|
||||||
|
func TestNFTNATFamilyQualifier(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
|
||||||
|
// A plain IPv4 port-forward: no address match, so the qualifier is required.
|
||||||
|
chain, expr, err := fw.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "prerouting", chain)
|
||||||
|
require.Contains(t, expr, "dnat ip to 192.168.1.2", "DNAT must carry the ip qualifier: %q", expr)
|
||||||
|
|
||||||
|
// Interface-only IPv4 SNAT.
|
||||||
|
_, expr, err = fw.MarshalNATRule(&NATRule{Kind: SNAT, Proto: TCP, Interface: "eth0", ToAddress: "1.2.3.4"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, expr, "snat ip to 1.2.3.4", "SNAT must carry the ip qualifier: %q", expr)
|
||||||
|
|
||||||
|
// IPv6 DNAT to an address:port emits ip6 and bracketed target.
|
||||||
|
_, expr, err = fw.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, expr, "dnat ip6 to [2001:db8::1]:8080", "IPv6 DNAT must carry the ip6 qualifier: %q", expr)
|
||||||
|
|
||||||
|
// Every kind must round-trip back to an equal rule through the list form.
|
||||||
|
for _, r := range []*NATRule{
|
||||||
|
{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2"},
|
||||||
|
{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.168.1.2", ToPort: 8080},
|
||||||
|
{Kind: SNAT, Proto: TCP, Interface: "eth0", ToAddress: "1.2.3.4"},
|
||||||
|
{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080},
|
||||||
|
{Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080},
|
||||||
|
{Kind: Masquerade, Interface: "eth0"},
|
||||||
|
} {
|
||||||
|
chain, expr, err := fw.MarshalNATRule(r)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *r)
|
||||||
|
parsed, _, err := fw.UnmarshalNATRule(expr, chain)
|
||||||
|
require.NoError(t, err, "failed to parse %q", expr)
|
||||||
|
require.True(t, parsed.EqualBase(r), "round-trip mismatch: input %+v, expr %q, output %+v", *r, expr, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNFTFeatureRules(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
|
||||||
|
// Confirm a few representative encodings.
|
||||||
|
cases := []struct {
|
||||||
|
rule *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{&Rule{Proto: ICMP, Action: Accept}, "meta l4proto icmp counter accept"},
|
||||||
|
{&Rule{Proto: ICMPv6, Action: Accept}, "meta l4proto icmpv6 counter accept"},
|
||||||
|
{&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, "icmp type 8 counter accept"},
|
||||||
|
{&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept}, "icmpv6 type 135 counter accept"},
|
||||||
|
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, "tcp dport {80,443} counter accept"},
|
||||||
|
{&Rule{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, "udp dport 1000-2000 counter accept"},
|
||||||
|
{&Rule{Proto: TCP, Port: 22, State: StateEstablished | StateRelated, Action: Accept}, "tcp dport 22 ct state {established,related} counter accept"},
|
||||||
|
{&Rule{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept}, `iifname "eth0" tcp dport 22 counter accept`},
|
||||||
|
{&Rule{Direction: DirOutput, OutInterface: "eth1", Action: Drop}, `oifname "eth1" counter drop`},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
_, expr, err := fw.MarshalRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, expr, "marshal %+v", *c.rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip every new-feature rule shape.
|
||||||
|
rules := []*Rule{
|
||||||
|
{Proto: ICMP, Action: Accept},
|
||||||
|
{Proto: ICMPv6, Action: Accept},
|
||||||
|
{Family: IPv4, Proto: ICMP, Action: Accept},
|
||||||
|
{Family: IPv6, Proto: ICMPv6, Action: Drop},
|
||||||
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
|
||||||
|
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}, {Start: 8000, End: 8100}}, Action: Accept},
|
||||||
|
{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
|
||||||
|
{Proto: TCP, Port: 22, State: StateNew | StateEstablished, Action: Accept},
|
||||||
|
{Family: IPv4, Source: "10.0.0.0/8", Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
|
||||||
|
{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
{Direction: DirOutput, OutInterface: "eth1", Proto: UDP, Port: 53, Action: Accept},
|
||||||
|
// A forward rule may match both an ingress and an egress interface.
|
||||||
|
{Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
chain, expr, err := fw.MarshalRule(r)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *r)
|
||||||
|
|
||||||
|
parsed, _, err := fw.UnmarshalRule(expr, chain)
|
||||||
|
require.NoError(t, err, "failed to parse %q", expr)
|
||||||
|
require.True(t, parsed.Equal(r, true),
|
||||||
|
"round-trip mismatch: input %+v, expr %q, output %+v", *r, expr, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forward rule marshals into the forward base chain and matches both interfaces.
|
||||||
|
chain, expr, err := fw.MarshalRule(&Rule{Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "forward", chain, "a forward rule targets the forward chain")
|
||||||
|
require.Equal(t, `iifname "eth0" oifname "eth1" counter accept`, expr)
|
||||||
|
|
||||||
|
// An input interface cannot be matched on an output rule and vice versa.
|
||||||
|
_, _, err = fw.MarshalRule(&Rule{Direction: DirOutput, InInterface: "eth0", Action: Accept})
|
||||||
|
require.Error(t, err, "expected error matching an input interface on an output rule")
|
||||||
|
_, _, err = fw.MarshalRule(&Rule{OutInterface: "eth0", Action: Accept})
|
||||||
|
require.Error(t, err, "expected error matching an output interface on an input rule")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A listed rule carries `counter packets N bytes M`; the parser must capture the
|
||||||
|
// counters onto the rule, while they remain outside rule identity.
|
||||||
|
func TestNFTCounters(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
r, _, err := f.UnmarshalRule("tcp dport 22 counter packets 42 bytes 336 accept # handle 7", "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, uint64(42), r.Packets)
|
||||||
|
require.Equal(t, uint64(336), r.Bytes)
|
||||||
|
require.Equal(t, Accept, r.Action)
|
||||||
|
// Counters are not part of rule identity.
|
||||||
|
require.True(t, r.EqualBase(&Rule{Proto: TCP, Port: 22, Action: Accept}, true),
|
||||||
|
"counters must not be part of rule identity: %+v", r)
|
||||||
|
|
||||||
|
// A rule without counters parses with zero counters.
|
||||||
|
r2, _, err := f.UnmarshalRule("tcp dport 22 accept # handle 1", "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Zero(t, r2.Packets)
|
||||||
|
require.Zero(t, r2.Bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNFTSetSpec(t *testing.T) {
|
||||||
|
f := new(NFT)
|
||||||
|
spec, err := f.setSpec(IPv4, SetHashIP)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "{ type ipv4_addr ; }", spec)
|
||||||
|
spec, err = f.setSpec(IPv6, SetHashNet)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "{ type ipv6_addr ; flags interval ; }", spec)
|
||||||
|
// FamilyAny resolves to IPv4.
|
||||||
|
spec, err = f.setSpec(FamilyAny, SetHashIP)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "{ type ipv4_addr ; }", spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// collapseSetSpaces must not collapse the spaces inside brace-like content
|
||||||
|
// that lives inside a quoted comment (it is not a real anonymous set), while
|
||||||
|
// still collapsing an actual anonymous set match outside any quote. nft's
|
||||||
|
// quoting has no backslash-escape mechanism: a bare `"` always toggles the
|
||||||
|
// quoted state.
|
||||||
|
func TestNFTCollapseSetSpacesQuotedComment(t *testing.T) {
|
||||||
|
f := new(NFT)
|
||||||
|
in := `tcp dport { 22, 80 } counter accept comment "a b { c } d" # handle 5`
|
||||||
|
// The real anonymous set collapses; the quoted comment (braces and spaces
|
||||||
|
// included) is preserved verbatim.
|
||||||
|
want := `tcp dport {22,80} counter accept comment "a b { c } d" # handle 5`
|
||||||
|
require.Equal(t, want, f.collapseSetSpaces(in))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalRule must reject a Comment or LogPrefix containing a double quote: nft's
|
||||||
|
// string literals have no escape for an embedded quote, so such a value cannot be
|
||||||
|
// written at all, and nft's own parser would otherwise fail with a confusing raw
|
||||||
|
// syntax error instead of a clear validation error.
|
||||||
|
func TestNFTMarshalRuleRejectsQuoteInComment(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
_, _, err := fw.MarshalRule(&Rule{Action: Accept, Comment: `has "quote"`})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
_, _, err = fw.MarshalRule(&Rule{Action: Accept, Log: true, LogPrefix: `has "quote"`})
|
||||||
|
require.Error(t, err)
|
||||||
|
|
||||||
|
// A comment/log prefix with no quote is unaffected.
|
||||||
|
_, expr, err := fw.MarshalRule(&Rule{Action: Accept, Comment: "plain comment"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, expr, `comment "plain comment"`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// unquote must strip a double-quoted value literally rather than decoding it
|
||||||
|
// as a Go string (strconv.Unquote): nft has no backslash-escape mechanism, so a
|
||||||
|
// literal backslash sequence in the value (e.g. a Windows-style path) must come
|
||||||
|
// back unchanged, not be reinterpreted as an escape.
|
||||||
|
func TestNFTUnquoteNoEscapeDecoding(t *testing.T) {
|
||||||
|
f := new(NFT)
|
||||||
|
require.Equal(t, `C:\new`, f.unquote(`"C:\new"`))
|
||||||
|
require.Equal(t, "plain", f.unquote(`"plain"`))
|
||||||
|
require.Equal(t, "bare", f.unquote("bare"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// setMatches must treat a matching family/type as safe to swallow as an
|
||||||
|
// idempotent re-add, and any mismatch (or an unreadable/absent existing set) as
|
||||||
|
// a genuine conflict that must not be silently treated as success.
|
||||||
|
func TestNFTSetMatches(t *testing.T) {
|
||||||
|
f := new(NFT)
|
||||||
|
require.True(t, f.setMatches(&AddressSet{Family: IPv4, Type: SetHashIP}, IPv4, SetHashIP))
|
||||||
|
// FamilyAny resolves to IPv4 for comparison, matching setSpec's own default.
|
||||||
|
require.True(t, f.setMatches(&AddressSet{Family: IPv4, Type: SetHashIP}, FamilyAny, SetHashIP))
|
||||||
|
require.False(t, f.setMatches(&AddressSet{Family: IPv6, Type: SetHashIP}, IPv4, SetHashIP))
|
||||||
|
require.False(t, f.setMatches(&AddressSet{Family: IPv4, Type: SetHashNet}, IPv4, SetHashIP))
|
||||||
|
require.False(t, f.setMatches(nil, IPv4, SetHashIP))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A host address written with an explicit /32 (or /128) prefix must round-trip
|
||||||
|
// even though nft lists it back without the prefix. Rule identity compares
|
||||||
|
// addresses semantically (addrEqual/canonAddr), so the bare read-back matches.
|
||||||
|
func TestNFTHostPrefixRoundTrip(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
orig := &Rule{Family: IPv4, Source: "1.2.3.4/32", Proto: TCP, Port: 22, Action: Accept}
|
||||||
|
chain, expr, err := f.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// nft strips the /32 from an exact host match when it lists the rule back.
|
||||||
|
listed := strings.Replace(expr, "1.2.3.4/32", "1.2.3.4", 1)
|
||||||
|
require.NotEqual(t, expr, listed)
|
||||||
|
got, _, err := f.UnmarshalRule(listed, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got.EqualBase(orig, true),
|
||||||
|
"a /32 host address must round-trip; got Source %q", got.Source)
|
||||||
|
|
||||||
|
// The same holds for an IPv6 /128 host and its zero-compressed spelling.
|
||||||
|
orig6 := &Rule{Family: IPv6, Source: "2001:0db8::1/128", Proto: TCP, Port: 22, Action: Accept}
|
||||||
|
chain6, expr6, err := f.MarshalRule(orig6)
|
||||||
|
require.NoError(t, err)
|
||||||
|
listed6 := strings.Replace(expr6, "2001:0db8::1/128", "2001:db8::1", 1)
|
||||||
|
got6, _, err := f.UnmarshalRule(listed6, chain6)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got6.EqualBase(orig6, true), "a /128 host address must round-trip; got %q", got6.Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nft lists ICMPv4 types 15 and 16 by their symbolic names info-request /
|
||||||
|
// info-reply, which the ICMPv4 name table previously omitted; such a rule must
|
||||||
|
// still parse back rather than being dropped from GetRules.
|
||||||
|
func TestNFTICMPv4InfoTypeNameParse(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
num uint8
|
||||||
|
name string
|
||||||
|
}{{15, "info-request"}, {16, "info-reply"}} {
|
||||||
|
orig := &Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](tc.num), Action: Accept}
|
||||||
|
chain, expr, err := f.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
named := strings.Replace(expr, "type "+strconv.Itoa(int(tc.num)), "type "+tc.name, 1)
|
||||||
|
require.NotEqual(t, expr, named, "marshaled rule should contain the numeric type")
|
||||||
|
got, _, err := f.UnmarshalRule(named, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.ICMPType)
|
||||||
|
require.Equal(t, tc.num, *got.ICMPType, "icmp %s is type %d", tc.name, tc.num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nft lists an ICMPv6 type by its ICMPv6 name, and several of those spellings
|
||||||
|
// (echo-request, destination-unreachable, ...) mean a different number under
|
||||||
|
// ICMPv4. The parser must resolve an icmpv6 type through the ICMPv6 table.
|
||||||
|
func TestNFTICMPv6TypeNameParse(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
|
||||||
|
// echo-request is ICMPv6 type 128 (it is 8 under ICMPv4).
|
||||||
|
chain, expr, err := f.MarshalRule(&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
named := strings.Replace(expr, "type 128", "type echo-request", 1)
|
||||||
|
require.NotEqual(t, expr, named, "marshaled rule should contain the numeric type")
|
||||||
|
got, _, err := f.UnmarshalRule(named, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.ICMPType)
|
||||||
|
require.Equal(t, uint8(128), *got.ICMPType, "echo-request must resolve to ICMPv6 type 128")
|
||||||
|
|
||||||
|
// nd-neighbor-solicit (135) is a v6-only name absent from the ICMPv4 table;
|
||||||
|
// it must still parse rather than dropping the rule.
|
||||||
|
chain2, expr2, err := f.MarshalRule(&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
named2 := strings.Replace(expr2, "type 135", "type nd-neighbor-solicit", 1)
|
||||||
|
got2, _, err := f.UnmarshalRule(named2, chain2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got2.ICMPType)
|
||||||
|
require.Equal(t, uint8(135), *got2.ICMPType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nftables applies and prints a default burst of 5 packets on every limit
|
||||||
|
// statement, even when none was requested. A rule marshaled with Burst 0 must
|
||||||
|
// still compare equal to the one nft lists back with burst 5.
|
||||||
|
func TestNFTRateBurstDefaultNormalized(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}}
|
||||||
|
chain, expr, err := f.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
withBurst := strings.Replace(expr, "rate 10/minute", "rate 10/minute burst 5 packets", 1)
|
||||||
|
require.NotEqual(t, expr, withBurst)
|
||||||
|
got, _, err := f.UnmarshalRule(withBurst, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.RateLimit)
|
||||||
|
require.Equal(t, uint(0), got.RateLimit.Burst, "nft's default burst of 5 must normalize to the unset 0")
|
||||||
|
require.True(t, got.EqualBase(orig, true), "a burst-5 read-back must equal the burst-0 original")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A comment containing a literal '#' must round-trip: strings.Fields would
|
||||||
|
// otherwise mistake the inner '#' for nft's `# handle N` marker and drop the
|
||||||
|
// rule.
|
||||||
|
func TestNFTCommentWithHash(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: "block # 5"}
|
||||||
|
chain, expr, err := f.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, _, err := f.UnmarshalRule(expr, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "block # 5", got.Comment)
|
||||||
|
|
||||||
|
// Also survive a trailing `# handle N` marker as printed by `nft -a`.
|
||||||
|
got2, _, err := f.UnmarshalRule(expr+" # handle 2", chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "block # 5", got2.Comment)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A log prefix containing whitespace must round-trip. nft prints it as a quoted
|
||||||
|
// string that strings.Fields splits on its internal spaces; the parser has to
|
||||||
|
// reassemble it rather than reading only the first token (which dropped the rest
|
||||||
|
// and made the whole rule unparseable, so it vanished from GetRules).
|
||||||
|
func TestNFTLogPrefixWithSpaces(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, Log: true, LogPrefix: "FW DROP"}
|
||||||
|
chain, expr, err := f.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _, err := f.UnmarshalRule(expr, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got.Log)
|
||||||
|
require.Equal(t, "FW DROP", got.LogPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A DNAT rule on SCTP must round-trip through the nft NAT parser, which had no
|
||||||
|
// sctp case and so dropped the rule on read (re-adding a duplicate every call).
|
||||||
|
func TestNFTSCTPNATRoundTrip(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
orig := &NATRule{Kind: DNAT, Family: IPv4, Proto: SCTP, Port: 9999, ToAddress: "10.0.0.5", ToPort: 9999}
|
||||||
|
chain, expr, err := f.MarshalNATRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _, err := f.UnmarshalNATRule(expr, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, orig.EqualBase(got), "sctp nat rule must round-trip; got %+v", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nft interval sets store a non-CIDR span as a range object; GetAddressSet must
|
||||||
|
// report it as "lo-hi" rather than silently dropping the entry.
|
||||||
|
func TestNFTDecodeRangeElem(t *testing.T) {
|
||||||
|
f := new(NFT)
|
||||||
|
require.Equal(t, "10.0.0.1-10.0.0.9", f.decodeElem([]byte(`{"range":["10.0.0.1","10.0.0.9"]}`)))
|
||||||
|
// Real nft renders a CIDR element as a prefix object with addr/len fields.
|
||||||
|
require.Equal(t, "10.0.0.0/24", f.decodeElem([]byte(`{"prefix":{"addr":"10.0.0.0","len":24}}`)))
|
||||||
|
require.Equal(t, "192.0.2.1", f.decodeElem([]byte(`"192.0.2.1"`)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// `nft -a list ruleset` appends `{ # handle N` (and sometimes ` progname ...`)
|
||||||
|
// to table/chain header lines. headerName must return the bare object name so
|
||||||
|
// listForeignRules skips our own table; a naive TrimSuffix(line, "{") left the
|
||||||
|
// handle tail attached, so the skip missed and GetRules re-listed our own rules
|
||||||
|
// as foreign (duplicates).
|
||||||
|
func TestNFTHeaderName(t *testing.T) {
|
||||||
|
f := new(NFT)
|
||||||
|
require.Equal(t, "inet gofwit", f.headerName("table inet gofwit { # handle 1", "table"))
|
||||||
|
require.Equal(t, "input", f.headerName("chain input { # handle 1", "chain"))
|
||||||
|
require.Equal(t, "inet firewalld",
|
||||||
|
f.headerName("table inet firewalld { # handle 215 progname firewalld", "table"))
|
||||||
|
require.Equal(t, "ip nat", f.headerName("table ip nat { # handle 6", "table"))
|
||||||
|
// A header without the -a handle suffix still parses.
|
||||||
|
require.Equal(t, "inet filter", f.headerName("table inet filter {", "table"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A bare FamilyAny redirect/masquerade is emitted into the inet table with no
|
||||||
|
// family qualifier, so it matches both IPv4 and IPv6 (it is NOT silently
|
||||||
|
// IPv4-only) and round-trips as FamilyAny.
|
||||||
|
func TestNFTFamilyAnyNATIsDualStack(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
for _, orig := range []*NATRule{
|
||||||
|
{Kind: Redirect, Family: FamilyAny, Proto: TCP, Port: 80, ToPort: 8080},
|
||||||
|
{Kind: Masquerade, Family: FamilyAny, Interface: "eth0"},
|
||||||
|
} {
|
||||||
|
chain, expr, err := f.MarshalNATRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotContains(t, expr, "meta nfproto", "a FamilyAny NAT rule must not pin a family: %q", expr)
|
||||||
|
got, _, err := f.UnmarshalNATRule(expr, chain)
|
||||||
|
require.NoError(t, err, "expr %q", expr)
|
||||||
|
require.Equal(t, FamilyAny, got.Family, "expr %q should read back FamilyAny", expr)
|
||||||
|
require.True(t, got.EqualBase(orig), "expr %q: want %+v got %+v", expr, orig, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nft merges a contiguous/overlapping anonymous port set on read
|
||||||
|
// ("{22,23,24-30}" -> "{22-30}"). The rule must still compare Equal to its own
|
||||||
|
// read-back or Sync would remove and re-add it every pass.
|
||||||
|
func TestNFTPortRangeCoalesceRoundTrip(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
orig := &Rule{Family: IPv4, Proto: TCP,
|
||||||
|
Ports: []PortRange{{Start: 22, End: 22}, {Start: 23, End: 23}, {Start: 24, End: 30}},
|
||||||
|
Action: Accept}
|
||||||
|
chain, expr, err := f.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, expr, "22", "marshaled set should carry the ports")
|
||||||
|
|
||||||
|
// Simulate nft's list normalization: the set is merged to a single range and
|
||||||
|
// re-spelled with the spacing nft prints.
|
||||||
|
listed := strings.Replace(expr, "{22,23,24-30}", "{ 22-30 }", 1)
|
||||||
|
require.NotEqual(t, expr, listed, "expected the marshaled set to be re-spelled")
|
||||||
|
|
||||||
|
got, _, err := f.UnmarshalRule(listed, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, orig.Equal(got, true),
|
||||||
|
"a coalesced port set must round-trip; got Ports %v", got.Ports)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nft re-spells a bare `reject` on read as `reject with <proto> <type>` (e.g.
|
||||||
|
// `reject with icmp port-unreachable`). The parser choked on the trailing `with
|
||||||
|
// ...` tokens and returned an error, so listChain silently dropped every reject
|
||||||
|
// rule — it read back as absent and could never be matched or removed. The
|
||||||
|
// listing form must parse back to the same rule the library rendered. (The other
|
||||||
|
// nft round-trip tests feed the marshalled form back in, which never carries the
|
||||||
|
// `with ...` detail, so they missed this.)
|
||||||
|
func TestNFTRejectRespelledRoundTrip(t *testing.T) {
|
||||||
|
fw := &NFT{table: "gofwit"}
|
||||||
|
|
||||||
|
rule := &Rule{Family: IPv4, Proto: TCP, Port: 3389, Source: "192.0.2.20/32", Action: Reject}
|
||||||
|
chain, _, err := fw.MarshalRule(rule)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// The exact form `nft -a list chain` emits for the rule above.
|
||||||
|
listed := "ip saddr 192.0.2.20 tcp dport 3389 counter packets 0 bytes 0 reject with icmp port-unreachable # handle 2"
|
||||||
|
parsed, handle, err := fw.UnmarshalRule(listed, chain)
|
||||||
|
require.NoError(t, err, "re-spelled reject line must parse")
|
||||||
|
require.Equal(t, "2", handle)
|
||||||
|
require.Equal(t, Reject, parsed.Action)
|
||||||
|
require.True(t, rule.Equal(parsed, true), "re-spelled reject must round-trip: got %+v", parsed)
|
||||||
|
|
||||||
|
// A reject carrying a comment after the `with ...` detail must still parse the
|
||||||
|
// comment (the detail-consuming loop stops at the comment marker).
|
||||||
|
withComment := `tcp dport 22 counter reject with icmp port-unreachable comment "block ssh" # handle 3`
|
||||||
|
parsed, _, err = fw.UnmarshalRule(withComment, "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, Reject, parsed.Action)
|
||||||
|
require.Equal(t, "block ssh", parsed.Comment)
|
||||||
|
|
||||||
|
// A tcp-reset reject (two-word detail) must also parse.
|
||||||
|
tcpReset := "tcp dport 22 counter reject with tcp reset # handle 4"
|
||||||
|
parsed, _, err = fw.UnmarshalRule(tcpReset, "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, Reject, parsed.Action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nft renders a set's prefix element as an object {"addr":..,"len":..}, not a
|
||||||
|
// two-element array. The decoder previously expected an array, so every hash:net
|
||||||
|
// (interval) set's CIDR entries were silently dropped on read.
|
||||||
|
func TestNFTDecodePrefixElemObject(t *testing.T) {
|
||||||
|
f := new(NFT)
|
||||||
|
cases := map[string]string{
|
||||||
|
`{"prefix": {"addr": "10.0.0.0", "len": 8}}`: "10.0.0.0/8",
|
||||||
|
`{"prefix": {"addr": "192.168.0.0", "len": 16}}`: "192.168.0.0/16",
|
||||||
|
`{"prefix": {"addr": "2001:db8::", "len": 32}}`: "2001:db8::/32",
|
||||||
|
`"1.2.3.4"`: "1.2.3.4",
|
||||||
|
`{"range": ["10.0.0.1", "10.0.0.5"]}`: "10.0.0.1-10.0.0.5",
|
||||||
|
}
|
||||||
|
for in, want := range cases {
|
||||||
|
require.Equal(t, want, f.decodeElem(json.RawMessage(in)), "f.decodeElem(%s)", in)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A rule that names the netfilter default burst (5) must round-trip Equal to
|
||||||
|
// itself. nft reads the default burst back as 0, so without folding 5==0 the rule
|
||||||
|
// would churn on every Sync. Regression for eqRateLimit + the nft read path.
|
||||||
|
func TestNFTBurst5RoundTrip(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
in := &Rule{Proto: TCP, Port: 25, Action: Drop,
|
||||||
|
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}}
|
||||||
|
chain, expr, err := fw.MarshalRule(in)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _, err := fw.UnmarshalRule(expr, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, in.Equal(got, true), "a Burst=5 rule must round-trip Equal to itself (expr %q)", expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A LogPrefix ending in a single quote is an identity field that real nft stores
|
||||||
|
// and lists back double-quoted (log prefix "block'"). The old trimQuotes stripped
|
||||||
|
// leading/trailing ' as well as the surrounding ", corrupting it to "block" and
|
||||||
|
// making the rule churn on every Sync. Parsing the exact nft list form asserts the
|
||||||
|
// fix against what nft actually emits, not just MarshalRule's own output.
|
||||||
|
func TestNFTLogPrefixEdgeQuoteRoundTrip(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
in := &Rule{Proto: TCP, Port: 25, Action: Drop, Log: true, LogPrefix: `block'`}
|
||||||
|
chain, expr, err := fw.MarshalRule(in)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, expr, `log prefix "block'"`, "marshaled form must match nft's list output")
|
||||||
|
got, _, err := fw.UnmarshalRule(expr, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, in.LogPrefix, got.LogPrefix, "LogPrefix ending in a quote must round-trip (expr %q)", expr)
|
||||||
|
require.True(t, in.Equal(got, true))
|
||||||
|
|
||||||
|
// Parse the literal line nft lists (with a handle marker) to cover the read path.
|
||||||
|
parsed, _, err := fw.UnmarshalRule(`tcp dport 25 log prefix "block'" drop # handle 5`, "input")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, `block'`, parsed.LogPrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A Comment ending in a single quote must round-trip through nft (same unquote
|
||||||
|
// fix); real nft lists it as comment "note'".
|
||||||
|
func TestNFTCommentEdgeQuoteRoundTrip(t *testing.T) {
|
||||||
|
fw := &NFT{table: "go_firewall"}
|
||||||
|
in := &Rule{Proto: TCP, Port: 25, Action: Drop, Comment: `note'`}
|
||||||
|
chain, expr, err := fw.MarshalRule(in)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _, err := fw.UnmarshalRule(expr, chain)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, in.Comment, got.Comment, "Comment ending in a quote must round-trip (expr %q)", expr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNFTLogLimitRoundTrip(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
cases := []*Rule{
|
||||||
|
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Log: true, LogPrefix: "ssh"},
|
||||||
|
// A non-default burst (nft's default is 5, which normalizes to the unset 0).
|
||||||
|
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 10}},
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 20}},
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 80, SourcePort: 1234, Action: Accept},
|
||||||
|
{Family: IPv4, Proto: TCP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, orig := range cases {
|
||||||
|
chain, expr, err := f.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _, err := f.UnmarshalRule(expr, chain)
|
||||||
|
require.NoError(t, err, "expr %q", expr)
|
||||||
|
require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Per-source connection limiting is not expressible in this model.
|
||||||
|
_, _, err := f.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Drop, ConnLimit: &ConnLimit{Count: 5, PerSource: true}})
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNFTNATRoundTrip(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
cases := []*NATRule{
|
||||||
|
{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Interface: "eth0"},
|
||||||
|
{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080},
|
||||||
|
{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4", Interface: "eth1"},
|
||||||
|
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
|
||||||
|
}
|
||||||
|
for _, orig := range cases {
|
||||||
|
chain, expr, err := f.MarshalNATRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _, err := f.UnmarshalNATRule(expr, chain)
|
||||||
|
require.NoError(t, err, "expr %q", expr)
|
||||||
|
require.True(t, got.EqualBase(orig), "expr %q: want %+v got %+v", expr, orig, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNFTProtocolAndComment(t *testing.T) {
|
||||||
|
f := &NFT{table: "test"}
|
||||||
|
cases := []*Rule{
|
||||||
|
{Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept},
|
||||||
|
{Family: IPv4, Proto: GRE, Action: Accept},
|
||||||
|
{Family: IPv6, Proto: ESP, Action: Accept},
|
||||||
|
{Family: IPv4, Proto: AH, Action: Drop},
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"},
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 443, Action: Accept, Comment: "https"},
|
||||||
|
}
|
||||||
|
for _, orig := range cases {
|
||||||
|
chain, expr, err := f.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _, err := f.UnmarshalRule(expr, chain)
|
||||||
|
require.NoError(t, err, "expr %q", expr)
|
||||||
|
require.True(t, got.EqualBase(orig, true), "expr %q: want %+v got %+v", expr, orig, got)
|
||||||
|
require.Equal(t, orig.Comment, got.Comment, "expr %q comment", expr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sanitizeNFTName reduces an arbitrary rule prefix to a valid nftables
|
||||||
|
// identifier, falling back to the default table name when nothing usable
|
||||||
|
// remains. This is the name every nft container is created under, so its
|
||||||
|
// behavior is worth pinning.
|
||||||
|
func TestSanitizeNFTName(t *testing.T) {
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"myapp", "myapp"},
|
||||||
|
{"my-app.v2", "my_app_v2"}, // '-' and '.' become '_'
|
||||||
|
{"my app", "my_app"}, // space becomes '_'
|
||||||
|
{"-lead-trail-", "lead_trail"}, // leading/trailing separators trimmed
|
||||||
|
{"a/b:c*d", "abcd"}, // unsupported runes dropped
|
||||||
|
{"", NFTDefaultTable}, // empty prefix -> default table
|
||||||
|
{"***", NFTDefaultTable}, // all-dropped -> default table
|
||||||
|
{"__", NFTDefaultTable}, // trims to empty -> default table
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
require.Equalf(t, c.want, sanitizeNFTName(c.in), "sanitizeNFTName(%q)", c.in)
|
||||||
|
}
|
||||||
|
}
|
||||||
603
pf_test.go
Normal file
603
pf_test.go
Normal file
|
|
@ -0,0 +1,603 @@
|
||||||
|
//go:build darwin || freebsd
|
||||||
|
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestPFMissingTableErr verifies the sentinel that makes address-set removal
|
||||||
|
// idempotent. pfctl prints "Table does not exist" for a missing table, so the
|
||||||
|
// removal helpers must recognize that exact wording (an earlier "not found" guard
|
||||||
|
// never matched, so removing an absent set errored instead of no-op succeeding).
|
||||||
|
func TestPFMissingTableErr(t *testing.T) {
|
||||||
|
fw := new(PF)
|
||||||
|
require.True(t, fw.isMissingTableErr(fmt.Errorf("pfctl: Table does not exist.")),
|
||||||
|
"pfctl's actual missing-table message must be recognized")
|
||||||
|
require.False(t, fw.isMissingTableErr(fmt.Errorf("pfctl: not found")),
|
||||||
|
"the wrong sentinel must not match")
|
||||||
|
require.False(t, fw.isMissingTableErr(nil))
|
||||||
|
require.False(t, fw.isMissingTableErr(fmt.Errorf("pfctl: permission denied")),
|
||||||
|
"a real failure must not be treated as a missing table")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPFICMP6TypeNameParse verifies that an icmp6-type printed by name resolves
|
||||||
|
// through the ICMPv6 table: pfctl reuses ICMPv4 spellings (e.g. echoreq) for
|
||||||
|
// different ICMPv6 numbers, so the ICMPv4 table would decode it wrongly.
|
||||||
|
func TestPFICMP6TypeNameParse(t *testing.T) {
|
||||||
|
f := &PF{anchor: "go_firewall"}
|
||||||
|
|
||||||
|
// echoreq is ICMPv6 type 128 (it is 8 under ICMPv4).
|
||||||
|
line, err := f.MarshalRule(&Rule{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
named := strings.Replace(line, "icmp6-type 128", "icmp6-type echoreq", 1)
|
||||||
|
require.NotEqual(t, line, named, "marshaled rule should contain the numeric icmp6 type")
|
||||||
|
got, err := f.UnmarshalRule(named)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.ICMPType)
|
||||||
|
require.Equal(t, uint8(128), *got.ICMPType, "echoreq must resolve to ICMPv6 type 128, not the ICMPv4 8")
|
||||||
|
|
||||||
|
// An ICMPv4 rule must still resolve echoreq to 8.
|
||||||
|
line4, err := f.MarshalRule(&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
named4 := strings.Replace(line4, "icmp-type 8", "icmp-type echoreq", 1)
|
||||||
|
got4, err := f.UnmarshalRule(named4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got4.ICMPType)
|
||||||
|
require.Equal(t, uint8(8), *got4.ICMPType)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPFTranslationBoundary verifies the nat/rdr anchors are inserted before the
|
||||||
|
// first filtering statement and AFTER any queueing (altq/queue) section — pf.conf
|
||||||
|
// sections are strictly ordered options → normalization → queueing → translation
|
||||||
|
// → filtering, so treating a queueing keyword as the boundary would splice the
|
||||||
|
// anchors ahead of it and produce a ruleset pfctl -f rejects.
|
||||||
|
func TestPFTranslationBoundary(t *testing.T) {
|
||||||
|
fw := new(PF)
|
||||||
|
// altq/queue precede the first pass/block; the boundary must be the pass line.
|
||||||
|
conf := []string{
|
||||||
|
"set skip on lo",
|
||||||
|
"scrub in all",
|
||||||
|
`altq on em0 bandwidth 100Mb hfsc queue { q_def }`,
|
||||||
|
`queue q_def bandwidth 100% hfsc(default)`,
|
||||||
|
"pass out all",
|
||||||
|
"block in all",
|
||||||
|
}
|
||||||
|
require.Equal(t, 4, fw.translationBoundary(conf),
|
||||||
|
"anchors must go after the queueing section, at the first pass/block")
|
||||||
|
|
||||||
|
// No filtering statements: append at the end.
|
||||||
|
require.Equal(t, 2, fw.translationBoundary([]string{"set skip on lo", "scrub in all"}))
|
||||||
|
|
||||||
|
// antispoof and anchor also open the filtering section.
|
||||||
|
require.Equal(t, 0, fw.translationBoundary([]string{"antispoof for em0"}))
|
||||||
|
require.Equal(t, 1, fw.translationBoundary([]string{"scrub in all", `anchor "foo"`}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPFHighICMPTypeNames verifies the high ICMPv4 type names pfctl prints (31-40)
|
||||||
|
// round-trip: MarshalRule emits the numeric type, pfctl re-spells it by name on
|
||||||
|
// -sr, and UnmarshalRule must resolve that name back to the number.
|
||||||
|
func TestPFHighICMPTypeNames(t *testing.T) {
|
||||||
|
f := &PF{anchor: "go_firewall"}
|
||||||
|
for name, num := range map[string]uint8{"photuris": 40, "skip": 39, "mobregreq": 35, "ipv6-where": 33} {
|
||||||
|
line, err := f.MarshalRule(&Rule{Family: IPv4, Proto: ICMP, ICMPType: Ptr(num), Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
named := strings.Replace(line, "icmp-type "+strconv.Itoa(int(num)), "icmp-type "+name, 1)
|
||||||
|
require.NotEqual(t, line, named, "expected numeric icmp-type in %q", line)
|
||||||
|
got, err := f.UnmarshalRule(named)
|
||||||
|
require.NoError(t, err, "pfctl name %q (type %d) must parse", name, num)
|
||||||
|
require.NotNil(t, got.ICMPType)
|
||||||
|
require.Equal(t, num, *got.ICMPType, "%s must resolve to %d", name, num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPFProtocolAndComment round-trips the added protocols and a rule comment
|
||||||
|
// (a pf label) through the pf rule encoder.
|
||||||
|
func TestPFProtocolAndComment(t *testing.T) {
|
||||||
|
f := &PF{anchor: "go_firewall"}
|
||||||
|
cases := []*Rule{
|
||||||
|
{Family: IPv4, Proto: SCTP, Port: 9000, Action: Accept},
|
||||||
|
{Family: IPv4, Proto: GRE, Action: Accept},
|
||||||
|
{Family: IPv4, Proto: ESP, Action: Accept},
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"},
|
||||||
|
}
|
||||||
|
for _, orig := range cases {
|
||||||
|
line, err := f.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, err := f.UnmarshalRule(line)
|
||||||
|
require.NoError(t, err, "line %q", line)
|
||||||
|
require.True(t, got.EqualBase(orig, true), "line %q: want %+v got %+v", line, orig, got)
|
||||||
|
require.Equal(t, orig.Comment, got.Comment, "line %q comment", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPFAnchorPreservesUnmodeled verifies parseAnchorRules keeps a rule line it
|
||||||
|
// cannot model as an opaque row (nil rule, raw text preserved) instead of dropping
|
||||||
|
// it, so a read-modify-write rewrite of our anchor does not silently delete a
|
||||||
|
// foreign rule loaded into it. The rules slice stays 1:1 with raw so the physical
|
||||||
|
// row edits (insert/move/remove) never misalign.
|
||||||
|
func TestPFAnchorPreservesUnmodeled(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
// The middle line uses a pf port operator (port > 1023) this backend does not
|
||||||
|
// model, so it cannot become a Rule; the two surrounding lines are modeled.
|
||||||
|
out := []string{
|
||||||
|
"pass in quick inet proto tcp from any to any port = 22 keep state",
|
||||||
|
"pass in quick inet proto tcp from any to any port > 1023 keep state",
|
||||||
|
"pass out quick inet proto udp from any to any port = 53 keep state",
|
||||||
|
}
|
||||||
|
rules, raw := fw.parseAnchorRules(out)
|
||||||
|
require.Len(t, rules, 3, "every physical row needs a slot, the opaque one included")
|
||||||
|
require.Len(t, raw, 3, "raw must stay 1:1 with rules")
|
||||||
|
require.NotNil(t, rules[0])
|
||||||
|
require.Nil(t, rules[1], "the unmodeled line must be an opaque (nil) row")
|
||||||
|
require.NotNil(t, rules[2])
|
||||||
|
require.Equal(t, out[1], raw[1], "the unmodeled line's text must be preserved verbatim")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPFReorderRowsKeepsOpaque verifies the move/remove row rebuild keeps an opaque
|
||||||
|
// (nil) row in place and maps the merged target position to the correct physical
|
||||||
|
// index past it, so relocating a modeled rule never drops or displaces a foreign
|
||||||
|
// line sharing our anchor.
|
||||||
|
func TestPFReorderRowsKeepsOpaque(t *testing.T) {
|
||||||
|
fw := new(PF)
|
||||||
|
ruleA := &Rule{Family: IPv4, Port: 22, Proto: TCP, Action: Accept}
|
||||||
|
ruleB := &Rule{Family: IPv4, Port: 53, Proto: UDP, Action: Accept}
|
||||||
|
rules := []*Rule{ruleA, nil, ruleB}
|
||||||
|
raw := []string{"lineA", "opaque", "lineB"}
|
||||||
|
|
||||||
|
// Move ruleB (a distinct rule) to the front.
|
||||||
|
out, moved := fw.reorderRows(rules, raw, ruleB, 1)
|
||||||
|
require.True(t, moved)
|
||||||
|
require.Equal(t, []string{"lineB", "lineA", "opaque"}, out,
|
||||||
|
"the opaque line must be kept; only the modeled rule relocates")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPFRules(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
|
||||||
|
// Marshal a representative rule and confirm the pf rule line.
|
||||||
|
line, err := fw.MarshalRule(&Rule{
|
||||||
|
Family: IPv4,
|
||||||
|
Source: "192.168.0.0/24",
|
||||||
|
Port: 23,
|
||||||
|
Proto: UDP,
|
||||||
|
Action: Accept,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "pass in quick inet proto udp from 192.168.0.0/24 to any port 23", line,
|
||||||
|
"unexpected rule line")
|
||||||
|
|
||||||
|
// The normalized form emitted by `pfctl -sr` must parse back to an
|
||||||
|
// equivalent rule.
|
||||||
|
rule, err := fw.UnmarshalRule("pass in quick inet proto udp from 192.168.0.0/24 to any port = 23 keep state")
|
||||||
|
require.NoError(t, err)
|
||||||
|
want := &Rule{Family: IPv4, Source: "192.168.0.0/24", Port: 23, Proto: UDP, Action: Accept}
|
||||||
|
require.True(t, rule.Equal(want, true), "parsed rule does not match: got %+v", rule)
|
||||||
|
|
||||||
|
// Round-trip the rules we typically set across directions, families and
|
||||||
|
// actions.
|
||||||
|
rules := []*Rule{
|
||||||
|
{Family: IPv4, Port: 4789, Proto: UDP, Action: Accept},
|
||||||
|
{Direction: DirOutput, Family: IPv6, Port: 4789, Proto: UDP, Action: Accept},
|
||||||
|
{Family: IPv4, Source: "67.227.233.116", Port: 4789, Proto: TCP, Action: Accept},
|
||||||
|
{Direction: DirOutput, Family: IPv4, Destination: "67.227.233.116", Port: 4791, Proto: TCP, Action: Reject},
|
||||||
|
{Family: IPv6, Source: "!2001:db8::1", Action: Drop},
|
||||||
|
// A non-address Source/Destination names a pf table, referenced as <name>.
|
||||||
|
{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop},
|
||||||
|
{Direction: DirOutput, Family: IPv4, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
line, err := fw.MarshalRule(r)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *r)
|
||||||
|
|
||||||
|
parsed, err := fw.UnmarshalRule(line)
|
||||||
|
require.NoError(t, err, "failed to parse %q", line)
|
||||||
|
require.True(t, parsed.Equal(r, true),
|
||||||
|
"round-trip mismatch: input %+v, line %q, output %+v", *r, line, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A non-address Source is emitted as a pf table reference in angle brackets.
|
||||||
|
setLine, err := fw.MarshalRule(&Rule{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, setLine, "from <blocklist>")
|
||||||
|
|
||||||
|
// Invalid lines must be rejected.
|
||||||
|
invalidRules := []string{
|
||||||
|
"pass in quick inet proto foo from any to any",
|
||||||
|
"frobnicate in quick from any to any",
|
||||||
|
"pass sideways quick from any to any",
|
||||||
|
}
|
||||||
|
for _, line := range invalidRules {
|
||||||
|
_, err := fw.UnmarshalRule(line)
|
||||||
|
require.Error(t, err, "line parsed when it should be invalid: %s", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A port without a concrete protocol cannot be expressed in pf.
|
||||||
|
_, err = fw.MarshalRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept})
|
||||||
|
require.Error(t, err, "expected error marshalling a port with no protocol")
|
||||||
|
|
||||||
|
// A single source port and a contiguous source-port range round-trip and are
|
||||||
|
// accepted; a discrete source-port list does not round-trip (pfctl expands it),
|
||||||
|
// so it is rejected rather than emitted.
|
||||||
|
_, err = fw.MarshalRule(&Rule{Proto: TCP, SourcePort: 1024, Action: Accept})
|
||||||
|
require.NoError(t, err, "a single source port is valid")
|
||||||
|
_, err = fw.MarshalRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 2048}}, Action: Accept})
|
||||||
|
require.NoError(t, err, "a source-port range is valid")
|
||||||
|
_, err = fw.MarshalRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept})
|
||||||
|
require.Error(t, err, "a discrete source-port list must be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pf exposes per-rule counters through `pfctl -vsr`, which prints a
|
||||||
|
// `[ Evaluations: N Packets: N Bytes: N States: N ]` continuation line under
|
||||||
|
// each rule (and may prefix rules with a @N number in a verbose listing).
|
||||||
|
// parseAnchorRules attaches those counters to the preceding rule.
|
||||||
|
func TestPFRuleCounters(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
|
||||||
|
out := []string{
|
||||||
|
"@0 pass in quick proto tcp from any to any port = 22",
|
||||||
|
" [ Evaluations: 100 Packets: 40 Bytes: 2400 States: 2 ]",
|
||||||
|
" [ Inserted: uid 0 pid 1 State Creations: 2 ]",
|
||||||
|
"pass in quick proto udp from any to any port = 53",
|
||||||
|
" [ Evaluations: 5 Packets: 5 Bytes: 300 States: 0 ]",
|
||||||
|
}
|
||||||
|
rules, raw := fw.parseAnchorRules(out)
|
||||||
|
require.Len(t, rules, 2, "expected two rules parsed")
|
||||||
|
require.Len(t, raw, 2, "raw must exclude the continuation lines")
|
||||||
|
|
||||||
|
// The @N prefix is stripped so the raw text stays loadable by pfctl -f.
|
||||||
|
require.NotContains(t, raw[0], "@0", "the rule-number prefix must be stripped: %q", raw[0])
|
||||||
|
|
||||||
|
require.EqualValues(t, 40, rules[0].Packets)
|
||||||
|
require.EqualValues(t, 2400, rules[0].Bytes)
|
||||||
|
require.EqualValues(t, 22, rules[0].Port)
|
||||||
|
require.EqualValues(t, 5, rules[1].Packets)
|
||||||
|
require.EqualValues(t, 300, rules[1].Bytes)
|
||||||
|
|
||||||
|
// The counter parser only fires on a line that carries both counters.
|
||||||
|
p, b, ok := fw.parseRuleCounters("[ Evaluations: 1 Packets: 7 Bytes: 500 States: 0 ]")
|
||||||
|
require.True(t, ok)
|
||||||
|
require.EqualValues(t, 7, p)
|
||||||
|
require.EqualValues(t, 500, b)
|
||||||
|
_, _, ok = fw.parseRuleCounters("[ Inserted: uid 0 pid 1 State Creations: 2 ]")
|
||||||
|
require.False(t, ok, "a non-counter continuation line must not report counters")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPFFeatureRules(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
|
||||||
|
// Confirm representative encodings.
|
||||||
|
cases := []struct {
|
||||||
|
rule *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{&Rule{Proto: ICMP, Action: Accept}, "pass in quick inet proto icmp from any to any"},
|
||||||
|
{&Rule{Proto: ICMPv6, Action: Accept}, "pass in quick inet6 proto icmp6 from any to any"},
|
||||||
|
{&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, "pass in quick inet proto icmp from any to any icmp-type 8"},
|
||||||
|
{&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept}, "pass in quick inet6 proto icmp6 from any to any icmp6-type 135"},
|
||||||
|
{&Rule{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, "pass in quick proto udp from any to any port 1000:2000"},
|
||||||
|
{&Rule{InInterface: "em0", Proto: TCP, Port: 22, Action: Accept}, "pass in quick on em0 proto tcp from any to any port 22"},
|
||||||
|
{&Rule{Direction: DirOutput, OutInterface: "em1", Action: Drop}, "block drop out quick on em1 from any to any"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := fw.MarshalRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Round-trip every new-feature rule shape.
|
||||||
|
rules := []*Rule{
|
||||||
|
{Proto: ICMP, Action: Accept},
|
||||||
|
{Proto: ICMPv6, Action: Drop},
|
||||||
|
{Family: IPv6, Proto: ICMPv6, Action: Accept},
|
||||||
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Accept},
|
||||||
|
{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
|
||||||
|
{InInterface: "em0", Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
{Direction: DirOutput, OutInterface: "em1", Proto: UDP, Port: 53, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
line, err := fw.MarshalRule(r)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *r)
|
||||||
|
|
||||||
|
parsed, err := fw.UnmarshalRule(line)
|
||||||
|
require.NoError(t, err, "failed to parse %q", line)
|
||||||
|
require.True(t, parsed.Equal(r, true),
|
||||||
|
"round-trip mismatch: input %+v, line %q, output %+v", *r, line, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pf cannot express a discrete destination-port list (PortList is false):
|
||||||
|
// pfctl expands `port { 80 443 }` into one rule per port on read, so it is
|
||||||
|
// rejected rather than emitted as a rule that reads back as several.
|
||||||
|
_, err := fw.MarshalRule(&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported, "a destination-port list must be rejected under pf")
|
||||||
|
|
||||||
|
// pf cannot express a connection-state match in this model.
|
||||||
|
_, err = fw.MarshalRule(&Rule{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept})
|
||||||
|
require.Error(t, err, "expected error marshalling a state match under pf")
|
||||||
|
|
||||||
|
// Interface/direction mismatches must be rejected.
|
||||||
|
_, err = fw.MarshalRule(&Rule{Direction: DirOutput, InInterface: "em0", Action: Accept})
|
||||||
|
require.Error(t, err, "expected error matching an input interface on an output rule")
|
||||||
|
_, err = fw.MarshalRule(&Rule{OutInterface: "em0", Action: Accept})
|
||||||
|
require.Error(t, err, "expected error matching an output interface on an input rule")
|
||||||
|
|
||||||
|
// pf has no distinct forward chain, so a forward rule is rejected with the
|
||||||
|
// ErrUnsupportedForward sentinel.
|
||||||
|
_, err = fw.MarshalRule(&Rule{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected")
|
||||||
|
require.False(t, fw.Capabilities().Forward, "pf does not advertise forward support")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPFLogLimitRoundTrip(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
cases := []*Rule{
|
||||||
|
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept, Log: true},
|
||||||
|
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept,
|
||||||
|
ConnLimit: &ConnLimit{Count: 100, PerSource: true},
|
||||||
|
RateLimit: &RateLimit{Rate: 15, Unit: PerSecond}},
|
||||||
|
{Family: IPv4, Port: 22, Proto: TCP, Action: Accept,
|
||||||
|
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}},
|
||||||
|
}
|
||||||
|
for _, orig := range cases {
|
||||||
|
line, err := fw.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, err := fw.UnmarshalRule(line)
|
||||||
|
require.NoError(t, err, "line %q", line)
|
||||||
|
require.True(t, got.EqualBase(orig, true), "line %q: want %+v got %+v", line, orig, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pf has no log prefix, and limits require an accept rule.
|
||||||
|
_, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "x"})
|
||||||
|
require.Error(t, err, "expected pf to reject a log prefix")
|
||||||
|
_, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}})
|
||||||
|
require.Error(t, err, "expected pf to reject a limit on a non-accept rule")
|
||||||
|
_, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, ConnLimit: &ConnLimit{Count: 5, PerSource: false}})
|
||||||
|
require.Error(t, err, "expected pf to reject a non-per-source connection limit")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPFNATRoundTrip(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
cases := []*NATRule{
|
||||||
|
{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "10.0.0.5", ToPort: 8080, Interface: "em0"},
|
||||||
|
{Kind: SNAT, Family: IPv4, Source: "10.0.0.0/24", ToAddress: "1.2.3.4", Interface: "em0"},
|
||||||
|
{Kind: Masquerade, Family: IPv4, Interface: "em0"},
|
||||||
|
}
|
||||||
|
for _, orig := range cases {
|
||||||
|
line, err := fw.MarshalNATRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, err := fw.UnmarshalNATRule(line)
|
||||||
|
require.NoError(t, err, "line %q", line)
|
||||||
|
require.True(t, got.EqualBase(orig), "line %q: want %+v got %+v", line, orig, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// pfctl prints a well-known translation port by its /etc/services name, e.g. a
|
||||||
|
// DNAT to port 80 reads back as `-> 10.0.0.5 port http`. The target port must
|
||||||
|
// resolve through the service-name lookup like a match port; a number-only
|
||||||
|
// parse fails and anchorNATRules silently drops the rule from the snapshot.
|
||||||
|
named, err := fw.UnmarshalNATRule("rdr on em0 inet proto tcp from any to any port www -> 10.0.0.5 port http")
|
||||||
|
require.NoError(t, err, "a named nat target port must parse")
|
||||||
|
require.Equal(t, uint16(80), named.ToPort, "named target port http must resolve to 80")
|
||||||
|
|
||||||
|
// pf has no portless redirect and masquerade needs an interface.
|
||||||
|
_, err = fw.MarshalNATRule(&NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080})
|
||||||
|
require.Error(t, err, "expected pf to reject a redirect")
|
||||||
|
_, err = fw.MarshalNATRule(&NATRule{Kind: Masquerade, Family: IPv4})
|
||||||
|
require.Error(t, err, "expected pf masquerade to require an interface")
|
||||||
|
}
|
||||||
|
|
||||||
|
// pf's max-src-conn-rate has no burst term, so a rate limit carrying a non-zero
|
||||||
|
// Burst cannot be honored and must be rejected rather than marshaled into a rule
|
||||||
|
// that reads back with Burst 0 and fails rule-identity comparison.
|
||||||
|
func TestPFRateLimitBurstRejected(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
_, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept,
|
||||||
|
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported, "a rate-limit burst must be rejected, not silently dropped")
|
||||||
|
|
||||||
|
// A burst-less rate limit still round-trips.
|
||||||
|
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept,
|
||||||
|
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute}}
|
||||||
|
line, err := fw.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, err := fw.UnmarshalRule(line)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, got.EqualBase(orig, true), "line %q", line)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pf label (user comment) containing consecutive spaces must round-trip: the
|
||||||
|
// line tokenizer collapses whitespace, so the label is recovered from the raw
|
||||||
|
// line rather than the split tokens.
|
||||||
|
func TestPFLabelConsecutiveSpaces(t *testing.T) {
|
||||||
|
fw := &PF{anchor: "go_firewall"}
|
||||||
|
for _, comment := range []string{"web server", "a b c", `has "quote" and spaces`} {
|
||||||
|
orig := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Comment: comment}
|
||||||
|
line, err := fw.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, err := fw.UnmarshalRule(line)
|
||||||
|
require.NoError(t, err, "line %q", line)
|
||||||
|
require.Equal(t, comment, got.Comment, "label whitespace must survive; line %q", line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A pf IPv4/IPv6 filter twin is collapsed by mergeFamilies into one FamilyAny
|
||||||
|
// rule (impliedFamily FamilyAny). RemoveRule/MoveRule must locate that read-back
|
||||||
|
// rule with EqualForRemoval, not the family-strict Equal — which never matches
|
||||||
|
// it, so the port stays open. Regression for the pf remove no-op on a merged rule.
|
||||||
|
func TestPFMergedFilterTwinIsRemovable(t *testing.T) {
|
||||||
|
f := &PF{anchor: "go_firewall"}
|
||||||
|
v4, err := f.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22")
|
||||||
|
require.NoError(t, err)
|
||||||
|
v6, err := f.UnmarshalRule("pass in quick inet6 proto tcp from any to any port = 22")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
merged := mergeFamiliesCopy([]*Rule{v4, v6})
|
||||||
|
require.Len(t, merged, 1, "the v4/v6 twin must collapse into one rule")
|
||||||
|
m := merged[0]
|
||||||
|
require.Equal(t, FamilyAny, m.impliedFamily())
|
||||||
|
|
||||||
|
// The old family-strict matcher could find neither physical row.
|
||||||
|
require.False(t, m.Equal(v4, true))
|
||||||
|
require.False(t, m.Equal(v6, true))
|
||||||
|
|
||||||
|
// The new matcher (EqualForRemoval) finds both, so RemoveRule clears both anchor
|
||||||
|
// rows and MoveRule can locate the rule.
|
||||||
|
require.True(t, v4.EqualForRemoval(m, true))
|
||||||
|
require.True(t, v6.EqualForRemoval(m, true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveRule must relocate BOTH physical rows of a merged IPv4/IPv6 pair, not just
|
||||||
|
// the first. mergeFamilies re-pairs rows on the lower physical index, so moving only
|
||||||
|
// the first row of the pair leaves the twin at the earlier index — which then wins
|
||||||
|
// on the next read, making a move to a LATER position a silent no-op. reorderRows
|
||||||
|
// moves the pair as a block. Regression for the pf MoveRule merged-pair defect.
|
||||||
|
func TestPFReorderRowsMergedPair(t *testing.T) {
|
||||||
|
fw := new(PF)
|
||||||
|
mk := func(fam Family, port uint16) *Rule {
|
||||||
|
return &Rule{Family: fam, Proto: TCP, Port: port, Action: Accept}
|
||||||
|
}
|
||||||
|
// Physical anchor rows: A is a v4/v6 pair (rows 0,1); B is a v4/v6 pair (rows 2,3).
|
||||||
|
// GetRules merges these to A(Number 1), B(Number 2).
|
||||||
|
rules := []*Rule{mk(IPv4, 22), mk(IPv6, 22), mk(IPv4, 80), mk(IPv6, 80)}
|
||||||
|
raw := []string{"A_v4", "A_v6", "B_v4", "B_v6"}
|
||||||
|
|
||||||
|
// Move merged A (read back as a collapsed FamilyAny pair) to position 2 — after B.
|
||||||
|
out, moved := fw.reorderRows(rules, raw, mk(FamilyAny, 22), 2)
|
||||||
|
require.True(t, moved)
|
||||||
|
require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out,
|
||||||
|
"both rows of the pair must move together, landing after B")
|
||||||
|
|
||||||
|
// Move merged B up to the front (position 1) — the previously-working direction.
|
||||||
|
out, moved = fw.reorderRows(rules, raw, mk(FamilyAny, 80), 1)
|
||||||
|
require.True(t, moved)
|
||||||
|
require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out)
|
||||||
|
|
||||||
|
// A concrete-family target relocates only its own family row, never the twin.
|
||||||
|
out, moved = fw.reorderRows(rules, raw, mk(IPv4, 22), 3)
|
||||||
|
require.True(t, moved)
|
||||||
|
require.Equal(t, []string{"A_v6", "B_v4", "B_v6", "A_v4"}, out)
|
||||||
|
|
||||||
|
// A rule that matches nothing reports no move (MoveRule then skips the reload).
|
||||||
|
_, moved = fw.reorderRows(rules, raw, mk(FamilyAny, 443), 1)
|
||||||
|
require.False(t, moved)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileLines must preserve the original file's mode (not loosen it to 0644)
|
||||||
|
// and must not leave a fixed-name temp file behind.
|
||||||
|
func TestWriteFileLinesPreservesMode(t *testing.T) {
|
||||||
|
fw := new(PF)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "pf.conf")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte("old\n"), 0600))
|
||||||
|
|
||||||
|
require.NoError(t, fw.writeFileLines(path, []string{"line1", "line2"}))
|
||||||
|
|
||||||
|
// Content replaced.
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "line1\nline2\n", string(data))
|
||||||
|
|
||||||
|
// Mode preserved, not widened to 0644.
|
||||||
|
fi, err := os.Stat(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, os.FileMode(0600), fi.Mode().Perm(), "mode must be preserved")
|
||||||
|
|
||||||
|
// No stale fixed-name temp file (the old fixed "<path>.tmp" scheme) remains.
|
||||||
|
_, err = os.Stat(path + ".tmp")
|
||||||
|
require.True(t, os.IsNotExist(err), "fixed-name temp file must not linger")
|
||||||
|
|
||||||
|
// A brand-new file defaults to 0600 rather than 0644.
|
||||||
|
newPath := filepath.Join(dir, "new.conf")
|
||||||
|
require.NoError(t, fw.writeFileLines(newPath, []string{"x"}))
|
||||||
|
fi, err = os.Stat(newPath)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, os.FileMode(0600), fi.Mode().Perm())
|
||||||
|
}
|
||||||
|
|
||||||
|
// readFileLines must handle a pf.conf line longer than bufio.Scanner's default
|
||||||
|
// 64 KB token cap rather than failing with a "token too long" error.
|
||||||
|
func TestReadFileLinesLongLine(t *testing.T) {
|
||||||
|
fw := new(PF)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "pf.conf")
|
||||||
|
long := strings.Repeat("a", 300*1024) // 300 KB, well past the 64 KB default
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(long+"\nshort\n"), 0600))
|
||||||
|
|
||||||
|
lines, err := fw.readFileLines(path)
|
||||||
|
require.NoError(t, err, "a long line must not overflow the scanner")
|
||||||
|
require.Len(t, lines, 2)
|
||||||
|
require.Equal(t, long, lines[0])
|
||||||
|
require.Equal(t, "short", lines[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// pf reports PortList=false because pfctl expands a discrete port list
|
||||||
|
// (`port { 80 443 }`) into one rule per port on read, so a multi-port rule does
|
||||||
|
// not round-trip. MarshalRule must reject a destination-port list (as it already
|
||||||
|
// does for a source-port list and as the sibling PortList=false backends do)
|
||||||
|
// rather than emit a rule that reads back as several and churns Sync. A single
|
||||||
|
// contiguous range (PortRange=true) stays one token and must still be allowed.
|
||||||
|
func TestPFMarshalRejectsDestPortList(t *testing.T) {
|
||||||
|
f := &PF{anchor: "go_firewall"}
|
||||||
|
|
||||||
|
// A destination-port list must be rejected as unsupported.
|
||||||
|
_, err := f.MarshalRule(&Rule{
|
||||||
|
Proto: TCP,
|
||||||
|
Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}},
|
||||||
|
Action: Accept,
|
||||||
|
})
|
||||||
|
require.Error(t, err, "a destination-port list must be rejected")
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported)
|
||||||
|
|
||||||
|
// A single contiguous range still round-trips as one token, so it is allowed.
|
||||||
|
line, err := f.MarshalRule(&Rule{
|
||||||
|
Proto: TCP,
|
||||||
|
Ports: []PortRange{{Start: 1000, End: 2000}},
|
||||||
|
Action: Accept,
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "a single contiguous range must remain expressible")
|
||||||
|
require.Contains(t, line, "1000:2000")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The NAT marshal path has the same constraint: a rdr/nat match-port list expands
|
||||||
|
// on read, so MarshalNATRule must reject a multi-port match rather than emit a
|
||||||
|
// list that reads back as several rules.
|
||||||
|
func TestPFMarshalNATRejectsPortList(t *testing.T) {
|
||||||
|
f := &PF{anchor: "go_firewall"}
|
||||||
|
_, err := f.MarshalNATRule(&NATRule{
|
||||||
|
Kind: DNAT,
|
||||||
|
Proto: TCP,
|
||||||
|
Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}},
|
||||||
|
ToAddress: "10.0.0.5",
|
||||||
|
})
|
||||||
|
require.Error(t, err, "a NAT match-port list must be rejected")
|
||||||
|
require.ErrorIs(t, err, ErrUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseAddr must not mutate the caller's token slice when stripping a leading
|
||||||
|
// "!" negation.
|
||||||
|
func TestParsePFAddrDoesNotMutateTokens(t *testing.T) {
|
||||||
|
fw := new(PF)
|
||||||
|
tokens := []string{"!1.2.3.4", "port", "22"}
|
||||||
|
val, neg, next, err := fw.parseAddr(tokens, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "1.2.3.4", val)
|
||||||
|
require.Equal(t, "!", neg)
|
||||||
|
require.Equal(t, 0, next)
|
||||||
|
require.Equal(t, "!1.2.3.4", tokens[0], "the caller's slice must be left unchanged")
|
||||||
|
|
||||||
|
// The separate-"!" token form advances the index and leaves tokens intact.
|
||||||
|
tokens = []string{"!", "1.2.3.4"}
|
||||||
|
val, neg, next, err = fw.parseAddr(tokens, 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "1.2.3.4", val)
|
||||||
|
require.Equal(t, "!", neg)
|
||||||
|
require.Equal(t, 1, next)
|
||||||
|
require.Equal(t, "!", tokens[0])
|
||||||
|
}
|
||||||
BIN
scripts/__pycache__/refactor_order.cpython-314.pyc
Normal file
BIN
scripts/__pycache__/refactor_order.cpython-314.pyc
Normal file
Binary file not shown.
380
scripts/refactor_backend.py
Normal file
380
scripts/refactor_backend.py
Normal file
|
|
@ -0,0 +1,380 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""refactor_backend.py — normalize function placement/naming in a go-firewall backend.
|
||||||
|
|
||||||
|
The library's convention (see CLAUDE.md and the APF refactor it was modeled on):
|
||||||
|
|
||||||
|
* A function used by more than one backend stays a package-level global.
|
||||||
|
* A function used only by this backend lives under the backend struct as a method.
|
||||||
|
* A method's name never repeats the backend token (the receiver already namespaces
|
||||||
|
it): apfNeedsHook -> (*APF).needsHook, isAPFConfRule -> (*APF).isConfRule.
|
||||||
|
* Visibility is preserved: an exported helper stays exported, an unexported one
|
||||||
|
stays unexported. Exported methods are the surface "people working with the
|
||||||
|
backend directly out of the manager interface" call.
|
||||||
|
* The New<Type> constructor stays a global.
|
||||||
|
|
||||||
|
The script parses the backend file, classifies every function, and (with --apply)
|
||||||
|
rewrites the backend file, its _test.go, and bare-name mentions in sibling files.
|
||||||
|
Default is a dry run that prints the plan. Naming is heuristic — review the plan,
|
||||||
|
and pass --rename a=b,c=d to override any proposed name.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
scripts/refactor_backend.py csf_linux.go # dry-run plan
|
||||||
|
scripts/refactor_backend.py csf_linux.go --prefix csf # override token(s)
|
||||||
|
scripts/refactor_backend.py csf_linux.go --apply
|
||||||
|
scripts/refactor_backend.py csf_linux.go --apply --rename fooBar=baz
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Go initialisms handled when lowercasing a leading word so an acronym stays intact
|
||||||
|
# (NATFamilies -> natFamilies, IPv6Unavailable -> ipv6Unavailable, not nATFamilies).
|
||||||
|
INITIALISMS = [
|
||||||
|
"ICMPV6", "ICMPV4", "HTTPS", "IPV4", "IPV6", "ICMP", "HTTP", "SCTP",
|
||||||
|
"NAT", "TCP", "UDP", "DNS", "URL", "URI", "API", "TLS", "SSH", "ACL",
|
||||||
|
"ID", "IP", "WFP", "UFW", "CSF", "APF", "NFT", "PF",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Group 1: receiver variable (optional — a method may use an anonymous receiver
|
||||||
|
# like `func (*IPTables) IgnoreLine(...)`). Group 2: receiver type. Group 3: name.
|
||||||
|
FUNC_RE = re.compile(r"^func\s+(?:\((?:(\w+)\s+)?\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\(", re.MULTILINE)
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg):
|
||||||
|
print("error: " + msg, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def split_blocks(text):
|
||||||
|
"""Split gofmt'd Go source into (preamble, [(header_line_idx, [lines])...]).
|
||||||
|
|
||||||
|
A top-level declaration starts with `func ` in column 0 and ends at the next
|
||||||
|
column-0 `func ` (gofmt guarantees the layout). Only func blocks are returned;
|
||||||
|
everything before the first func is the preamble.
|
||||||
|
"""
|
||||||
|
lines = text.split("\n")
|
||||||
|
starts = [i for i, l in enumerate(lines) if l.startswith("func ")]
|
||||||
|
if not starts:
|
||||||
|
return lines, []
|
||||||
|
preamble = lines[: starts[0]]
|
||||||
|
blocks = []
|
||||||
|
for n, s in enumerate(starts):
|
||||||
|
e = starts[n + 1] if n + 1 < len(starts) else len(lines)
|
||||||
|
blocks.append((s, lines[s:e]))
|
||||||
|
return preamble, blocks
|
||||||
|
|
||||||
|
|
||||||
|
def to_unexported(name):
|
||||||
|
"""Lowercase the leading word of an identifier, keeping acronyms whole."""
|
||||||
|
if not name or name[0].islower():
|
||||||
|
return name
|
||||||
|
up = name.upper()
|
||||||
|
for init in sorted(INITIALISMS, key=len, reverse=True):
|
||||||
|
if up.startswith(init) and len(name) >= len(init):
|
||||||
|
return name[: len(init)].lower() + name[len(init):]
|
||||||
|
i = 0
|
||||||
|
while i < len(name) and name[i].isupper():
|
||||||
|
i += 1
|
||||||
|
if i <= 1:
|
||||||
|
return name[0].lower() + name[1:]
|
||||||
|
if i < len(name): # uppercase run followed by lowercase: last upper starts a word
|
||||||
|
return name[: i - 1].lower() + name[i - 1:]
|
||||||
|
return name.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def strip_tokens(name, tokens):
|
||||||
|
"""Remove each backend token from an identifier as a camelCase/acronym segment."""
|
||||||
|
for tok in tokens:
|
||||||
|
low, up, cap = tok.lower(), tok.upper(), tok.capitalize()
|
||||||
|
if name.startswith(low) and (len(name) == len(low) or name[len(low)].isupper()):
|
||||||
|
name = name[len(low):]
|
||||||
|
name = name.replace(up, "")
|
||||||
|
name = re.sub(cap + r"(?=[A-Z]|$)", "", name)
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def target_name(old, exported, tokens):
|
||||||
|
"""Proposed identifier: token stripped, original visibility preserved."""
|
||||||
|
stripped = strip_tokens(old, tokens) or old
|
||||||
|
if exported:
|
||||||
|
return stripped[0].upper() + stripped[1:]
|
||||||
|
return to_unexported(stripped)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(description="Normalize a go-firewall backend's function placement/naming.")
|
||||||
|
ap.add_argument("file", help="backend source file (e.g. csf_linux.go)")
|
||||||
|
ap.add_argument("--type", help="receiver struct type (auto-detected by default)")
|
||||||
|
ap.add_argument("--recv", help="receiver variable name for converted globals (auto-detected)")
|
||||||
|
ap.add_argument("--prefix", help="comma-separated backend token(s) to strip (default: lowercased type)")
|
||||||
|
ap.add_argument("--rename", default="", help="comma-separated old=new overrides for proposed names")
|
||||||
|
ap.add_argument("--apply", action="store_true", help="write changes (default: dry-run plan only)")
|
||||||
|
ap.add_argument("--no-tests", action="store_true", help="do not touch the _test.go file")
|
||||||
|
ap.add_argument("--no-comments", action="store_true", help="do not update bare-name mentions in sibling files")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
path = args.file
|
||||||
|
if not os.path.isfile(path):
|
||||||
|
die("no such file: " + path)
|
||||||
|
pkgdir = os.path.dirname(os.path.abspath(path)) or "."
|
||||||
|
text = open(path).read()
|
||||||
|
|
||||||
|
# Detect the receiver type and its dominant receiver-variable name.
|
||||||
|
recv_types, recv_vars = {}, {}
|
||||||
|
for m in FUNC_RE.finditer(text):
|
||||||
|
rv, rt = m.group(1), m.group(2)
|
||||||
|
if rt:
|
||||||
|
recv_types[rt] = recv_types.get(rt, 0) + 1
|
||||||
|
recv_vars.setdefault(rt, {})
|
||||||
|
recv_vars[rt][rv] = recv_vars[rt].get(rv, 0) + 1
|
||||||
|
typ = args.type or (max(recv_types, key=recv_types.get) if recv_types else None)
|
||||||
|
if not typ:
|
||||||
|
die("could not detect a receiver type; pass --type")
|
||||||
|
recv = args.recv or (max(recv_vars.get(typ, {"f": 1}), key=recv_vars.get(typ, {"f": 1}).get))
|
||||||
|
tokens = [t for t in (args.prefix.split(",") if args.prefix else [typ.lower()]) if t]
|
||||||
|
overrides = dict(p.split("=", 1) for p in args.rename.split(",") if "=" in p)
|
||||||
|
|
||||||
|
# Enumerate this file's top-level declarations.
|
||||||
|
preamble, blocks = split_blocks(text)
|
||||||
|
decls = [] # (name, is_method, recv_var, header_idx)
|
||||||
|
for hidx, blk in blocks:
|
||||||
|
m = FUNC_RE.match(blk[0])
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
decls.append({"name": m.group(3), "method": bool(m.group(2)),
|
||||||
|
"recv": m.group(1), "hidx": hidx})
|
||||||
|
names = {d["name"] for d in decls}
|
||||||
|
|
||||||
|
# Which globals are shared? A global referenced by any sibling .go file other than
|
||||||
|
# this file and its own _test.go must stay global.
|
||||||
|
base = os.path.basename(path)
|
||||||
|
testbase = base[:-3] + "_test.go"
|
||||||
|
shared = set()
|
||||||
|
siblings = [f for f in os.listdir(pkgdir)
|
||||||
|
if f.endswith(".go") and f not in (base, testbase)]
|
||||||
|
for f in siblings:
|
||||||
|
raw = open(os.path.join(pkgdir, f)).read()
|
||||||
|
# Strip line comments so a bare mention in another file's comment does not
|
||||||
|
# masquerade as real cross-backend usage.
|
||||||
|
body = "\n".join(l.split("//", 1)[0] for l in raw.split("\n"))
|
||||||
|
for d in decls:
|
||||||
|
if not d["method"] and re.search(r"\b" + re.escape(d["name"]) + r"\b", body):
|
||||||
|
shared.add(d["name"])
|
||||||
|
|
||||||
|
# Reference graph within this file: which declared names each block's body uses.
|
||||||
|
body_refs = {}
|
||||||
|
for hidx, blk in blocks:
|
||||||
|
m = FUNC_RE.match(blk[0])
|
||||||
|
who = m.group(3)
|
||||||
|
used = set()
|
||||||
|
for line in blk[1:]:
|
||||||
|
code = line.split("//", 1)[0]
|
||||||
|
for n in names:
|
||||||
|
if re.search(r"\b" + re.escape(n) + r"\(", code):
|
||||||
|
used.add(n)
|
||||||
|
body_refs[who] = used
|
||||||
|
method_names = {d["name"] for d in decls if d["method"]}
|
||||||
|
|
||||||
|
# Decide each function's fate.
|
||||||
|
# keep-global : constructor / shared / (guarded) called by a staying global
|
||||||
|
# to-method : backend-only global -> method
|
||||||
|
# rename : method whose name carries the token
|
||||||
|
convert = set() # globals that will become methods
|
||||||
|
for d in decls:
|
||||||
|
if d["method"]:
|
||||||
|
continue
|
||||||
|
if d["name"] == "New" + typ or d["name"] in shared:
|
||||||
|
continue
|
||||||
|
convert.add(d["name"])
|
||||||
|
# A converted global cannot be called from a context with no receiver in scope:
|
||||||
|
# a function that stays a plain global, or a method with an anonymous receiver.
|
||||||
|
# Demote such names back to global until the set is stable.
|
||||||
|
anon_methods = {d["name"] for d in decls if d["method"] and not d["recv"]}
|
||||||
|
changed = True
|
||||||
|
while changed:
|
||||||
|
changed = False
|
||||||
|
no_recv = (shared | {"New" + typ} | anon_methods
|
||||||
|
| {d["name"] for d in decls if not d["method"] and d["name"] not in convert})
|
||||||
|
for g in list(convert):
|
||||||
|
callers = [w for w, u in body_refs.items() if g in u]
|
||||||
|
if any(c in no_recv for c in callers):
|
||||||
|
convert.discard(g)
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
plan = [] # (old, new, action)
|
||||||
|
for d in decls:
|
||||||
|
old = d["name"]
|
||||||
|
if not d["method"]:
|
||||||
|
if old in convert:
|
||||||
|
new = overrides.get(old) or target_name(old, old[0].isupper(), tokens)
|
||||||
|
plan.append((old, new, "global->method"))
|
||||||
|
else:
|
||||||
|
why = "constructor" if old == "New" + typ else ("shared" if old in shared else "kept-global")
|
||||||
|
plan.append((old, old, why))
|
||||||
|
continue
|
||||||
|
# Existing method: strip the token if present, preserve visibility.
|
||||||
|
stripped = strip_tokens(old, tokens)
|
||||||
|
if stripped and stripped != old:
|
||||||
|
new = overrides.get(old) or target_name(old, old[0].isupper(), tokens)
|
||||||
|
plan.append((old, new, "method-rename"))
|
||||||
|
else:
|
||||||
|
plan.append((old, overrides.get(old, old), "method-ok"))
|
||||||
|
|
||||||
|
# Report.
|
||||||
|
rename_map = {o: n for o, n, a in plan if n != o}
|
||||||
|
print(f"# {base}: type=*{typ} recv={recv} tokens={tokens}")
|
||||||
|
print(f"# {len(rename_map)} change(s); {len(shared)} shared global(s) left in place\n")
|
||||||
|
width = max((len(o) for o, _, _ in plan), default=1)
|
||||||
|
for old, new, action in plan:
|
||||||
|
arrow = f"-> {new}" if new != old else ""
|
||||||
|
note = ""
|
||||||
|
if action == "global->method":
|
||||||
|
note = f" (now {recv}.{new})"
|
||||||
|
print(f" [{action:14}] {old:<{width}} {arrow}{note}")
|
||||||
|
# Collisions.
|
||||||
|
seen = {}
|
||||||
|
for old, new, action in plan:
|
||||||
|
if action in ("global->method", "method-rename"):
|
||||||
|
seen.setdefault(new, []).append(old)
|
||||||
|
for new, olds in seen.items():
|
||||||
|
if len(olds) > 1 or new in (method_names - set(rename_map)):
|
||||||
|
print(f" ! WARNING: name collision on {new}: {olds}")
|
||||||
|
|
||||||
|
if not args.apply:
|
||||||
|
print("\n(dry run — re-run with --apply to write changes)")
|
||||||
|
return
|
||||||
|
|
||||||
|
apply_source(path, text, plan, typ, recv, blocks, convert, rename_map)
|
||||||
|
if not args.no_tests:
|
||||||
|
tpath = os.path.join(pkgdir, testbase)
|
||||||
|
if os.path.isfile(tpath):
|
||||||
|
apply_test(tpath, plan, typ, convert, rename_map, recv)
|
||||||
|
if not args.no_comments:
|
||||||
|
for f in siblings:
|
||||||
|
apply_comments(os.path.join(pkgdir, f), rename_map)
|
||||||
|
|
||||||
|
changed_files = [path]
|
||||||
|
if not args.no_tests and os.path.isfile(os.path.join(pkgdir, testbase)):
|
||||||
|
changed_files.append(os.path.join(pkgdir, testbase))
|
||||||
|
subprocess.run(["gofmt", "-w", *changed_files])
|
||||||
|
print(f"\napplied. gofmt'd {len(changed_files)} file(s). Now run: go vet ./... && go test ./...")
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_body(lines, block_recv, convert_new, method_new):
|
||||||
|
"""Rewrite one block's body lines: global-call sites gain a receiver, method
|
||||||
|
renames swap the identifier, and bare comment mentions are renamed."""
|
||||||
|
out = []
|
||||||
|
for line in lines:
|
||||||
|
# Longest-first so no old name is a prefix of another.
|
||||||
|
for old in sorted(convert_new, key=len, reverse=True):
|
||||||
|
new = convert_new[old]
|
||||||
|
if block_recv:
|
||||||
|
line = re.sub(r"\b" + re.escape(old) + r"\(", f"{block_recv}.{new}(", line)
|
||||||
|
line = re.sub(r"\b" + re.escape(old) + r"\b(?!\()", new, line)
|
||||||
|
for old in sorted(method_new, key=len, reverse=True):
|
||||||
|
line = re.sub(r"\b" + re.escape(old) + r"\b", method_new[old], line)
|
||||||
|
out.append(line)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_source(path, text, plan, typ, recv, blocks, convert, rename_map):
|
||||||
|
convert_new = {o: n for o, n, a in plan if a == "global->method"}
|
||||||
|
method_new = {o: n for o, n, a in plan if a == "method-rename"}
|
||||||
|
preamble, blocks = split_blocks(text)
|
||||||
|
out = list(preamble)
|
||||||
|
for hidx, blk in blocks:
|
||||||
|
m = FUNC_RE.match(blk[0])
|
||||||
|
name = m.group(3)
|
||||||
|
header, body = blk[0], blk[1:]
|
||||||
|
if name in convert_new:
|
||||||
|
# Global declaration becomes a method; body callers get the receiver.
|
||||||
|
new = convert_new[name]
|
||||||
|
header = re.sub(r"^func\s+" + re.escape(name) + r"\(",
|
||||||
|
f"func ({recv} *{typ}) {new}(", header)
|
||||||
|
block_recv = recv
|
||||||
|
elif m.group(2): # existing method
|
||||||
|
block_recv = m.group(1)
|
||||||
|
if name in method_new:
|
||||||
|
header = re.sub(r"\b" + re.escape(name) + r"\b", method_new[name], header, count=1)
|
||||||
|
else: # staying global function
|
||||||
|
block_recv = None
|
||||||
|
out.append(header)
|
||||||
|
out.extend(rewrite_body(body, block_recv, convert_new, method_new))
|
||||||
|
open(path, "w").write("\n".join(out))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_test(path, plan, typ, convert, rename_map, recv):
|
||||||
|
"""Rewrite the backend's _test.go. Method renames are plain swaps; a global that
|
||||||
|
became a method needs an instance at each call site — reuse a local one or inject
|
||||||
|
`fw := new(Type)` at the top of the test function."""
|
||||||
|
convert_new = {o: n for o, n, a in plan if a == "global->method"}
|
||||||
|
method_new = {o: n for o, n, a in plan if a == "method-rename"}
|
||||||
|
text = open(path).read()
|
||||||
|
preamble, blocks = split_blocks(text)
|
||||||
|
|
||||||
|
# Preferred instance-variable name: the one tests already use most, else "fw".
|
||||||
|
inst_counts = {}
|
||||||
|
for pat in (r"(\w+)\s*:=\s*new\(" + typ + r"\)", r"(\w+)\s*:=\s*&" + typ + r"\{"):
|
||||||
|
for mm in re.finditer(pat, text):
|
||||||
|
inst_counts[mm.group(1)] = inst_counts.get(mm.group(1), 0) + 1
|
||||||
|
default_inst = max(inst_counts, key=inst_counts.get) if inst_counts else "fw"
|
||||||
|
|
||||||
|
out = list(preamble)
|
||||||
|
for hidx, blk in blocks:
|
||||||
|
header, body = blk[0], list(blk[1:])
|
||||||
|
text_body = "\n".join(body)
|
||||||
|
needs = any(re.search(r"\b" + re.escape(o) + r"\(", text_body) for o in convert_new)
|
||||||
|
# An instance already local to this test function?
|
||||||
|
inst = None
|
||||||
|
for pat in (r"(\w+)\s*:=\s*new\(" + typ + r"\)", r"(\w+)\s*:=\s*&" + typ + r"\{",
|
||||||
|
r"var\s+(\w+)\s+\*?" + typ + r"\b"):
|
||||||
|
mm = re.search(pat, text_body)
|
||||||
|
if mm:
|
||||||
|
inst = mm.group(1)
|
||||||
|
break
|
||||||
|
inject = needs and inst is None
|
||||||
|
if inject:
|
||||||
|
inst = default_inst
|
||||||
|
recv_for_calls = inst # in tests, "receiver" is the instance var
|
||||||
|
new_body = []
|
||||||
|
for line in body:
|
||||||
|
for old in sorted(convert_new, key=len, reverse=True):
|
||||||
|
new = convert_new[old]
|
||||||
|
if recv_for_calls:
|
||||||
|
line = re.sub(r"\b" + re.escape(old) + r"\(", f"{recv_for_calls}.{new}(", line)
|
||||||
|
line = re.sub(r"\b" + re.escape(old) + r"\b(?!\()", new, line)
|
||||||
|
for old in sorted(method_new, key=len, reverse=True):
|
||||||
|
line = re.sub(r"\b" + re.escape(old) + r"\b", method_new[old], line)
|
||||||
|
new_body.append(line)
|
||||||
|
out.append(header)
|
||||||
|
if inject:
|
||||||
|
out.append(f"\t{inst} := new({typ})")
|
||||||
|
out.extend(new_body)
|
||||||
|
open(path, "w").write("\n".join(out))
|
||||||
|
|
||||||
|
|
||||||
|
def apply_comments(path, rename_map):
|
||||||
|
"""Update bare-name mentions of renamed identifiers in a sibling file's comments.
|
||||||
|
Only touches lines that are comments and only replaces the exact old identifier."""
|
||||||
|
if not rename_map:
|
||||||
|
return
|
||||||
|
lines = open(path).read().split("\n")
|
||||||
|
changed = False
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
if "//" not in line:
|
||||||
|
continue
|
||||||
|
code, _, comment = line.partition("//")
|
||||||
|
new_comment = comment
|
||||||
|
for old in sorted(rename_map, key=len, reverse=True):
|
||||||
|
new_comment = re.sub(r"\b" + re.escape(old) + r"\b", rename_map[old], new_comment)
|
||||||
|
if new_comment != comment:
|
||||||
|
lines[i] = code + "//" + new_comment
|
||||||
|
changed = True
|
||||||
|
if changed:
|
||||||
|
open(path, "w").write("\n".join(lines))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
245
scripts/refactor_order.py
Normal file
245
scripts/refactor_order.py
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""refactor_order.py — reorder a go-firewall backend so it matches the Manager interface.
|
||||||
|
|
||||||
|
Ordering rules:
|
||||||
|
1. Types and constants stay at the top (the preamble).
|
||||||
|
2. The New<Type> constructor is placed immediately after the preamble.
|
||||||
|
3. Remaining functions are ordered to match the `type Manager interface` list.
|
||||||
|
4. Any helper must appear before the first function that calls it; dependencies are
|
||||||
|
therefore hoisted in front of the exported method that needs them.
|
||||||
|
|
||||||
|
Doc comments attached to a function are moved together with the function.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
scripts/refactor_order.py apf_linux.go # dry-run to stdout
|
||||||
|
scripts/refactor_order.py apf_linux.go --apply # rewrite in place
|
||||||
|
scripts/refactor_order.py apf_linux.go --apply --gofmt # rewrite and format
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Matches a function declaration together with its immediately preceding
|
||||||
|
# column-0 `//` doc comments. Groups:
|
||||||
|
# 3: receiver variable (None for a plain function)
|
||||||
|
# 4: receiver type (None for a plain function)
|
||||||
|
# 5: function name
|
||||||
|
FUNC_RE = re.compile(
|
||||||
|
r"(?m)^((?://.*\n)*)^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\("
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def split_blocks(text):
|
||||||
|
"""Split source into (preamble, [block_dict, ...]).
|
||||||
|
|
||||||
|
Each block begins with the doc comments immediately preceding a `func` line
|
||||||
|
and ends at the start of the next block, so comments stay attached to their
|
||||||
|
function.
|
||||||
|
"""
|
||||||
|
lines = text.split("\n")
|
||||||
|
func_lines = [i for i, line in enumerate(lines) if line.startswith("func ")]
|
||||||
|
if not func_lines:
|
||||||
|
return text, []
|
||||||
|
|
||||||
|
decl_re = re.compile(r"^func\s+(?:\((\w+)\s+\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*\(")
|
||||||
|
|
||||||
|
def comment_start(func_line):
|
||||||
|
i = func_line - 1
|
||||||
|
# Allow a single blank line between the previous block and the doc comment.
|
||||||
|
if i >= 0 and lines[i].strip() == "":
|
||||||
|
i -= 1
|
||||||
|
while i >= 0 and lines[i].startswith("//"):
|
||||||
|
i -= 1
|
||||||
|
return i + 1
|
||||||
|
|
||||||
|
comment_starts = [comment_start(fl) for fl in func_lines]
|
||||||
|
first_start = comment_starts[0]
|
||||||
|
preamble_lines = lines[:first_start]
|
||||||
|
preamble = "\n".join(preamble_lines)
|
||||||
|
if preamble_lines:
|
||||||
|
preamble += "\n"
|
||||||
|
|
||||||
|
blocks = []
|
||||||
|
for idx, fl in enumerate(func_lines):
|
||||||
|
end_line = func_lines[idx + 1] if idx + 1 < len(func_lines) else len(lines)
|
||||||
|
block_lines = lines[comment_starts[idx] : end_line]
|
||||||
|
block_text = "\n".join(block_lines)
|
||||||
|
if block_text and not block_text.endswith("\n"):
|
||||||
|
block_text += "\n"
|
||||||
|
|
||||||
|
m = decl_re.match(lines[fl])
|
||||||
|
if not m:
|
||||||
|
die(f"could not parse declaration: {lines[fl]}")
|
||||||
|
assert m
|
||||||
|
blocks.append(
|
||||||
|
{
|
||||||
|
"recv_var": m.group(1),
|
||||||
|
"recv_type": m.group(2),
|
||||||
|
"name": m.group(3),
|
||||||
|
"text": block_text,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return preamble, blocks
|
||||||
|
|
||||||
|
|
||||||
|
def parse_manager_interface(interface_path):
|
||||||
|
"""Return the method names of `type Manager interface` in order."""
|
||||||
|
if not os.path.isfile(interface_path):
|
||||||
|
die(f"interface file not found: {interface_path}")
|
||||||
|
text = open(interface_path).read()
|
||||||
|
m = re.search(r"type Manager interface \{(.*?)\n\}", text, re.DOTALL)
|
||||||
|
if not m:
|
||||||
|
die(f"type Manager interface not found in {interface_path}")
|
||||||
|
assert m
|
||||||
|
body = m.group(1)
|
||||||
|
# Strip interface comments so they do not interfere with method detection.
|
||||||
|
body = re.sub(r"(?m)^\s*//.*$", "", body)
|
||||||
|
methods = re.findall(r"^\s+([A-Z]\w+)\s*\(", body, re.MULTILINE)
|
||||||
|
return methods
|
||||||
|
|
||||||
|
|
||||||
|
def detect_backend_type_and_receiver(blocks):
|
||||||
|
"""Detect the dominant backend receiver type and variable name."""
|
||||||
|
recv_counts = {}
|
||||||
|
type_counts = {}
|
||||||
|
for block in blocks:
|
||||||
|
rv = block["recv_var"]
|
||||||
|
rt = block["recv_type"]
|
||||||
|
if rt:
|
||||||
|
recv_counts[rv] = recv_counts.get(rv, 0) + 1
|
||||||
|
type_counts[rt] = type_counts.get(rt, 0) + 1
|
||||||
|
if not type_counts:
|
||||||
|
die("could not detect a backend receiver type")
|
||||||
|
backend_type = max(type_counts, key=lambda k: type_counts[k])
|
||||||
|
receiver = max(recv_counts, key=lambda k: recv_counts[k]) if recv_counts else None
|
||||||
|
return backend_type, receiver
|
||||||
|
|
||||||
|
|
||||||
|
def build_call_graph(blocks, receiver):
|
||||||
|
"""Return a map from function name to the set of in-file functions it calls."""
|
||||||
|
names = {block["name"] for block in blocks}
|
||||||
|
graph = {name: set() for name in names}
|
||||||
|
|
||||||
|
method_call_re = None
|
||||||
|
if receiver:
|
||||||
|
method_call_re = re.compile(rf"\b{re.escape(receiver)}\.([A-Za-z_]\w*)\(")
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
name = block["name"]
|
||||||
|
text = block["text"]
|
||||||
|
deps = graph[name]
|
||||||
|
|
||||||
|
# Receiver method calls: f.name(...)
|
||||||
|
if method_call_re:
|
||||||
|
for mm in method_call_re.finditer(text):
|
||||||
|
called = mm.group(1)
|
||||||
|
if called != name and called in names:
|
||||||
|
deps.add(called)
|
||||||
|
|
||||||
|
# Plain function calls: name(...)
|
||||||
|
# Exclude calls preceded by a dot, since those are method calls on some
|
||||||
|
# other value and are already captured above for the backend receiver.
|
||||||
|
for mm in re.finditer(r"(?<!\.)\b([A-Za-z_]\w*)\(", text):
|
||||||
|
called = mm.group(1)
|
||||||
|
if called != name and called in names:
|
||||||
|
deps.add(called)
|
||||||
|
|
||||||
|
return graph
|
||||||
|
|
||||||
|
|
||||||
|
def reorder(blocks, manager_order, constructor):
|
||||||
|
"""Return blocks in the required order."""
|
||||||
|
backend_type, receiver = detect_backend_type_and_receiver(blocks)
|
||||||
|
graph = build_call_graph(blocks, receiver)
|
||||||
|
name_to_block = {block["name"]: block for block in blocks}
|
||||||
|
names = set(name_to_block.keys())
|
||||||
|
|
||||||
|
if constructor not in names:
|
||||||
|
die(f"constructor {constructor} not found in file")
|
||||||
|
|
||||||
|
missing = [m for m in manager_order if m not in names]
|
||||||
|
if missing:
|
||||||
|
die(f"Manager interface methods missing from file: {missing}")
|
||||||
|
|
||||||
|
order = []
|
||||||
|
placed = set()
|
||||||
|
|
||||||
|
def place(name):
|
||||||
|
if name in placed:
|
||||||
|
return
|
||||||
|
if name not in name_to_block:
|
||||||
|
return
|
||||||
|
for dep in sorted(graph.get(name, set())):
|
||||||
|
place(dep)
|
||||||
|
order.append(name)
|
||||||
|
placed.add(name)
|
||||||
|
|
||||||
|
# 1. Constructor after preamble, before other functions.
|
||||||
|
place(constructor)
|
||||||
|
|
||||||
|
# 2. Manager interface methods in interface order.
|
||||||
|
for method in manager_order:
|
||||||
|
place(method)
|
||||||
|
|
||||||
|
# 3. Any leftover functions (e.g. helper-only internals not reachable from
|
||||||
|
# the public surface) are appended in dependency order.
|
||||||
|
for name in sorted(names):
|
||||||
|
if name not in placed:
|
||||||
|
place(name)
|
||||||
|
|
||||||
|
return [name_to_block[name] for name in order]
|
||||||
|
|
||||||
|
|
||||||
|
def die(msg):
|
||||||
|
print("error: " + msg, file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
ap = argparse.ArgumentParser(
|
||||||
|
description="Reorder a go-firewall backend to match the Manager interface and dependency order."
|
||||||
|
)
|
||||||
|
ap.add_argument("file", help="backend source file (e.g. apf_linux.go)")
|
||||||
|
ap.add_argument(
|
||||||
|
"--interface",
|
||||||
|
default="firewall.go",
|
||||||
|
help="file containing the Manager interface (default: firewall.go)",
|
||||||
|
)
|
||||||
|
ap.add_argument(
|
||||||
|
"--constructor",
|
||||||
|
help="constructor name override (default: New<Type> auto-detected)",
|
||||||
|
)
|
||||||
|
ap.add_argument("--apply", action="store_true", help="rewrite the file in place")
|
||||||
|
ap.add_argument("--gofmt", action="store_true", help="run gofmt after rewriting")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
if not os.path.isfile(args.file):
|
||||||
|
die(f"no such file: {args.file}")
|
||||||
|
|
||||||
|
text = open(args.file).read()
|
||||||
|
preamble, blocks = split_blocks(text)
|
||||||
|
if not blocks:
|
||||||
|
die("no functions found in file")
|
||||||
|
|
||||||
|
backend_type, _ = detect_backend_type_and_receiver(blocks)
|
||||||
|
constructor = args.constructor or ("New" + backend_type)
|
||||||
|
|
||||||
|
manager_order = parse_manager_interface(args.interface)
|
||||||
|
ordered = reorder(blocks, manager_order, constructor)
|
||||||
|
new_text = preamble + "".join(block["text"] for block in ordered)
|
||||||
|
|
||||||
|
if not args.apply:
|
||||||
|
print(new_text, end="")
|
||||||
|
return
|
||||||
|
|
||||||
|
open(args.file, "w").write(new_text)
|
||||||
|
if args.gofmt:
|
||||||
|
subprocess.run(["gofmt", "-w", args.file])
|
||||||
|
print(f"reordered {args.file}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
2
test/integration/.gitignore
vendored
Normal file
2
test/integration/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
# Test binary compiled by host-linux-vm.sh and run natively inside the VM.
|
||||||
|
firewall.test
|
||||||
438
test/integration/guest-linux-run.sh
Executable file
438
test/integration/guest-linux-run.sh
Executable file
|
|
@ -0,0 +1,438 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# IN-VM script — runs inside the disposable VM, launched by cloud-init from
|
||||||
|
# host-linux-vm.sh. Do NOT run it on a workstation: it installs packages and
|
||||||
|
# enables/disables real backends, rewriting /etc/csf, /etc/apf, /etc/ufw, and
|
||||||
|
# iptables config, which would reconfigure a real host. A GOFW_ALLOW_RUN /
|
||||||
|
# disposable-VM-marker guard below refuses to run otherwise.
|
||||||
|
#
|
||||||
|
# Runs the go-firewall integration suite against real backends natively inside the
|
||||||
|
# disposable VM. This script loops sequentially over the requested backends (never
|
||||||
|
# in parallel — they all share the same kernel netfilter/ipset state) and for each
|
||||||
|
# one lazily provisions it on first use, then runs its test:
|
||||||
|
#
|
||||||
|
# ensure_provisioned — if the backend has no clean-config snapshot yet, install
|
||||||
|
# its packages, seed a minimal/clean config, and snapshot
|
||||||
|
# it under /root/gofw-base/<backend>/ (mirroring its real
|
||||||
|
# absolute path). Skipped once a snapshot exists, so a
|
||||||
|
# reused VM overlay provisions nothing.
|
||||||
|
# flush_kernel_state — clear leftover kernel netfilter/ipset state.
|
||||||
|
# restore_config — rsync the clean snapshot back over the live config.
|
||||||
|
# enable_backend — start the backend.
|
||||||
|
# run the test binary — one backend at a time.
|
||||||
|
# disable_backend — stop it again, pass or fail, before the next backend.
|
||||||
|
#
|
||||||
|
# Provisioning per backend (rather than all backends up front) means a run limited
|
||||||
|
# to a subset only pays for the packages it needs — csf and apf in particular pull
|
||||||
|
# from third-party network sources (see fetch_cached), which is slow and flaky, so
|
||||||
|
# a run that never touches them never downloads them.
|
||||||
|
#
|
||||||
|
# To run, use the Makefile:
|
||||||
|
#
|
||||||
|
# make test-integration-linux
|
||||||
|
|
||||||
|
set -u
|
||||||
|
export DEBIAN_FRONTEND=noninteractive
|
||||||
|
|
||||||
|
BASE=/root/gofw-base
|
||||||
|
CACHE_DIR=/mnt/gofw-cache
|
||||||
|
|
||||||
|
# Refuse to run unless the disposable-VM marker is present. Installing packages and
|
||||||
|
# enabling/disabling firewalld/ufw/csf/apf/iptables-persistent operates directly on
|
||||||
|
# the real host's firewall and systemd state, which is dangerous to do by accident
|
||||||
|
# on a workstation. host-linux-vm.sh writes the marker before invoking this script
|
||||||
|
# and exports GOFW_ALLOW_RUN=1 for the guest payload; either is accepted here.
|
||||||
|
MARKER=/etc/gofw-disposable-vm
|
||||||
|
if [[ ! -f "$MARKER" ]] && [[ "${GOFW_ALLOW_RUN:-0}" != "1" ]]; then
|
||||||
|
echo "!! guest-linux-run.sh must be run from inside the disposable test VM, not on your machine." >&2
|
||||||
|
echo " It installs backend packages and enables/disables real backends (firewalld, ufw," >&2
|
||||||
|
echo " csf, apf, iptables-persistent), which would reconfigure a real host's firewall." >&2
|
||||||
|
echo " Use: make test-integration-linux" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
backends=("$@")
|
||||||
|
if [[ ${#backends[@]} -eq 0 ]]; then
|
||||||
|
backends=(nft iptables firewalld ufw csf apf)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# The host compiles the test binary into its .cache, shared here as the writable
|
||||||
|
# gofwcache mount (CACHE_DIR) — the repo share is read-only and holds no artifacts.
|
||||||
|
BIN="$CACHE_DIR/firewall.test"
|
||||||
|
|
||||||
|
# flush_kernel_state clears the shared netfilter/ipset state every backend reads
|
||||||
|
# and writes, so nothing one backend's test leaves behind can leak into the next
|
||||||
|
# backend's run. Run before AND after every backend: before, because a prior run
|
||||||
|
# in this same VM boot may have left rules behind if its test crashed; after,
|
||||||
|
# because none of these tools' own disable is guaranteed to fully clear it.
|
||||||
|
flush_kernel_state() {
|
||||||
|
nft flush ruleset 2>/dev/null || true
|
||||||
|
iptables -F 2>/dev/null || true
|
||||||
|
iptables -t nat -F 2>/dev/null || true
|
||||||
|
iptables -X 2>/dev/null || true
|
||||||
|
ip6tables -F 2>/dev/null || true
|
||||||
|
ip6tables -t nat -F 2>/dev/null || true
|
||||||
|
ip6tables -X 2>/dev/null || true
|
||||||
|
ipset destroy 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# backend_config_dir prints the single config directory a backend snapshots, or
|
||||||
|
# nothing for nft (which has no persisted config).
|
||||||
|
backend_config_dir() {
|
||||||
|
case "$1" in
|
||||||
|
iptables) echo /etc/iptables ;;
|
||||||
|
firewalld) echo /etc/firewalld ;;
|
||||||
|
ufw) echo /etc/ufw ;;
|
||||||
|
csf) echo /etc/csf ;;
|
||||||
|
apf) echo /etc/apf ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# restore_config rsync-restores a backend's clean config snapshot onto its own
|
||||||
|
# live config directory — never onto / itself, which would make --delete treat
|
||||||
|
# every other file on the filesystem as "extraneous" and try to remove it.
|
||||||
|
# --delete removes anything a previous test iteration left behind that
|
||||||
|
# provisioning did not seed (e.g. a rule file rewritten by AddRule).
|
||||||
|
restore_config() {
|
||||||
|
local b="$1"
|
||||||
|
local dir
|
||||||
|
dir="$(backend_config_dir "$b")"
|
||||||
|
[[ -n "$dir" ]] || return 0
|
||||||
|
if [[ -d "$BASE/$b$dir" ]]; then
|
||||||
|
rsync -a --delete "$BASE/$b$dir/" "$dir/"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# snapshot copies each given absolute path into $BASE/<backend>/, preserving the
|
||||||
|
# absolute path underneath so restore_config can restore it with a single `rsync -a`
|
||||||
|
# scoped to that backend's own config directory (never onto / itself). Creating
|
||||||
|
# $BASE/<backend>/ also serves as the backend's "provisioned" marker.
|
||||||
|
snapshot() {
|
||||||
|
local backend="$1"
|
||||||
|
shift
|
||||||
|
mkdir -p "$BASE/$backend"
|
||||||
|
local p
|
||||||
|
for p in "$@"; do
|
||||||
|
mkdir -p "$BASE/$backend/$(dirname "$p")"
|
||||||
|
cp -a "$p" "$BASE/$backend/$p"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# pkg_installed reports whether a package is actually installed (dpkg status
|
||||||
|
# "install ok installed"), not merely known to dpkg — `dpkg -s` exits 0 even for
|
||||||
|
# a removed package whose config files remain (status "deinstall ok
|
||||||
|
# config-files"), which is exactly the state ufw and iptables-persistent leave
|
||||||
|
# each other in when their mutual apt Conflict removes one of them.
|
||||||
|
pkg_installed() {
|
||||||
|
dpkg-query -W -f='${Status}' "$1" 2>/dev/null | grep -q "^install ok installed"
|
||||||
|
}
|
||||||
|
|
||||||
|
# wait_active polls `systemctl is-active` for up to 15s so the test does not race
|
||||||
|
# a daemon that is still settling after `systemctl start` returns.
|
||||||
|
wait_active() {
|
||||||
|
local unit="$1"
|
||||||
|
for _ in $(seq 1 15); do
|
||||||
|
systemctl is-active --quiet "$unit" && return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Provisioning (lazy, per backend)
|
||||||
|
#
|
||||||
|
# Two independent, idempotent concerns, each checked per backend before its test:
|
||||||
|
# - packages: gated on backend_installed (dpkg/binary presence), so the
|
||||||
|
# ufw/iptables-persistent package a conflicting backend's install removed is
|
||||||
|
# reinstalled on demand — the package state, not the config snapshot, answers
|
||||||
|
# "is it installed?".
|
||||||
|
# - config: gated on the $BASE/<backend>/ snapshot dir; seeded and snapshotted
|
||||||
|
# once, then replayed by restore_config before every test.
|
||||||
|
# A reused VM overlay keeps both, so it provisions nothing; a fresh overlay does
|
||||||
|
# only the backends this run actually touches.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
apt_updated=0
|
||||||
|
|
||||||
|
# apt_update_once refreshes the package index a single time per boot, before the
|
||||||
|
# first install. Guarded so provisioning several backends in one run does not
|
||||||
|
# re-run `apt-get update` for each.
|
||||||
|
apt_update_once() {
|
||||||
|
[[ "$apt_updated" = 1 ]] && return 0
|
||||||
|
apt-get update
|
||||||
|
apt_updated=1
|
||||||
|
}
|
||||||
|
|
||||||
|
# fetch_cached downloads url to dest, retrying a few times with a backoff since
|
||||||
|
# csf's download host in particular is known to fail intermittently. A
|
||||||
|
# successful download is copied into CACHE_DIR (a writable 9p share backed by
|
||||||
|
# the host's .cache, vs. the read-only repo share) so a later re-provision — or
|
||||||
|
# a fresh VM overlay — reuses it instead of hitting the network again.
|
||||||
|
fetch_cached() {
|
||||||
|
local url="$1" dest="$2" name="$3"
|
||||||
|
local cached="$CACHE_DIR/$name"
|
||||||
|
if [[ -f "$cached" ]]; then
|
||||||
|
echo ">> using cached $name"
|
||||||
|
cp "$cached" "$dest"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local attempt
|
||||||
|
for attempt in $(seq 1 5); do
|
||||||
|
if wget --tries=3 --timeout=30 -qO "$dest" "$url"; then
|
||||||
|
mkdir -p "$CACHE_DIR"
|
||||||
|
cp "$dest" "$cached"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "!! download of $name failed (attempt $attempt/5); retrying" >&2
|
||||||
|
sleep $((attempt * 5))
|
||||||
|
done
|
||||||
|
echo "!! failed to download $name after 5 attempts" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# backend_installed reports whether a backend's packages are already present. It
|
||||||
|
# gates provision_backend on package state rather than on the config snapshot, so
|
||||||
|
# the ufw/iptables-persistent pair — which apt's mutual Conflict makes
|
||||||
|
# install-one-remove-the-other — is reinstalled by whichever test needs it, even
|
||||||
|
# though its config snapshot already exists. csf and apf are third-party and not
|
||||||
|
# dpkg packages, so they are probed by their installed binary instead.
|
||||||
|
backend_installed() {
|
||||||
|
case "$1" in
|
||||||
|
nft) pkg_installed nftables ;;
|
||||||
|
iptables) pkg_installed netfilter-persistent ;;
|
||||||
|
firewalld) pkg_installed firewalld ;;
|
||||||
|
ufw) pkg_installed ufw ;;
|
||||||
|
csf) command -v csf >/dev/null 2>&1 ;;
|
||||||
|
apf) command -v apf >/dev/null 2>&1 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# provision_backend installs one backend's packages. Called only when
|
||||||
|
# backend_installed reports them missing — on first use, or to reinstall the
|
||||||
|
# ufw/iptables-persistent package a conflicting backend's install removed. It
|
||||||
|
# touches neither daemon state (enable_backend/disable_backend own that) nor config
|
||||||
|
# (ensure_config_snapshot owns that). Runs under `set -e` in a subshell (see
|
||||||
|
# ensure_provisioned) so any failing step fails the backend cleanly.
|
||||||
|
provision_backend() {
|
||||||
|
case "$1" in
|
||||||
|
nft)
|
||||||
|
# nft: kernel-native, no persisted config.
|
||||||
|
apt_update_once
|
||||||
|
apt-get install -y --no-install-recommends nftables
|
||||||
|
;;
|
||||||
|
iptables)
|
||||||
|
# iptables: installed via iptables-persistent. Preseed its debconf autosave
|
||||||
|
# prompts so the noninteractive install does not block waiting for a "save
|
||||||
|
# current rules?" answer. Install netfilter-persistent by name for clarity —
|
||||||
|
# it ships the .service unit the constructor requires, and iptables-persistent
|
||||||
|
# depends on it.
|
||||||
|
#
|
||||||
|
# ufw and iptables-persistent/netfilter-persistent mutually Conflict at the
|
||||||
|
# apt level on this distro (Ubuntu ships ufw preinstalled by default, and
|
||||||
|
# installing either package here auto-removes the other), so only one of the
|
||||||
|
# pair can be installed at a time — hence backend_installed gates this per
|
||||||
|
# test so the one a given backend needs is reinstalled on demand.
|
||||||
|
apt_update_once
|
||||||
|
echo "iptables-persistent iptables-persistent/autosave_v4 boolean false" | debconf-set-selections
|
||||||
|
echo "iptables-persistent iptables-persistent/autosave_v6 boolean false" | debconf-set-selections
|
||||||
|
apt-get install -y --no-install-recommends iptables iptables-persistent netfilter-persistent ipset
|
||||||
|
;;
|
||||||
|
firewalld)
|
||||||
|
# firewalld: available in Ubuntu's universe repo.
|
||||||
|
apt_update_once
|
||||||
|
apt-get install -y --no-install-recommends firewalld
|
||||||
|
;;
|
||||||
|
ufw)
|
||||||
|
# ufw: Ubuntu's default firewall tool.
|
||||||
|
apt_update_once
|
||||||
|
apt-get install -y --no-install-recommends ufw ipset
|
||||||
|
;;
|
||||||
|
csf)
|
||||||
|
# csf (ConfigServer Security & Firewall): third-party Perl package installed
|
||||||
|
# from upstream.
|
||||||
|
apt_update_once
|
||||||
|
apt-get install -y --no-install-recommends perl libwww-perl libio-socket-ssl-perl iptables ipset ca-certificates wget
|
||||||
|
fetch_cached https://download.configserver.dev/csf.tgz /tmp/csf.tgz csf.tgz
|
||||||
|
tar -xzf /tmp/csf.tgz -C /tmp
|
||||||
|
(cd /tmp/csf && sh install.sh)
|
||||||
|
rm -rf /tmp/csf /tmp/csf.tgz
|
||||||
|
;;
|
||||||
|
apf)
|
||||||
|
# apf (Advanced Policy Firewall): third-party package from rfxn upstream.
|
||||||
|
apt_update_once
|
||||||
|
apt-get install -y --no-install-recommends iproute2 kmod ca-certificates wget
|
||||||
|
fetch_cached https://github.com/rfxn/advanced-policy-firewall/archive/refs/heads/master.tar.gz /tmp/apf.tar.gz apf.tar.gz
|
||||||
|
tar -xzf /tmp/apf.tar.gz -C /tmp
|
||||||
|
(cd /tmp/advanced-policy-firewall-master && bash install.sh)
|
||||||
|
rm -rf /tmp/apf.tar.gz /tmp/advanced-policy-firewall-master
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "!! unknown backend '$1'" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ensure_config_snapshot seeds a backend's minimal/clean config and snapshots it
|
||||||
|
# under $BASE/<backend>/ the first time it is needed, then is a no-op (the snapshot
|
||||||
|
# dir is the marker). Must run after provision_backend, since it edits config files
|
||||||
|
# the package install creates. restore_config replays this snapshot before every
|
||||||
|
# test, so a package reinstalled after a conflict always has its default config
|
||||||
|
# overwritten by the clean seed. Leaves every backend disabled — the loop's
|
||||||
|
# enable/disable owns daemon state, so nothing here starts or stops a service.
|
||||||
|
ensure_config_snapshot() {
|
||||||
|
local b="$1"
|
||||||
|
[[ -d "$BASE/$b" ]] && return 0
|
||||||
|
echo ">> [$b] seeding and snapshotting clean config"
|
||||||
|
case "$b" in
|
||||||
|
nft)
|
||||||
|
# nft has no persisted config; the snapshot dir is just a uniformity marker.
|
||||||
|
mkdir -p "$BASE/nft"
|
||||||
|
;;
|
||||||
|
iptables)
|
||||||
|
mkdir -p /etc/iptables
|
||||||
|
printf '*filter\n:INPUT ACCEPT [0:0]\n:FORWARD ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\nCOMMIT\n*nat\n:PREROUTING ACCEPT [0:0]\n:INPUT ACCEPT [0:0]\n:OUTPUT ACCEPT [0:0]\n:POSTROUTING ACCEPT [0:0]\nCOMMIT\n' \
|
||||||
|
| tee /etc/iptables/rules.v4 >/etc/iptables/rules.v6
|
||||||
|
snapshot iptables /etc/iptables/rules.v4 /etc/iptables/rules.v6
|
||||||
|
;;
|
||||||
|
firewalld)
|
||||||
|
snapshot firewalld /etc/firewalld
|
||||||
|
;;
|
||||||
|
ufw)
|
||||||
|
sed -i 's/^ENABLED=.*/ENABLED=no/' /etc/ufw/ufw.conf
|
||||||
|
snapshot ufw /etc/ufw
|
||||||
|
;;
|
||||||
|
csf)
|
||||||
|
sed -i 's/^TESTING = .*/TESTING = "0"/' /etc/csf/csf.conf
|
||||||
|
rm -f /etc/csf/csf.disable
|
||||||
|
snapshot csf /etc/csf
|
||||||
|
;;
|
||||||
|
apf)
|
||||||
|
sed -i 's/^DEVEL_MODE=.*/DEVEL_MODE="0"/' /etc/apf/conf.apf
|
||||||
|
snapshot apf /etc/apf
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ensure_provisioned makes a backend ready to test: installs its packages if
|
||||||
|
# backend_installed reports them missing, then seeds+snapshots its clean config if
|
||||||
|
# that has not been done yet. Each step runs in a `set -e` subshell so a mid-step
|
||||||
|
# failure aborts that step as a unit under this script's loop-friendly `set -u`,
|
||||||
|
# while staying contained to the one backend rather than aborting the whole run.
|
||||||
|
ensure_provisioned() {
|
||||||
|
local b="$1"
|
||||||
|
if ! backend_installed "$b"; then
|
||||||
|
echo ">> [$b] installing packages"
|
||||||
|
( set -eo pipefail; provision_backend "$b" ) || return 1
|
||||||
|
fi
|
||||||
|
( set -eo pipefail; ensure_config_snapshot "$b" ) || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# enable_backend starts a backend's daemon against the clean config just restored.
|
||||||
|
# It restarts (rather than starts) the daemon backends to ensure the restored
|
||||||
|
# config is re-read if the service was already in a running state.
|
||||||
|
enable_backend() {
|
||||||
|
case "$1" in
|
||||||
|
nft) : ;; # kernel-native; nothing to enable
|
||||||
|
iptables)
|
||||||
|
# The unit's constructor requires UnitFileState=="enabled", so enable before
|
||||||
|
# restarting; daemon-reload picks up the generated unit for this SysV-only service.
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl enable netfilter-persistent.service
|
||||||
|
systemctl restart netfilter-persistent.service
|
||||||
|
;;
|
||||||
|
firewalld)
|
||||||
|
# Debian/Ubuntu ships firewalld.service masked by default (it conflicts
|
||||||
|
# with the distro's default iptables/ufw setup), so it must be unmasked
|
||||||
|
# before it can start at all.
|
||||||
|
systemctl unmask firewalld.service 2>/dev/null || true
|
||||||
|
systemctl enable firewalld.service
|
||||||
|
systemctl restart firewalld.service && wait_active firewalld.service
|
||||||
|
;;
|
||||||
|
ufw)
|
||||||
|
# restore_config seeded ENABLED=no; flip it on for the test. No restart
|
||||||
|
# needed: `ufw --force enable` runs a full flush-and-re-apply even when ufw
|
||||||
|
# is already up, so it is already a restart.
|
||||||
|
sed -i 's/^ENABLED=.*/ENABLED=yes/' /etc/ufw/ufw.conf
|
||||||
|
systemctl enable ufw.service
|
||||||
|
ufw --force enable
|
||||||
|
;;
|
||||||
|
csf)
|
||||||
|
systemctl enable csf.service
|
||||||
|
systemctl restart csf.service && wait_active csf.service
|
||||||
|
;;
|
||||||
|
apf)
|
||||||
|
systemctl enable apf.service
|
||||||
|
systemctl restart apf.service && wait_active apf.service
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# disable_backend stops and disables a backend's daemon so it cannot interfere with
|
||||||
|
# the next backend's test. Called after every run, pass or fail, and errors are
|
||||||
|
# ignored since the goal is only to leave the daemon down.
|
||||||
|
disable_backend() {
|
||||||
|
case "$1" in
|
||||||
|
nft) : ;;
|
||||||
|
iptables) systemctl disable --now netfilter-persistent.service 2>/dev/null || true ;;
|
||||||
|
firewalld) systemctl disable --now firewalld.service 2>/dev/null || true ;;
|
||||||
|
ufw)
|
||||||
|
ufw disable 2>/dev/null || true
|
||||||
|
systemctl disable ufw.service 2>/dev/null || true
|
||||||
|
;;
|
||||||
|
csf) systemctl disable --now csf.service lfd.service 2>/dev/null || true ;;
|
||||||
|
apf) systemctl disable --now apf.service 2>/dev/null || true ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
declare -A result
|
||||||
|
overall=0
|
||||||
|
|
||||||
|
# Loop through requested backends and test.
|
||||||
|
for b in "${backends[@]}"; do
|
||||||
|
# Provision on first use; a provisioning failure is contained to this backend.
|
||||||
|
if ! ensure_provisioned "$b"; then
|
||||||
|
echo "!! [$b] provisioning failed"
|
||||||
|
result[$b]="PROVISION-FAIL"
|
||||||
|
overall=1
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> [$b] resetting kernel state and restoring clean config"
|
||||||
|
flush_kernel_state
|
||||||
|
restore_config "$b"
|
||||||
|
|
||||||
|
echo ">> [$b] enabling backend"
|
||||||
|
if ! enable_backend "$b"; then
|
||||||
|
echo "!! [$b] enable failed"
|
||||||
|
result[$b]="ENABLE-FAIL"
|
||||||
|
overall=1
|
||||||
|
disable_backend "$b"
|
||||||
|
flush_kernel_state
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> [$b] running integration test"
|
||||||
|
FIREWALL_BACKEND="$b" "$BIN" -test.v -test.run TestIntegration
|
||||||
|
rc=$?
|
||||||
|
|
||||||
|
# Always disable, even on test failure, so one backend's failure never leaves
|
||||||
|
# it running to interfere with the next backend's test.
|
||||||
|
echo ">> [$b] disabling backend"
|
||||||
|
disable_backend "$b"
|
||||||
|
flush_kernel_state
|
||||||
|
|
||||||
|
if [[ $rc -eq 0 ]]; then
|
||||||
|
result[$b]="PASS"
|
||||||
|
else
|
||||||
|
result[$b]="FAIL($rc)"
|
||||||
|
overall=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== integration results ===="
|
||||||
|
for b in "${backends[@]}"; do
|
||||||
|
printf " %-10s %s\n" "$b" "${result[$b]:-SKIPPED}"
|
||||||
|
done
|
||||||
|
exit $overall
|
||||||
196
test/integration/host-freebsd-vm.sh
Executable file
196
test/integration/host-freebsd-vm.sh
Executable file
|
|
@ -0,0 +1,196 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# HOST script — run this on your workstation. It boots a throwaway FreeBSD QEMU VM
|
||||||
|
# and runs the pf integration backend inside it.
|
||||||
|
#
|
||||||
|
# FreeBSD's pf runs natively, so the VM *is* the test environment: it
|
||||||
|
# enables pf with a minimal ruleset, mounts the freebsd-cross-compiled test binary
|
||||||
|
# from the host over a virtio-9p share, and runs it with FIREWALL_BACKEND=pf. The
|
||||||
|
# same pf backend serves macOS, which cannot be automated in a VM.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./host-freebsd-vm.sh
|
||||||
|
#
|
||||||
|
# The overlay disk is kept between runs by default. pf state lives in the kernel and
|
||||||
|
# is cleared on every reboot (reloaded from /etc/pf.conf), so reuse just needs a
|
||||||
|
# fresh cloud-init seed with a unique instance-id each run so cloud-init executes
|
||||||
|
# the test payload on every boot. Set GOFW_VM_REUSE=0 to force a fresh overlay each
|
||||||
|
# run; deleting .cache/freebsd-overlay.qcow2 also resets it.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo="$(cd "$here/../.." && pwd)"
|
||||||
|
|
||||||
|
IMAGE_URL="${GOFW_FBSD_IMAGE_URL:-https://download.freebsd.org/releases/VM-IMAGES/15.1-RELEASE/amd64/Latest/FreeBSD-15.1-RELEASE-amd64-BASIC-CLOUDINIT-ufs.qcow2.xz}"
|
||||||
|
MEM="${GOFW_VM_MEM:-2048}"
|
||||||
|
CPUS="${GOFW_VM_CPUS:-2}"
|
||||||
|
CACHE="${GOFW_VM_CACHE:-$repo/.cache}"
|
||||||
|
REUSE="${GOFW_VM_REUSE:-1}" # 1 = keep the overlay disk between runs (default)
|
||||||
|
BOOT_TIMEOUT="${GOFW_VM_TIMEOUT:-1200}"
|
||||||
|
SHUTDOWN_GRACE="${GOFW_VM_SHUTDOWN_GRACE:-60}" # seconds to wait for a clean poweroff before killing
|
||||||
|
|
||||||
|
mkdir -p "$CACHE"
|
||||||
|
base="$CACHE/freebsd.qcow2"
|
||||||
|
overlay="$CACHE/freebsd-overlay.qcow2"
|
||||||
|
seed="$CACHE/freebsd-seed.iso"
|
||||||
|
console="$CACHE/freebsd-console.log"
|
||||||
|
bin="$CACHE/firewall.test.freebsd"
|
||||||
|
|
||||||
|
command -v qemu-system-x86_64 >/dev/null || { echo "!! qemu-system-x86_64 not found"; exit 1; }
|
||||||
|
command -v genisoimage >/dev/null || { echo "!! genisoimage not found"; exit 1; }
|
||||||
|
|
||||||
|
echo ">> cross-compiling freebsd test binary on host"
|
||||||
|
( cd "$repo" && GOOS=freebsd GOARCH=amd64 CGO_ENABLED=0 go test -c -tags integration -o "$bin" . )
|
||||||
|
|
||||||
|
if [[ ! -f "$base" ]]; then
|
||||||
|
echo ">> downloading FreeBSD cloud image -> $base"
|
||||||
|
curl -L --fail -o "$base.xz" "$IMAGE_URL"
|
||||||
|
xz -dc "$base.xz" >"$base"
|
||||||
|
rm -f "$base.xz"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reuse the overlay only when asked and one already exists; otherwise start fresh.
|
||||||
|
if [[ "$REUSE" = 1 ]] && [[ -f "$overlay" ]]; then
|
||||||
|
echo ">> reusing existing overlay disk (GOFW_VM_REUSE=1): $overlay"
|
||||||
|
else
|
||||||
|
echo ">> creating a fresh overlay disk (base stays pristine)"
|
||||||
|
rm -f "$overlay"
|
||||||
|
qemu-img create -f qcow2 -b "$base" -F qcow2 "$overlay" 20G >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A fresh cloud-init seed is generated for every run. The unique instance-id makes
|
||||||
|
# each boot look like a new instance to cloud-init, so per-instance modules
|
||||||
|
# (write_files and runcmd) execute on both the first and every reused boot.
|
||||||
|
run_id="$(date +%s)-${RANDOM}"
|
||||||
|
instance_id="gofw-it-fbsd-${run_id}"
|
||||||
|
|
||||||
|
work="$(mktemp -d "$CACHE/fbsd-seed.XXXXXX")"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
cat >"$work/meta-data" <<EOF
|
||||||
|
instance-id: ${instance_id}
|
||||||
|
local-hostname: gofw-it-fbsd
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >"$work/user-data" <<EOF
|
||||||
|
#cloud-config
|
||||||
|
bootcmd:
|
||||||
|
# Skip the FreeBSD cloud image's first-boot freebsd-update/pkg fetch — it adds
|
||||||
|
# minutes of patch downloads and is irrelevant to the test. Runs before the
|
||||||
|
# firstboot rc scripts (a fresh overlay re-triggers them every run otherwise).
|
||||||
|
- [ rm, -f, /firstboot ]
|
||||||
|
write_files:
|
||||||
|
- path: /etc/pf.conf
|
||||||
|
content: |
|
||||||
|
set skip on lo0
|
||||||
|
runcmd:
|
||||||
|
- sysrc pf_enable=YES
|
||||||
|
- service pf start
|
||||||
|
- sh -c 'kldload virtio_p9fs 2>/dev/null || true'
|
||||||
|
- mkdir -p /mnt/gofw-cache
|
||||||
|
- mount -t p9fs gofwcache /mnt/gofw-cache
|
||||||
|
- cp /mnt/gofw-cache/firewall.test.freebsd /root/firewall.test
|
||||||
|
- chmod +x /root/firewall.test
|
||||||
|
- sh -c 'echo GOFW_VM_BEGIN; env FIREWALL_BACKEND=pf /root/firewall.test -test.v -test.run TestIntegration; echo "GOFW_VM_DONE rc=\$?"'
|
||||||
|
- poweroff
|
||||||
|
EOF
|
||||||
|
|
||||||
|
genisoimage -quiet -output "$seed" -volid CIDATA -joliet -rock \
|
||||||
|
"$work/user-data" "$work/meta-data"
|
||||||
|
|
||||||
|
echo ">> booting FreeBSD VM (serial console below; it powers off when finished)"
|
||||||
|
echo " Ctrl-C requests a clean ACPI shutdown (force-killed after ${SHUTDOWN_GRACE}s)."
|
||||||
|
echo " ------------------------------------------------------------------"
|
||||||
|
: >"$console"
|
||||||
|
|
||||||
|
# HMP monitor on a localhost-only, transient TCP port so the interrupt/timeout
|
||||||
|
# handlers can ask the guest to power off cleanly, poked via bash /dev/tcp.
|
||||||
|
mon_port=$(( 20000 + RANDOM % 20000 ))
|
||||||
|
|
||||||
|
# Stream serial through a FIFO so we hold qemu's real PID and tee flushes the log
|
||||||
|
# deterministically before we read it.
|
||||||
|
fifo="$work/qemu.out"
|
||||||
|
mkfifo "$fifo"
|
||||||
|
tee "$console" <"$fifo" &
|
||||||
|
tee_pid=$!
|
||||||
|
|
||||||
|
qemu_pid=""
|
||||||
|
interrupted=0
|
||||||
|
timed_out=0
|
||||||
|
|
||||||
|
# stop_vm asks the guest for a clean ACPI poweroff, waits SHUTDOWN_GRACE seconds,
|
||||||
|
# then hard-kills if it overruns. Idempotent; safe to call more than once.
|
||||||
|
stop_vm() {
|
||||||
|
kill -0 "$qemu_pid" 2>/dev/null || return 0
|
||||||
|
if exec 3<>"/dev/tcp/127.0.0.1/$mon_port" 2>/dev/null; then
|
||||||
|
printf 'system_powerdown\n' >&3
|
||||||
|
exec 3<&- 3>&-
|
||||||
|
fi
|
||||||
|
for _ in $(seq 1 "$SHUTDOWN_GRACE"); do
|
||||||
|
kill -0 "$qemu_pid" 2>/dev/null || return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "!! VM did not power off within ${SHUTDOWN_GRACE}s; killing it" >&2
|
||||||
|
kill -9 "$qemu_pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# on_interrupt drives a clean shutdown on Ctrl-C; further signals are ignored so a
|
||||||
|
# second Ctrl-C cannot abort mid-shutdown and orphan qemu. Invoked via the trap below.
|
||||||
|
# shellcheck disable=SC2329 # invoked indirectly by `trap on_interrupt INT TERM`.
|
||||||
|
on_interrupt() {
|
||||||
|
trap '' INT TERM
|
||||||
|
interrupted=1
|
||||||
|
echo >&2
|
||||||
|
echo ">> interrupt received; asking the VM to power off cleanly…" >&2
|
||||||
|
stop_vm
|
||||||
|
}
|
||||||
|
trap on_interrupt INT TERM
|
||||||
|
|
||||||
|
set +e
|
||||||
|
qemu-system-x86_64 \
|
||||||
|
-enable-kvm -cpu host -m "$MEM" -smp "$CPUS" \
|
||||||
|
-drive file="$overlay",if=virtio,format=qcow2 \
|
||||||
|
-drive file="$seed",if=virtio,format=raw \
|
||||||
|
-netdev user,id=n0 -device virtio-net-pci,netdev=n0 \
|
||||||
|
-fsdev local,id=fs0,path="$CACHE",security_model=none,readonly=on \
|
||||||
|
-device virtio-9p-pci,fsdev=fs0,mount_tag=gofwcache \
|
||||||
|
-monitor tcp:127.0.0.1:"$mon_port",server,nowait \
|
||||||
|
-serial stdio -display none -no-reboot </dev/null >"$fifo" 2>&1 &
|
||||||
|
qemu_pid=$!
|
||||||
|
|
||||||
|
# Wait for the VM, enforcing the boot timeout, while staying interruptible.
|
||||||
|
deadline=$(( SECONDS + BOOT_TIMEOUT ))
|
||||||
|
while kill -0 "$qemu_pid" 2>/dev/null; do
|
||||||
|
if [[ "$SECONDS" -ge "$deadline" ]]; then
|
||||||
|
timed_out=1
|
||||||
|
echo >&2
|
||||||
|
echo "!! VM exceeded ${BOOT_TIMEOUT}s; shutting it down" >&2
|
||||||
|
stop_vm
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2 & wait $! 2>/dev/null
|
||||||
|
done
|
||||||
|
wait "$qemu_pid" 2>/dev/null
|
||||||
|
trap - INT TERM
|
||||||
|
wait "$tee_pid" 2>/dev/null # let tee drain the FIFO and flush the console log
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo " ------------------------------------------------------------------"
|
||||||
|
if [[ "$interrupted" = 1 ]]; then
|
||||||
|
echo "!! run interrupted; VM stopped. Partial serial log: $console"
|
||||||
|
exit 130
|
||||||
|
fi
|
||||||
|
if [[ "$timed_out" = 1 ]]; then
|
||||||
|
echo "!! VM timed out after ${BOOT_TIMEOUT}s (see $console)"
|
||||||
|
exit 124
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "==== FreeBSD pf run summary ===="
|
||||||
|
if grep -q "GOFW_VM_DONE" "$console"; then
|
||||||
|
grep -E -- '--- (PASS|FAIL|SKIP): |^(PASS|FAIL|ok)\b|GOFW_VM_DONE' "$console" | sed 's/\r$//'
|
||||||
|
rc="$(grep -o 'GOFW_VM_DONE rc=[0-9]*' "$console" | tail -1 | grep -o '[0-9]*$')"
|
||||||
|
echo "(full serial log: $console)"
|
||||||
|
exit "${rc:-1}"
|
||||||
|
fi
|
||||||
|
echo "!! the in-VM run did not complete (no GOFW_VM_DONE marker). See $console"
|
||||||
|
exit 1
|
||||||
230
test/integration/host-linux-vm.sh
Executable file
230
test/integration/host-linux-vm.sh
Executable file
|
|
@ -0,0 +1,230 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# HOST script — run this on your workstation. It boots a throwaway QEMU VM and
|
||||||
|
# runs the Linux integration backends inside it.
|
||||||
|
#
|
||||||
|
# Why a VM: the systemd backends (firewalld/ufw/iptables/csf/apf) need a booted
|
||||||
|
# systemd and, for real isolation between six backends that all fight over the same
|
||||||
|
# kernel netfilter/ipset state, a disposable environment we can freely reconfigure.
|
||||||
|
# We run a minimal Ubuntu server cloud-init image in qemu for that disposability,
|
||||||
|
# same as we would need even if the backends ran natively on bare metal.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./host-linux-vm.sh # all backends: nft firewalld ufw iptables apf csf
|
||||||
|
# ./host-linux-vm.sh firewalld # a subset
|
||||||
|
#
|
||||||
|
# The overlay disk is kept between runs by default: the first run creates it and
|
||||||
|
# provisions each requested backend on first use (package installs, third-party
|
||||||
|
# csf/apf downloads); later runs boot the same disk with those backends already
|
||||||
|
# provisioned and go straight to testing. Because provisioning is per-backend and
|
||||||
|
# keyed on each backend's clean-config snapshot, a fresh overlay only pays for the
|
||||||
|
# backends a given run actually touches, and a later run adds any not-yet-seen
|
||||||
|
# backend on demand. A fresh cloud-init seed is generated every run with a unique
|
||||||
|
# instance-id, so cloud-init executes the test payload on every boot. Backend
|
||||||
|
# firewall state does not persist across backends within a run — guest-linux-run.sh
|
||||||
|
# flushes and rsync-restores clean config between every backend. Set GOFW_VM_REUSE=0
|
||||||
|
# to force a fresh overlay each run; deleting .cache/overlay.qcow2 also resets it.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo="$(cd "$here/../.." && pwd)"
|
||||||
|
|
||||||
|
backends=("$@")
|
||||||
|
if [[ ${#backends[@]} -eq 0 ]]; then
|
||||||
|
backends=(nft firewalld ufw iptables apf csf)
|
||||||
|
fi
|
||||||
|
|
||||||
|
IMAGE_URL="${GOFW_VM_IMAGE_URL:-https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img}"
|
||||||
|
MEM="${GOFW_VM_MEM:-4096}"
|
||||||
|
CPUS="${GOFW_VM_CPUS:-2}"
|
||||||
|
CACHE="${GOFW_VM_CACHE:-$repo/.cache}"
|
||||||
|
REUSE="${GOFW_VM_REUSE:-1}" # 1 = keep the overlay disk between runs (default)
|
||||||
|
BOOT_TIMEOUT="${GOFW_VM_TIMEOUT:-2400}" # seconds; covers apt installs, csf/apf downloads, and tests
|
||||||
|
SHUTDOWN_GRACE="${GOFW_VM_SHUTDOWN_GRACE:-60}" # seconds to wait for a clean poweroff before killing
|
||||||
|
|
||||||
|
mkdir -p "$CACHE"
|
||||||
|
base="$CACHE/base.img"
|
||||||
|
overlay="$CACHE/overlay.qcow2"
|
||||||
|
seed="$CACHE/seed.iso"
|
||||||
|
console="$CACHE/console.log"
|
||||||
|
bin="$CACHE/firewall.test"
|
||||||
|
|
||||||
|
command -v qemu-system-x86_64 >/dev/null || { echo "!! qemu-system-x86_64 not found"; exit 1; }
|
||||||
|
command -v genisoimage >/dev/null || { echo "!! genisoimage not found"; exit 1; }
|
||||||
|
|
||||||
|
echo ">> compiling test binary on host (Go toolchain lives here, not in the VM)"
|
||||||
|
# Write into $CACHE (shared into the guest as the writable gofwcache mount), not the
|
||||||
|
# repo tree — the repo is shared read-only and should stay free of build artifacts.
|
||||||
|
( cd "$repo" && CGO_ENABLED=0 go test -c -tags integration -o "$bin" . )
|
||||||
|
|
||||||
|
if [[ ! -f "$base" ]]; then
|
||||||
|
echo ">> downloading base cloud image -> $base"
|
||||||
|
curl -L --fail -o "$base.tmp" "$IMAGE_URL"
|
||||||
|
mv "$base.tmp" "$base"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reuse the overlay only when asked and one already exists; otherwise start fresh.
|
||||||
|
# guest-linux-run.sh provisions each backend lazily and keys the "already
|
||||||
|
# provisioned" check on its per-backend snapshot, so a reused overlay's snapshots
|
||||||
|
# simply cause it to skip re-provisioning — the host does not need to track or
|
||||||
|
# signal fresh-vs-reused at all.
|
||||||
|
if [[ "$REUSE" = 1 ]] && [[ -f "$overlay" ]]; then
|
||||||
|
echo ">> reusing existing overlay disk (GOFW_VM_REUSE=1): $overlay"
|
||||||
|
else
|
||||||
|
echo ">> creating a fresh overlay disk (base stays pristine)"
|
||||||
|
rm -f "$overlay"
|
||||||
|
qemu-img create -f qcow2 -b "$base" -F qcow2 "$overlay" 20G >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# A fresh cloud-init seed is generated for every run. The unique instance-id makes
|
||||||
|
# each boot look like a new instance to cloud-init, so per-instance modules
|
||||||
|
# (write_files and runcmd) execute on both the first and every reused boot.
|
||||||
|
run_id="$(date +%s)-${RANDOM}"
|
||||||
|
instance_id="gofw-it-${run_id}"
|
||||||
|
|
||||||
|
# --- cloud-init seed --------------------------------------------------------
|
||||||
|
work="$(mktemp -d "$CACHE/seed.XXXXXX")"
|
||||||
|
trap 'rm -rf "$work"' EXIT
|
||||||
|
|
||||||
|
cat >"$work/meta-data" <<EOF
|
||||||
|
instance-id: ${instance_id}
|
||||||
|
local-hostname: gofw-it
|
||||||
|
EOF
|
||||||
|
|
||||||
|
cat >"$work/user-data" <<EOF
|
||||||
|
#cloud-config
|
||||||
|
write_files:
|
||||||
|
- path: /usr/local/bin/gofw-guest-payload.sh
|
||||||
|
permissions: '0755'
|
||||||
|
content: |
|
||||||
|
#!/bin/bash
|
||||||
|
set -x
|
||||||
|
# Authorize the in-VM guest script to run its destructive backend setup.
|
||||||
|
# guest-linux-run.sh refuses to run without this (or the disposable-VM
|
||||||
|
# marker below), so a workstation cannot execute it by accident.
|
||||||
|
export GOFW_ALLOW_RUN=1
|
||||||
|
mkdir -p /mnt/gofw /mnt/gofw-cache
|
||||||
|
mount -t 9p -o trans=virtio,version=9p2000.L,ro gofwrepo /mnt/gofw
|
||||||
|
mount -t 9p -o trans=virtio,version=9p2000.L,rw gofwcache /mnt/gofw-cache
|
||||||
|
cd /mnt/gofw
|
||||||
|
touch /etc/gofw-disposable-vm
|
||||||
|
echo "GOFW_VM_BEGIN"
|
||||||
|
./test/integration/guest-linux-run.sh ${backends[*]}
|
||||||
|
echo "GOFW_VM_DONE rc=\$?"
|
||||||
|
sync
|
||||||
|
poweroff
|
||||||
|
runcmd:
|
||||||
|
- [ bash, /usr/local/bin/gofw-guest-payload.sh ]
|
||||||
|
EOF
|
||||||
|
|
||||||
|
genisoimage -quiet -output "$seed" -volid CIDATA -joliet -rock \
|
||||||
|
"$work/user-data" "$work/meta-data"
|
||||||
|
|
||||||
|
# --- boot -------------------------------------------------------------------
|
||||||
|
echo ">> booting VM (serial console below; it powers off when finished)"
|
||||||
|
echo " backends: ${backends[*]}"
|
||||||
|
echo " Ctrl-C requests a clean ACPI shutdown (force-killed after ${SHUTDOWN_GRACE}s)."
|
||||||
|
echo " ------------------------------------------------------------------"
|
||||||
|
: >"$console"
|
||||||
|
|
||||||
|
# HMP monitor on a localhost-only, transient TCP port so the interrupt/timeout
|
||||||
|
# handlers can ask the guest to power off cleanly, poked via bash /dev/tcp (no
|
||||||
|
# extra tools). A clean ACPI shutdown matters now that the overlay is persisted.
|
||||||
|
mon_port=$(( 20000 + RANDOM % 20000 ))
|
||||||
|
|
||||||
|
# Stream serial through a FIFO instead of a pipeline so we hold qemu's real PID
|
||||||
|
# (to signal it) and tee flushes the log deterministically before we read it.
|
||||||
|
fifo="$work/qemu.out"
|
||||||
|
mkfifo "$fifo"
|
||||||
|
tee "$console" <"$fifo" &
|
||||||
|
tee_pid=$!
|
||||||
|
|
||||||
|
qemu_pid=""
|
||||||
|
interrupted=0
|
||||||
|
timed_out=0
|
||||||
|
|
||||||
|
# stop_vm asks the guest for a clean ACPI poweroff, waits SHUTDOWN_GRACE seconds,
|
||||||
|
# then hard-kills if it overruns. Idempotent; safe to call more than once.
|
||||||
|
stop_vm() {
|
||||||
|
kill -0 "$qemu_pid" 2>/dev/null || return 0
|
||||||
|
if exec 3<>"/dev/tcp/127.0.0.1/$mon_port" 2>/dev/null; then
|
||||||
|
printf 'system_powerdown\n' >&3
|
||||||
|
exec 3<&- 3>&-
|
||||||
|
fi
|
||||||
|
for _ in $(seq 1 "$SHUTDOWN_GRACE"); do
|
||||||
|
kill -0 "$qemu_pid" 2>/dev/null || return 0
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
echo "!! VM did not power off within ${SHUTDOWN_GRACE}s; killing it" >&2
|
||||||
|
kill -9 "$qemu_pid" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# on_interrupt drives a clean shutdown on Ctrl-C; further signals are ignored so a
|
||||||
|
# second Ctrl-C cannot abort mid-shutdown and orphan qemu. Invoked via the trap below.
|
||||||
|
# shellcheck disable=SC2329 # invoked indirectly by `trap on_interrupt INT TERM`.
|
||||||
|
on_interrupt() {
|
||||||
|
trap '' INT TERM
|
||||||
|
interrupted=1
|
||||||
|
echo >&2
|
||||||
|
echo ">> interrupt received; asking the VM to power off cleanly…" >&2
|
||||||
|
stop_vm
|
||||||
|
}
|
||||||
|
trap on_interrupt INT TERM
|
||||||
|
|
||||||
|
# fs1/gofwcache is writable (unlike fs0/gofwrepo) and backed by the same host
|
||||||
|
# directory used for the VM images, so guest-linux-run.sh can cache downloaded
|
||||||
|
# csf/apf packages there during provisioning and reuse them across VM overlay
|
||||||
|
# rebuilds instead of re-fetching from the network every time.
|
||||||
|
set +e
|
||||||
|
qemu-system-x86_64 \
|
||||||
|
-enable-kvm -cpu host -m "$MEM" -smp "$CPUS" \
|
||||||
|
-drive file="$overlay",if=virtio,format=qcow2 \
|
||||||
|
-drive file="$seed",if=virtio,format=raw \
|
||||||
|
-netdev user,id=n0 -device virtio-net-pci,netdev=n0 \
|
||||||
|
-fsdev local,id=fs0,path="$repo",security_model=none,readonly=on \
|
||||||
|
-device virtio-9p-pci,fsdev=fs0,mount_tag=gofwrepo \
|
||||||
|
-fsdev local,id=fs1,path="$CACHE",security_model=none \
|
||||||
|
-device virtio-9p-pci,fsdev=fs1,mount_tag=gofwcache \
|
||||||
|
-monitor tcp:127.0.0.1:"$mon_port",server,nowait \
|
||||||
|
-serial stdio -display none -no-reboot </dev/null >"$fifo" 2>&1 &
|
||||||
|
qemu_pid=$!
|
||||||
|
|
||||||
|
# Wait for the VM, enforcing the boot timeout. Sleeping in a backgrounded child we
|
||||||
|
# then wait on keeps the loop interruptible so the Ctrl-C trap fires promptly.
|
||||||
|
deadline=$(( SECONDS + BOOT_TIMEOUT ))
|
||||||
|
while kill -0 "$qemu_pid" 2>/dev/null; do
|
||||||
|
if [[ "$SECONDS" -ge "$deadline" ]]; then
|
||||||
|
timed_out=1
|
||||||
|
echo >&2
|
||||||
|
echo "!! VM exceeded ${BOOT_TIMEOUT}s; shutting it down" >&2
|
||||||
|
stop_vm
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2 & wait $! 2>/dev/null
|
||||||
|
done
|
||||||
|
wait "$qemu_pid" 2>/dev/null
|
||||||
|
trap - INT TERM
|
||||||
|
wait "$tee_pid" 2>/dev/null # let tee drain the FIFO and flush the console log
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo " ------------------------------------------------------------------"
|
||||||
|
if [[ "$interrupted" = 1 ]]; then
|
||||||
|
echo "!! run interrupted; VM stopped. Partial serial log: $console"
|
||||||
|
exit 130
|
||||||
|
fi
|
||||||
|
if [[ "$timed_out" = 1 ]]; then
|
||||||
|
echo "!! VM timed out after ${BOOT_TIMEOUT}s (see $console)"
|
||||||
|
exit 124
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- results ----------------------------------------------------------------
|
||||||
|
echo
|
||||||
|
echo "==== VM run summary ===="
|
||||||
|
if grep -q "GOFW_VM_DONE" "$console"; then
|
||||||
|
sed -n '/==== integration results ====/,/GOFW_VM_DONE/p' "$console" | sed 's/\r$//'
|
||||||
|
rc="$(grep -o 'GOFW_VM_DONE rc=[0-9]*' "$console" | tail -1 | grep -o '[0-9]*$')"
|
||||||
|
echo "(full serial log: $console)"
|
||||||
|
exit "${rc:-1}"
|
||||||
|
fi
|
||||||
|
echo "!! the in-VM run did not complete (no GOFW_VM_DONE marker). See $console"
|
||||||
|
exit 1
|
||||||
205
test/integration/host-windows-vm.sh
Executable file
205
test/integration/host-windows-vm.sh
Executable file
|
|
@ -0,0 +1,205 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# HOST script — run this on your workstation. It boots a throwaway QEMU Windows VM
|
||||||
|
# and runs the Windows Firewall (wf) integration backend inside it.
|
||||||
|
#
|
||||||
|
# On first use it performs a fully unattended install of Windows Server 2022
|
||||||
|
# Evaluation into .cache/windows.qcow2 (downloads the ~5 GB eval ISO if absent),
|
||||||
|
# provisions OpenSSH + disables UAC via an Autounattend.xml answer file, then boots
|
||||||
|
# a fresh overlay of that image, copies in the windows-cross-compiled test binary,
|
||||||
|
# and runs it over SSH. Everything is disposable; the base install is reused.
|
||||||
|
#
|
||||||
|
# Requirements: qemu-system-x86_64, KVM, genisoimage, socat, ssh (key auth).
|
||||||
|
#
|
||||||
|
# A Windows host with Administrator rights can also run the suite directly:
|
||||||
|
# go test -tags integration -run TestIntegration (from an elevated prompt)
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
repo="$(cd "$here/../.." && pwd)"
|
||||||
|
|
||||||
|
MEM="${GOFW_VM_MEM:-4096}"
|
||||||
|
CPUS="${GOFW_VM_CPUS:-2}"
|
||||||
|
CACHE="${GOFW_VM_CACHE:-$repo/.cache}"
|
||||||
|
BOOT_TIMEOUT="${GOFW_VM_TIMEOUT:-3600}"
|
||||||
|
SSH_PORT="${GOFW_WIN_SSH_PORT:-2222}"
|
||||||
|
ISO_URL="${GOFW_WIN_ISO_URL:-https://software-static.download.prss.microsoft.com/sg/download/888969d5-f34g-4e03-ac9d-1f9786c66749/SERVER_EVAL_x64FRE_en-us.iso}"
|
||||||
|
PASS='Gofw!Test2024'
|
||||||
|
USER='Administrator'
|
||||||
|
|
||||||
|
mkdir -p "$CACHE"
|
||||||
|
iso="$CACHE/winserver.iso"
|
||||||
|
base="$CACHE/windows.qcow2"
|
||||||
|
overlay="$CACHE/windows-overlay.qcow2"
|
||||||
|
uaiso="$CACHE/autounattend.iso"
|
||||||
|
bin="$CACHE/firewall.test.exe"
|
||||||
|
mon="$CACHE/win-mon.sock"
|
||||||
|
|
||||||
|
for t in qemu-system-x86_64 genisoimage socat ssh ssh-keygen; do
|
||||||
|
command -v "$t" >/dev/null || { echo "!! $t not found"; exit 1; }
|
||||||
|
done
|
||||||
|
|
||||||
|
# Key-based SSH (no sshpass dependency); the public key is injected into the guest
|
||||||
|
# via the Autounattend firstlogon commands.
|
||||||
|
key="$CACHE/win_key"
|
||||||
|
[[ -f "$key" ]] || ssh-keygen -q -t ed25519 -N '' -C gofw-win -f "$key"
|
||||||
|
pubkey="$(cat "$key.pub")"
|
||||||
|
|
||||||
|
ssh_opts=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o IdentitiesOnly=yes -i "$key")
|
||||||
|
run_ssh() { ssh "${ssh_opts[@]}" -p "$SSH_PORT" "$USER@127.0.0.1" "$@"; }
|
||||||
|
copy_in() { scp "${ssh_opts[@]}" -P "$SSH_PORT" "$1" "$USER@127.0.0.1:$2"; }
|
||||||
|
|
||||||
|
# wait_ssh <qemu_pid> — returns 0 once the guest answers SSH, 1 on timeout/death.
|
||||||
|
wait_ssh() {
|
||||||
|
local pid="$1" deadline=$((SECONDS + BOOT_TIMEOUT))
|
||||||
|
until run_ssh "echo ok" >/dev/null 2>&1; do
|
||||||
|
kill -0 "$pid" 2>/dev/null || { echo "!! qemu exited before SSH came up"; return 1; }
|
||||||
|
[[ $SECONDS -ge $deadline ]] && { echo "!! timed out waiting for SSH"; return 1; }
|
||||||
|
sleep 10
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- one-time unattended install -------------------------------------------
|
||||||
|
install_windows() {
|
||||||
|
if [[ ! -f "$iso" ]]; then
|
||||||
|
echo ">> downloading Windows Server 2022 eval ISO (~5 GB) -> $iso"
|
||||||
|
curl -L --fail -o "$iso.tmp" "$ISO_URL"
|
||||||
|
mv "$iso.tmp" "$iso"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo ">> building Autounattend.xml seed"
|
||||||
|
local work; work="$(mktemp -d "$CACHE/ua.XXXXXX")"
|
||||||
|
cat >"$work/autounattend.xml" <<XML
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<unattend xmlns="urn:schemas-microsoft-com:unattend">
|
||||||
|
<settings pass="windowsPE">
|
||||||
|
<component name="Microsoft-Windows-International-Core-WinPE" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<SetupUILanguage><UILanguage>en-US</UILanguage></SetupUILanguage>
|
||||||
|
<InputLocale>en-US</InputLocale><SystemLocale>en-US</SystemLocale>
|
||||||
|
<UILanguage>en-US</UILanguage><UserLocale>en-US</UserLocale>
|
||||||
|
</component>
|
||||||
|
<component name="Microsoft-Windows-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<DiskConfiguration>
|
||||||
|
<Disk wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
|
||||||
|
<DiskID>0</DiskID><WillWipeDisk>true</WillWipeDisk>
|
||||||
|
<CreatePartitions>
|
||||||
|
<CreatePartition wcm:action="add"><Order>1</Order><Type>Primary</Type><Extend>true</Extend></CreatePartition>
|
||||||
|
</CreatePartitions>
|
||||||
|
<ModifyPartitions>
|
||||||
|
<ModifyPartition wcm:action="add"><Order>1</Order><PartitionID>1</PartitionID><Format>NTFS</Format><Active>true</Active><Label>Windows</Label></ModifyPartition>
|
||||||
|
</ModifyPartitions>
|
||||||
|
</Disk>
|
||||||
|
</DiskConfiguration>
|
||||||
|
<ImageInstall>
|
||||||
|
<OSImage>
|
||||||
|
<InstallTo><DiskID>0</DiskID><PartitionID>1</PartitionID></InstallTo>
|
||||||
|
<InstallFrom><MetaData wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Key>/IMAGE/INDEX</Key><Value>3</Value></MetaData></InstallFrom>
|
||||||
|
</OSImage>
|
||||||
|
</ImageInstall>
|
||||||
|
<UserData><AcceptEula>true</AcceptEula></UserData>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
<settings pass="specialize">
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<ComputerName>WINTEST</ComputerName>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
<settings pass="oobeSystem">
|
||||||
|
<component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="neutral" versionScope="nonSxS">
|
||||||
|
<UserAccounts><AdministratorPassword><Value>$PASS</Value><PlainText>true</PlainText></AdministratorPassword></UserAccounts>
|
||||||
|
<AutoLogon><Password><Value>$PASS</Value><PlainText>true</PlainText></Password><Enabled>true</Enabled><Username>$USER</Username><LogonCount>999</LogonCount></AutoLogon>
|
||||||
|
<OOBE>
|
||||||
|
<HideEULAPage>true</HideEULAPage><HideLocalAccountScreen>true</HideLocalAccountScreen>
|
||||||
|
<HideOnlineAccountScreens>true</HideOnlineAccountScreens><HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
|
||||||
|
<NetworkLocation>Work</NetworkLocation><ProtectYourPC>3</ProtectYourPC>
|
||||||
|
<SkipUserOOBE>true</SkipUserOOBE><SkipMachineOOBE>true</SkipMachineOOBE>
|
||||||
|
</OOBE>
|
||||||
|
<FirstLogonCommands>
|
||||||
|
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>1</Order><CommandLine>reg add HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System /v EnableLUA /t REG_DWORD /d 0 /f</CommandLine></SynchronousCommand>
|
||||||
|
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>2</Order><CommandLine>reg add "HKLM\SOFTWARE\OpenSSH" /v DefaultShell /t REG_SZ /d "C:\Windows\System32\cmd.exe" /f</CommandLine></SynchronousCommand>
|
||||||
|
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>3</Order><CommandLine>powershell -NoProfile -Command "Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0.1.0"</CommandLine></SynchronousCommand>
|
||||||
|
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>4</Order><CommandLine>powershell -NoProfile -Command "New-Item -Force -ItemType Directory C:\ProgramData\ssh | Out-Null; Set-Content -Encoding ascii -Path C:\ProgramData\ssh\administrators_authorized_keys -Value '$pubkey'; icacls C:\ProgramData\ssh\administrators_authorized_keys /inheritance:r /grant SYSTEM:F /grant BUILTIN\Administrators:F"</CommandLine></SynchronousCommand>
|
||||||
|
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>5</Order><CommandLine>powershell -NoProfile -Command "Set-Service sshd -StartupType Automatic; Start-Service sshd"</CommandLine></SynchronousCommand>
|
||||||
|
<SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"><Order>6</Order><CommandLine>netsh advfirewall firewall add rule name=OpenSSH dir=in action=allow protocol=TCP localport=22</CommandLine></SynchronousCommand>
|
||||||
|
</FirstLogonCommands>
|
||||||
|
</component>
|
||||||
|
</settings>
|
||||||
|
</unattend>
|
||||||
|
XML
|
||||||
|
genisoimage -quiet -o "$uaiso" -V UNATTEND -J -r "$work/autounattend.xml"
|
||||||
|
rm -rf "$work"
|
||||||
|
|
||||||
|
echo ">> creating install disk"
|
||||||
|
rm -f "$base.installing"
|
||||||
|
qemu-img create -f qcow2 "$base.installing" 40G >/dev/null
|
||||||
|
|
||||||
|
rm -f "$mon"
|
||||||
|
echo ">> booting installer (unattended; this takes ~30-40 min)"
|
||||||
|
qemu-system-x86_64 \
|
||||||
|
-enable-kvm -cpu host -m "$MEM" -smp "$CPUS" \
|
||||||
|
-drive file="$base.installing",if=none,id=disk,format=qcow2 \
|
||||||
|
-device ahci,id=ahci -device ide-hd,drive=disk,bus=ahci.0 \
|
||||||
|
-drive file="$iso",media=cdrom,if=none,id=inst -device ide-cd,drive=inst,bus=ahci.1 \
|
||||||
|
-drive file="$uaiso",media=cdrom,if=none,id=ua -device ide-cd,drive=ua,bus=ahci.2 \
|
||||||
|
-netdev "user,id=n0,hostfwd=tcp::${SSH_PORT}-:22" -device e1000,netdev=n0 \
|
||||||
|
-boot once=d,menu=off -monitor "unix:$mon,server,nowait" -display none -serial null &
|
||||||
|
local qpid=$!
|
||||||
|
trap 'kill "$qpid" 2>/dev/null || true' RETURN
|
||||||
|
|
||||||
|
# Bypass the "Press any key to boot from CD" prompt: tap Enter for the first
|
||||||
|
# ~20 s via the QEMU monitor.
|
||||||
|
( for _ in $(seq 1 20); do echo "sendkey ret"; sleep 1; done | socat - "unix-connect:$mon" >/dev/null 2>&1 || true ) &
|
||||||
|
|
||||||
|
echo ">> waiting for the installed guest to answer SSH (up to ${BOOT_TIMEOUT}s)"
|
||||||
|
if ! wait_ssh "$qpid"; then
|
||||||
|
echo "!! install did not finish; see the console. Leaving $base.installing for inspection."
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo ">> install complete; shutting the guest down"
|
||||||
|
run_ssh "shutdown /s /t 0" >/dev/null 2>&1 || true
|
||||||
|
for _ in $(seq 1 30); do kill -0 "$qpid" 2>/dev/null || break; sleep 2; done
|
||||||
|
kill "$qpid" 2>/dev/null || true
|
||||||
|
trap - RETURN
|
||||||
|
mv "$base.installing" "$base"
|
||||||
|
echo ">> prepared image ready: $base"
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -f "$base" ]] || install_windows
|
||||||
|
|
||||||
|
# --- run the suite ----------------------------------------------------------
|
||||||
|
echo ">> cross-compiling windows test binary on host"
|
||||||
|
( cd "$repo" && GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go test -c -tags integration -o "$bin" . )
|
||||||
|
|
||||||
|
echo ">> creating a fresh overlay disk (base stays pristine)"
|
||||||
|
rm -f "$overlay"
|
||||||
|
qemu-img create -f qcow2 -b "$base" -F qcow2 "$overlay" 40G >/dev/null
|
||||||
|
|
||||||
|
echo ">> booting Windows VM (headless; SSH on localhost:$SSH_PORT)"
|
||||||
|
qemu-system-x86_64 \
|
||||||
|
-enable-kvm -cpu host -m "$MEM" -smp "$CPUS" \
|
||||||
|
-drive file="$overlay",if=none,id=disk,format=qcow2 \
|
||||||
|
-device ahci,id=ahci -device ide-hd,drive=disk,bus=ahci.0 \
|
||||||
|
-netdev "user,id=n0,hostfwd=tcp::${SSH_PORT}-:22" -device e1000,netdev=n0 \
|
||||||
|
-monitor "unix:$mon,server,nowait" -display none -serial null &
|
||||||
|
qpid=$!
|
||||||
|
trap 'kill "$qpid" 2>/dev/null || true' EXIT
|
||||||
|
|
||||||
|
wait_ssh "$qpid" || exit 124
|
||||||
|
|
||||||
|
echo ">> copying test binary and running the suite (Administrator, UAC disabled)"
|
||||||
|
copy_in "$bin" "firewall.test.exe"
|
||||||
|
set +e
|
||||||
|
# Keep the SSH control channel alive independent of the port-22 rules the suite
|
||||||
|
# adds and removes. The guest sshd listens on :22 (host :2222 forwards to it), so a
|
||||||
|
# subtest that manages a bare TCP/22 rule would otherwise remove the OpenSSH allow
|
||||||
|
# and WFP would drop this session. This rule permits inbound TCP from the QEMU SLIRP
|
||||||
|
# gateway (10.0.2.2, the source the forwarded SSH appears from) on any local port;
|
||||||
|
# its shape (a source-only match, no local port) matches no test rule, so the suite
|
||||||
|
# never removes it, and it is not the bare TCP/22 rule the suite round-trips.
|
||||||
|
run_ssh 'netsh advfirewall firewall add rule name=gofw-keepalive dir=in action=allow protocol=TCP remoteip=10.0.2.0/24'
|
||||||
|
run_ssh 'set "FIREWALL_BACKEND=wf"&& firewall.test.exe -test.v -test.run TestIntegration'
|
||||||
|
rc=$?
|
||||||
|
set -e
|
||||||
|
|
||||||
|
run_ssh "shutdown /s /t 0" >/dev/null 2>&1 || true
|
||||||
|
echo "==== Windows wf run finished (rc=$rc) ===="
|
||||||
|
exit "$rc"
|
||||||
1588
ufw_linux.go
Normal file
1588
ufw_linux.go
Normal file
File diff suppressed because it is too large
Load diff
628
ufw_linux_test.go
Normal file
628
ufw_linux_test.go
Normal file
|
|
@ -0,0 +1,628 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/hex"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestUFWParseTupleRowsModelsRouteRows verifies a route/forward tuple is now
|
||||||
|
// decoded as a forward-direction rule (occupying its physical row), and ParseRules
|
||||||
|
// returns it alongside the ordinary rules.
|
||||||
|
func TestUFWParseTupleRowsModelsRouteRows(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
p := filepath.Join(dir, "user.rules")
|
||||||
|
content := "### tuple ### route:allow tcp 23 0.0.0.0/0 any 0.0.0.0/0 in\n" +
|
||||||
|
"### tuple ### allow tcp 22 0.0.0.0/0 any 0.0.0.0/0 in\n" +
|
||||||
|
"### tuple ### allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in\n"
|
||||||
|
require.NoError(t, os.WriteFile(p, []byte(content), 0644))
|
||||||
|
fw := new(UFW)
|
||||||
|
|
||||||
|
rows, err := fw.parseTupleRows(p, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rows, 3, "every tuple line occupies a physical row, including the route rule")
|
||||||
|
require.NotNil(t, rows[0], "the route (forward) tuple is now modeled as a forward rule")
|
||||||
|
require.Equal(t, DirForward, rows[0].Direction)
|
||||||
|
require.EqualValues(t, 23, rows[0].Port)
|
||||||
|
require.NotNil(t, rows[1])
|
||||||
|
require.EqualValues(t, 22, rows[1].Port)
|
||||||
|
require.NotNil(t, rows[2])
|
||||||
|
require.EqualValues(t, 80, rows[2].Port)
|
||||||
|
|
||||||
|
reps, err := fw.ParseRules(p, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, reps, 3, "ParseRules returns every rule, the forward one included")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUFWNativeInsertPositionCountsRouteRows verifies a preceding route rule shifts
|
||||||
|
// the native `ufw insert` position, since ufw counts route rules in its numbered
|
||||||
|
// list. Ignoring them would insert the rule one slot too early.
|
||||||
|
func TestUFWNativeInsertPositionCountsRouteRows(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
a := &Rule{Proto: TCP, Port: 22, Action: Accept}
|
||||||
|
b := &Rule{Proto: TCP, Port: 80, Action: Accept}
|
||||||
|
// Physical order: route(nil) is #1, A is #2, B is #3. GetRules reports A=1, B=2.
|
||||||
|
rows := []*Rule{nil, a, b}
|
||||||
|
require.Equal(t, 2, fw.nativeInsertPositionFromRows(rows, 1), "insert before A lands at A's physical slot 2")
|
||||||
|
require.Equal(t, 3, fw.nativeInsertPositionFromRows(rows, 2), "insert before B lands at B's physical slot 3, not 2")
|
||||||
|
require.Equal(t, 4, fw.nativeInsertPositionFromRows(rows, 3), "past the end appends past the last physical tuple")
|
||||||
|
|
||||||
|
// With no un-representable rows the mapping is the plain merged index.
|
||||||
|
plain := []*Rule{a, b}
|
||||||
|
require.Equal(t, 1, fw.nativeInsertPositionFromRows(plain, 1))
|
||||||
|
require.Equal(t, 2, fw.nativeInsertPositionFromRows(plain, 2))
|
||||||
|
require.Equal(t, 3, fw.nativeInsertPositionFromRows(plain, 3))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUFWRules(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
|
||||||
|
// Parse a rule that is expected to parse right.
|
||||||
|
rule, err := fw.UnmarshalRule(`allow udp 23 0.0.0.0/0 any 192.168.0.0/24 in`, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Re-encode the rule which should result in expected rich rule.
|
||||||
|
args, err := fw.MarshalRule(rule)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, `allow in proto udp from 192.168.0.0/24 to 0.0.0.0/0 port 23`, args,
|
||||||
|
"the rule did not encode as expected")
|
||||||
|
|
||||||
|
// Try encoding a bunch of invalid rules.
|
||||||
|
invalidRules := []string{
|
||||||
|
`log udp 23 0.0.0.0/0 any 192.168.0.0/24 in`, // unsupported action
|
||||||
|
`allow udp 23 0.0.0.0/0 any`, // too few fields
|
||||||
|
}
|
||||||
|
for _, richRule := range invalidRules {
|
||||||
|
_, err := fw.UnmarshalRule(richRule, IPv4)
|
||||||
|
require.Error(t, err, "this rule was parsed when it should be invalid: %s", richRule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A `route:` (forward) tuple decodes to a forward-direction rule. The `route:`
|
||||||
|
// prefix is stripped from the action, the direction is forward, and the trailing
|
||||||
|
// interface field(s) populate the in/out interfaces (a bare direction leaves
|
||||||
|
// both empty).
|
||||||
|
routeCases := []struct {
|
||||||
|
tuple string
|
||||||
|
action Action
|
||||||
|
port uint16
|
||||||
|
in string
|
||||||
|
out string
|
||||||
|
}{
|
||||||
|
{`route:allow tcp 23 0.0.0.0/0 any 0.0.0.0/0 in`, Accept, 23, "", ""},
|
||||||
|
{`route:deny tcp 25 192.168.0.1 any 10.0.0.0/8 in_eth0`, Drop, 25, "eth0", ""},
|
||||||
|
{`route:allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in_eth0 out_eth1`, Accept, 80, "eth0", "eth1"},
|
||||||
|
}
|
||||||
|
for _, rc := range routeCases {
|
||||||
|
got, err := fw.UnmarshalRule(rc.tuple, IPv4)
|
||||||
|
require.NoError(t, err, "a route (forward) rule must decode: %s", rc.tuple)
|
||||||
|
require.Equal(t, DirForward, got.Direction, "route rule is forward: %s", rc.tuple)
|
||||||
|
require.Equal(t, rc.action, got.Action, rc.tuple)
|
||||||
|
require.EqualValues(t, rc.port, got.Port, rc.tuple)
|
||||||
|
require.Equal(t, rc.in, got.InInterface, rc.tuple)
|
||||||
|
require.Equal(t, rc.out, got.OutInterface, rc.tuple)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An application-profile tuple (9 fields, with a dapp/sapp name before the
|
||||||
|
// trailing direction field) decodes like an ordinary tuple: ufw's real tuples
|
||||||
|
// carry the app's concrete protocol/port in the six core fields — e.g. ufw's own
|
||||||
|
// recorded output for "ufw allow Apache" is
|
||||||
|
// `allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 Apache - in` — so the dapp/sapp name
|
||||||
|
// tokens are simply skipped to reach the direction field; they carry no
|
||||||
|
// independent match information the model needs to represent.
|
||||||
|
appRule, err := fw.UnmarshalRule(`allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 Apache - in`, IPv4)
|
||||||
|
require.NoError(t, err, "a real application-profile tuple must decode")
|
||||||
|
require.Equal(t, Accept, appRule.Action)
|
||||||
|
require.Equal(t, TCP, appRule.Proto)
|
||||||
|
require.EqualValues(t, 80, appRule.Port)
|
||||||
|
require.False(t, appRule.IsOutput())
|
||||||
|
|
||||||
|
// A multi-port app profile (Samba's "137,138") and a sapp-only form (source app,
|
||||||
|
// dapp placeholder "-") both decode the same way.
|
||||||
|
sambaRule, err := fw.UnmarshalRule(`allow udp 137,138 0.0.0.0/0 any 0.0.0.0/0 Samba - in`, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, UDP, sambaRule.Proto)
|
||||||
|
require.Len(t, sambaRule.Ports, 2)
|
||||||
|
require.Equal(t, PortRange{Start: 137, End: 137}, sambaRule.Ports[0])
|
||||||
|
require.Equal(t, PortRange{Start: 138, End: 138}, sambaRule.Ports[1])
|
||||||
|
|
||||||
|
sappRule, err := fw.UnmarshalRule(`allow udp any 10.0.0.1 137,138 0.0.0.0/0 - Samba in`, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, UDP, sappRule.Proto)
|
||||||
|
require.Len(t, sappRule.SourcePorts, 2)
|
||||||
|
require.Equal(t, PortRange{Start: 137, End: 137}, sappRule.SourcePorts[0])
|
||||||
|
require.Equal(t, PortRange{Start: 138, End: 138}, sappRule.SourcePorts[1])
|
||||||
|
require.Equal(t, "10.0.0.1", sappRule.Destination)
|
||||||
|
|
||||||
|
// An 8-field tuple never occurs in a real ufw file (ufw always writes both dapp
|
||||||
|
// and sapp, using "-" for whichever is absent), so it is rejected as malformed.
|
||||||
|
_, err = fw.UnmarshalRule(`allow any any 0.0.0.0/0 any 0.0.0.0/0 Apache -`, IPv4)
|
||||||
|
require.Error(t, err, "an 8-field tuple is not a real ufw shape and must be rejected")
|
||||||
|
|
||||||
|
// Source ports round-trip through the tuple's sport field.
|
||||||
|
srcTuple, err := fw.UnmarshalRule(`allow tcp any 0.0.0.0/0 1024:65535 192.168.0.0/24 in`, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, srcTuple.SourcePorts, 1)
|
||||||
|
require.Equal(t, PortRange{Start: 1024, End: 65535}, srcTuple.SourcePorts[0])
|
||||||
|
require.Equal(t, "192.168.0.0/24", srcTuple.Source)
|
||||||
|
|
||||||
|
srcMarshal, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Source: "192.168.0.0/24", SourcePort: 1234, Port: 22, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "allow in proto tcp from 192.168.0.0/24 port 1234 to 0.0.0.0/0 port 22", srcMarshal,
|
||||||
|
"unexpected source-port marshal")
|
||||||
|
|
||||||
|
// A single source port with an unspecified protocol is valid (ufw emits
|
||||||
|
// `proto all ... --sport`); only a source-port list or range needs tcp/udp.
|
||||||
|
anySrc, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: ProtocolAny, SourcePort: 1234, Action: Accept})
|
||||||
|
require.NoError(t, err, "a single source port with proto any must be accepted")
|
||||||
|
require.Contains(t, anySrc, "port 1234")
|
||||||
|
_, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: ProtocolAny, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept})
|
||||||
|
require.Error(t, err, "expected a source-port range without tcp/udp to be rejected")
|
||||||
|
|
||||||
|
// Test rules we typically set.
|
||||||
|
validRules := []string{
|
||||||
|
`allow udp 4789 ::/0 any ::/0 in`,
|
||||||
|
`allow udp 4789 ::/0 any ::/0 out`,
|
||||||
|
`allow tcp 4789 0.0.0.0/0 any 67.227.233.116 in`,
|
||||||
|
`allow tcp 4791 67.227.233.116 any 0.0.0.0/0 out`,
|
||||||
|
}
|
||||||
|
for _, richRule := range validRules {
|
||||||
|
_, err := fw.UnmarshalRule(richRule, IPv4)
|
||||||
|
require.NoError(t, err, "this rich rule was not parsed when it should be valid: %s", richRule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalRule must not mutate the caller's rule while inferring the
|
||||||
|
// family and normalizing the destination.
|
||||||
|
orig := &Rule{Direction: DirInput, Family: IPv4, Port: 4789, Proto: TCP, Action: Accept}
|
||||||
|
before := *orig
|
||||||
|
_, err = fw.MarshalRule(orig)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, before, *orig, "MarshalRule mutated the caller's rule")
|
||||||
|
|
||||||
|
// An interface-bound tuple parses the interface back out of the direction.
|
||||||
|
ifRule, err := fw.UnmarshalRule(`allow tcp 22 0.0.0.0/0 any 0.0.0.0/0 in_eth0`, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "eth0", ifRule.InInterface, "unexpected interface parse: %+v", *ifRule)
|
||||||
|
require.False(t, ifRule.IsOutput(), "unexpected interface parse: %+v", *ifRule)
|
||||||
|
|
||||||
|
// Marshalling an interface-bound rule emits `in on <iface>`.
|
||||||
|
spec, err := fw.MarshalRule(&Rule{InInterface: "eth0", Family: IPv4, Proto: TCP, Port: 22, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "allow in on eth0 proto tcp to 0.0.0.0/0 port 22", spec, "unexpected interface marshal")
|
||||||
|
|
||||||
|
// Features ufw cannot express in this model are rejected. A multiport match
|
||||||
|
// without a concrete tcp/udp protocol is also rejected.
|
||||||
|
unsupported := []*Rule{
|
||||||
|
{Proto: ICMP, Action: Accept},
|
||||||
|
{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
|
||||||
|
{Proto: ProtocolAny, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range unsupported {
|
||||||
|
_, err := fw.MarshalRule(r)
|
||||||
|
require.Error(t, err, "expected error marshalling unsupported rule %+v", *r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ufw supports port lists and colon ranges on tcp/udp.
|
||||||
|
portCases := []struct {
|
||||||
|
rule *Rule
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Family: IPv4, Action: Accept}, "allow in proto tcp to 0.0.0.0/0 port 80,443"},
|
||||||
|
{&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}, "allow in 80,443/tcp"},
|
||||||
|
{&Rule{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}, "allow in 1000:2000/udp"},
|
||||||
|
}
|
||||||
|
for _, c := range portCases {
|
||||||
|
got, err := fw.MarshalRule(c.rule)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *c.rule)
|
||||||
|
require.Equal(t, c.want, got, "marshal %+v", *c.rule)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A tuple with a multiport list and a colon range parses into port specs.
|
||||||
|
multi, err := fw.UnmarshalRule("allow tcp 80,443 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, multi.Ports, 2, "unexpected multiport parse: %+v", *multi)
|
||||||
|
require.Equal(t, PortRange{Start: 80, End: 80}, multi.Ports[0], "unexpected multiport parse: %+v", *multi)
|
||||||
|
require.Equal(t, PortRange{Start: 443, End: 443}, multi.Ports[1], "unexpected multiport parse: %+v", *multi)
|
||||||
|
|
||||||
|
ran, err := fw.UnmarshalRule("allow tcp 1000:2000 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, ran.Ports, 1, "unexpected range parse: %+v", *ran)
|
||||||
|
require.Equal(t, PortRange{Start: 1000, End: 2000}, ran.Ports[0], "unexpected range parse: %+v", *ran)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ufw expresses per-rule logging natively with its `log` keyword (placed after
|
||||||
|
// the direction and any interface clause), rather than diverting a logged rule to
|
||||||
|
// before.rules where it could not be removed. Only a custom log prefix — which
|
||||||
|
// ufw cannot set — still needs the raw path.
|
||||||
|
func TestUFWNativeLogging(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
|
||||||
|
// A plain logged rule stays on the CLI/tuple path and emits `log`.
|
||||||
|
logged := &Rule{Family: IPv4, Proto: TCP, Port: 22, Log: true, Action: Accept}
|
||||||
|
require.False(t, fw.needsIPTablesRules(logged), "a plain logged rule must not be routed to before.rules")
|
||||||
|
spec, err := fw.MarshalRule(logged)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "allow in log proto tcp to 0.0.0.0/0 port 22", spec)
|
||||||
|
|
||||||
|
// The keyword follows the interface clause.
|
||||||
|
ifLogged := &Rule{Family: IPv4, Proto: TCP, Port: 22, InInterface: "eth0", Log: true, Action: Accept}
|
||||||
|
spec, err = fw.MarshalRule(ifLogged)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "allow in on eth0 log proto tcp to 0.0.0.0/0 port 22", spec)
|
||||||
|
|
||||||
|
// A custom log prefix cannot be set in a tuple: routed raw, rejected by marshal.
|
||||||
|
prefixed := &Rule{Family: IPv4, Proto: TCP, Port: 22, Log: true, LogPrefix: "DROP22", Action: Drop}
|
||||||
|
require.True(t, fw.needsIPTablesRules(prefixed), "a custom-prefix log must go to before.rules")
|
||||||
|
_, err = fw.MarshalRule(prefixed)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ufw carries the portless IP protocols gre/esp/ah natively (its
|
||||||
|
// supported_protocols list), so they must marshal to a `proto` tuple rather than
|
||||||
|
// being diverted to before.rules where they could not be removed.
|
||||||
|
func TestUFWPortlessProtocols(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
for _, p := range []Protocol{GRE, ESP, AH} {
|
||||||
|
require.False(t, fw.protoNeedsRaw(p), "%s is native to ufw", p)
|
||||||
|
// With an address.
|
||||||
|
spec, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: p, Source: "10.0.0.0/24", Action: Accept})
|
||||||
|
require.NoError(t, err, "failed to marshal %s rule", p)
|
||||||
|
require.Contains(t, spec, "proto "+p.String())
|
||||||
|
// Without an address: still emits the proto clause (against an any dest).
|
||||||
|
spec, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: p, Action: Accept})
|
||||||
|
require.NoError(t, err, "failed to marshal bare %s rule", p)
|
||||||
|
require.Contains(t, spec, "proto "+p.String(), "bare %s rule dropped its protocol: %q", p, spec)
|
||||||
|
}
|
||||||
|
// ICMP and SCTP genuinely need the raw path.
|
||||||
|
require.True(t, fw.protoNeedsRaw(ICMP))
|
||||||
|
require.True(t, fw.protoNeedsRaw(ICMPv6))
|
||||||
|
require.True(t, fw.protoNeedsRaw(SCTP))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUFWPortlessTCPUDP guards a bare tcp/udp match ("allow all TCP inbound"):
|
||||||
|
// with no port and no address, the protocol must still be carried on an `any`
|
||||||
|
// destination rather than dropped to a bare `allow in` that ufw rejects.
|
||||||
|
func TestUFWPortlessTCPUDP(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
for _, p := range []Protocol{TCP, UDP} {
|
||||||
|
spec, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: p, Action: Accept})
|
||||||
|
require.NoError(t, err, "failed to marshal bare %s rule", p)
|
||||||
|
require.Contains(t, spec, "proto "+p.String(), "bare %s rule dropped its protocol: %q", p, spec)
|
||||||
|
require.NotEqual(t, "allow in", spec, "bare %s rule emitted the invalid bare form", p)
|
||||||
|
}
|
||||||
|
// A family-agnostic bare tcp match must cover both families via the literal any.
|
||||||
|
spec, err := fw.MarshalRule(&Rule{Proto: TCP, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, spec, "proto tcp", "got %q", spec)
|
||||||
|
require.Contains(t, spec, "to any", "got %q", spec)
|
||||||
|
|
||||||
|
// A true match-all rule must still be a valid command, not a bare `allow in`.
|
||||||
|
spec, err = fw.MarshalRule(&Rule{Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotEqual(t, "allow in", spec, "match-all rule emitted the invalid bare form")
|
||||||
|
require.Contains(t, spec, "to any", "got %q", spec)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A forward rule marshals into a ufw route rule: the spec carries `in on`/`out on`
|
||||||
|
// interface clauses (no bare direction, which ufw rejects on a route rule) and the
|
||||||
|
// command args place the `route` keyword before the verb.
|
||||||
|
func TestUFWForwardRouteMarshal(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
|
||||||
|
r := &Rule{Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Proto: TCP, Port: 80, Action: Accept}
|
||||||
|
spec, err := fw.MarshalRule(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, spec, "allow in on eth0 out on eth1", "route spec must carry both interface clauses: %q", spec)
|
||||||
|
require.Contains(t, spec, "80", "the destination port must be present: %q", spec)
|
||||||
|
require.NotContains(t, spec, "route", "MarshalRule leaves the route keyword to the command builder: %q", spec)
|
||||||
|
|
||||||
|
// The command builder prepends `route` before the verb.
|
||||||
|
args := fw.ruleArgs(r, []string{"prepend"}, spec)
|
||||||
|
require.Equal(t, "route", args[0], "forward command starts with route")
|
||||||
|
require.Equal(t, "prepend", args[1], "the verb follows route")
|
||||||
|
|
||||||
|
// An ordinary rule carries neither the route keyword nor an out-interface clause.
|
||||||
|
ordinary, err := fw.MarshalRule(&Rule{Proto: TCP, Port: 22, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
inArgs := fw.ruleArgs(&Rule{Proto: TCP, Port: 22, Action: Accept}, []string{"prepend"}, ordinary)
|
||||||
|
require.Equal(t, "prepend", inArgs[0], "a non-forward rule has no leading route keyword")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUFWParseIPTablesRules(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
|
||||||
|
content := `# rules.before
|
||||||
|
*filter
|
||||||
|
:ufw-before-input - [0:0]
|
||||||
|
:ufw-before-output - [0:0]
|
||||||
|
# allow all on loopback
|
||||||
|
-A ufw-before-input -i lo -j ACCEPT
|
||||||
|
# quickly process packets for which we already have a connection
|
||||||
|
-A ufw-before-input -m conntrack --ctstate RELATED,ESTABLISHED -j ACCEPT
|
||||||
|
# ok icmp codes for INPUT
|
||||||
|
-A ufw-before-input -p icmp --icmp-type echo-request -j ACCEPT
|
||||||
|
-A ufw-before-input -p icmp --icmp-type destination-unreachable -j ACCEPT
|
||||||
|
# a jump to an internal chain must be skipped
|
||||||
|
-A ufw-before-input -j ufw-not-local
|
||||||
|
-A ufw-not-local -m addrtype --dst-type LOCAL -j RETURN
|
||||||
|
# the forward chain is now a modeled direction
|
||||||
|
-A ufw-before-forward -p icmp --icmp-type echo-request -j ACCEPT
|
||||||
|
COMMIT
|
||||||
|
`
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "before.rules")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0644))
|
||||||
|
|
||||||
|
rules, err := fw.ParseIPTablesRules(path, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Expect: loopback interface, conntrack established/related, the two input ICMP
|
||||||
|
// rules, and the forward ICMP rule. The chain jump and internal chain are
|
||||||
|
// skipped.
|
||||||
|
require.Len(t, rules, 5, "expected 5 parsed iptables rules, got %+v", rules)
|
||||||
|
|
||||||
|
var foundEcho, foundForward bool
|
||||||
|
for _, r := range rules {
|
||||||
|
if r.Proto == ICMP && r.ICMPType != nil && *r.ICMPType == 8 && r.Action == Accept {
|
||||||
|
foundEcho = true
|
||||||
|
if r.IsForward() {
|
||||||
|
foundForward = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.True(t, foundEcho, "expected an icmp echo-request accept rule, got %+v", rules)
|
||||||
|
require.True(t, foundForward, "expected the forward-chain icmp rule to be modeled, got %+v", rules)
|
||||||
|
|
||||||
|
// A missing iptables rules file contributes no rules and no error.
|
||||||
|
missing, err := fw.ParseIPTablesRules(filepath.Join(dir, "nope.rules"), IPv4)
|
||||||
|
require.NoError(t, err, "expected no error for a missing file")
|
||||||
|
require.Nil(t, missing, "expected no rules for a missing file")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUFWIPTablesRulesWrite(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
|
||||||
|
// needsIPTablesRules routes ICMP and state rules to the iptables rules files.
|
||||||
|
require.True(t, fw.needsIPTablesRules(&Rule{Proto: ICMP, Action: Accept}),
|
||||||
|
"expected an icmp rule to need the iptables rules files")
|
||||||
|
require.True(t, fw.needsIPTablesRules(&Rule{State: StateEstablished, Action: Accept}),
|
||||||
|
"expected a state rule to need the iptables rules files")
|
||||||
|
require.False(t, fw.needsIPTablesRules(&Rule{Proto: TCP, Port: 22, Action: Accept}),
|
||||||
|
"a plain tcp rule should use the ufw cli")
|
||||||
|
|
||||||
|
// marshalIPTablesLines rewrites the iptables chain to ufw's own chain. IPv4
|
||||||
|
// rules use the `ufw-*` chains; IPv6 rules must use the `ufw6-*` chains, since
|
||||||
|
// that is what before6.rules declares.
|
||||||
|
specs, err := fw.marshalIPTablesLines(&Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"-A ufw-before-input -p icmp -m icmp --icmp-type 8 -j ACCEPT"}, specs,
|
||||||
|
"unexpected iptables rules spec")
|
||||||
|
|
||||||
|
v6specs, err := fw.marshalIPTablesLines(&Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}, IPv6)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, []string{"-A ufw6-before-input -p icmpv6 -m icmp6 --icmpv6-type 128 -j ACCEPT"}, v6specs,
|
||||||
|
"an IPv6 rule must target the ufw6- chain so ip6tables-restore accepts before6.rules")
|
||||||
|
|
||||||
|
scaffold := "*filter\n:ufw-before-input - [0:0]\n:ufw-before-output - [0:0]\nCOMMIT\n"
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "before.rules")
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(scaffold), 0644))
|
||||||
|
|
||||||
|
icmp := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}
|
||||||
|
|
||||||
|
// Adding inserts the rule before COMMIT and it parses back equal.
|
||||||
|
changed, err := fw.editIPTablesRulesFile(path, icmp, IPv4, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed, "expected add to change the file")
|
||||||
|
|
||||||
|
rules, err := fw.ParseIPTablesRules(path, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
// The parsed rule is family-tagged from the file (IPv4), so compare with
|
||||||
|
// EqualBase, which ignores family.
|
||||||
|
require.Len(t, rules, 1)
|
||||||
|
require.True(t, rules[0].EqualBase(icmp, true), "expected the icmp rule to be present, got %+v", rules[0])
|
||||||
|
|
||||||
|
// Adding again is idempotent.
|
||||||
|
changed, err = fw.editIPTablesRulesFile(path, icmp, IPv4, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, changed, "expected a duplicate add to be a no-op")
|
||||||
|
|
||||||
|
// A different (state) rule adds alongside it.
|
||||||
|
state := &Rule{State: StateEstablished | StateRelated, Action: Accept}
|
||||||
|
_, err = fw.editIPTablesRulesFile(path, state, IPv4, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
rules, _ = fw.ParseIPTablesRules(path, IPv4)
|
||||||
|
require.Len(t, rules, 2, "expected 2 rules after adding a state rule, got %+v", rules)
|
||||||
|
|
||||||
|
// Removing the icmp rule leaves only the state rule.
|
||||||
|
changed, err = fw.editIPTablesRulesFile(path, icmp, IPv4, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, changed, "expected remove to change the file")
|
||||||
|
rules, _ = fw.ParseIPTablesRules(path, IPv4)
|
||||||
|
require.Len(t, rules, 1, "expected only the state rule to remain, got %+v", rules)
|
||||||
|
require.Equal(t, StateEstablished|StateRelated, rules[0].State,
|
||||||
|
"expected only the state rule to remain, got %+v", rules)
|
||||||
|
|
||||||
|
// Removing a rule that is not present is a no-op.
|
||||||
|
changed, err = fw.editIPTablesRulesFile(path, icmp, IPv4, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, changed, "expected removing an absent rule to be a no-op")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ufw's tuple grammar has no address negation, so a negated plain source or
|
||||||
|
// destination is routed to the before.rules raw path (which expresses it as
|
||||||
|
// `iptables ! -s/-d`) rather than rejected. A non-negated address stays on the
|
||||||
|
// tuple/CLI path and marshals cleanly.
|
||||||
|
func TestUFWNegatedAddressRoutesToRaw(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
|
||||||
|
require.True(t, fw.needsIPTablesRules(&Rule{Family: IPv4, Proto: TCP, Source: "!10.0.0.1", Port: 22, Action: Accept}),
|
||||||
|
"a negated source must route to before.rules")
|
||||||
|
require.True(t, fw.needsIPTablesRules(&Rule{Family: IPv4, Proto: TCP, Destination: "!10.0.0.1", Port: 22, Action: Accept}),
|
||||||
|
"a negated destination must route to before.rules")
|
||||||
|
|
||||||
|
// A non-negated address stays on the tuple path and marshals cleanly.
|
||||||
|
nonNeg := &Rule{Family: IPv4, Proto: TCP, Source: "10.0.0.1", Port: 22, Action: Accept}
|
||||||
|
require.False(t, fw.needsIPTablesRules(nonNeg), "a plain address stays on the CLI path")
|
||||||
|
_, err := fw.MarshalRule(nonNeg)
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A native ufw `limit` rule reads back from user.rules as an accept carrying
|
||||||
|
// ufw's built-in rate. It must be routed through the CLI/user.rules path (not the
|
||||||
|
// before.rules raw path), or it can never be removed and Restore duplicates it.
|
||||||
|
func TestUFWNativeLimitRouting(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
r, err := fw.UnmarshalRule("limit tcp 22 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, fw.isNativeLimit(r), "a limit tuple must be recognized as a native limit")
|
||||||
|
require.False(t, fw.needsIPTablesRules(r), "a native limit lives in user.rules, not before.rules")
|
||||||
|
|
||||||
|
spec, err := fw.MarshalRule(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, strings.HasPrefix(spec, "limit "), "a native limit must marshal to a `limit` tuple; got %q", spec)
|
||||||
|
|
||||||
|
// A general (non-native) rate limit still needs the raw before.rules path, and
|
||||||
|
// MarshalRule must refuse it rather than silently drop the rate.
|
||||||
|
general := &Rule{Proto: TCP, Port: 22, Action: Accept, RateLimit: &RateLimit{Rate: 100, Unit: PerSecond}}
|
||||||
|
require.True(t, fw.needsIPTablesRules(general), "a non-native rate limit needs before.rules")
|
||||||
|
_, err = fw.MarshalRule(general)
|
||||||
|
require.Error(t, err, "a non-native rate limit must not be silently dropped by MarshalRule")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalRule must reject a per-rule modifier it cannot put in a tuple (logging,
|
||||||
|
// a connection limit) rather than silently dropping it — those are routed to the
|
||||||
|
// before.rules files by needsIPTablesRules.
|
||||||
|
func TestUFWMarshalRejectsUnexpressibleModifiers(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
_, err := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, Log: true, LogPrefix: "x"})
|
||||||
|
require.Error(t, err, "logging must not be silently dropped")
|
||||||
|
_, err = fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, ConnLimit: &ConnLimit{Count: 10}})
|
||||||
|
require.Error(t, err, "a connection limit must not be silently dropped")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A family-agnostic source-port rule must emit the literal "any" address so ufw
|
||||||
|
// installs both the IPv4 and IPv6 rule, not a v4-only zero network.
|
||||||
|
func TestUFWFamilyAnySourcePortUsesAny(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
spec, err := fw.MarshalRule(&Rule{Proto: TCP, SourcePort: 1024, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, spec, "from any port 1024")
|
||||||
|
require.NotContains(t, spec, "0.0.0.0/0", "a FamilyAny rule must not be pinned to an IPv4 zero network")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRules must read tuples whose action carries a `_log` suffix or is `limit`
|
||||||
|
// rather than dropping them.
|
||||||
|
func TestUFWLimitAndLogTupleParse(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
|
||||||
|
limit, err := fw.UnmarshalRule("limit tcp 22 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, Accept, limit.Action)
|
||||||
|
require.NotNil(t, limit.RateLimit, "a limit tuple must carry a rate limit")
|
||||||
|
|
||||||
|
logged, err := fw.UnmarshalRule("allow_log tcp 80 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, Accept, logged.Action)
|
||||||
|
require.True(t, logged.Log, "an allow_log tuple must be read as a logged rule")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The ufw default policy lives in /etc/default/ufw under DEFAULT_*_POLICY keys
|
||||||
|
// with quoted ACCEPT/DROP/REJECT values (not ufw.conf's *_ACCEPT yes/no).
|
||||||
|
func TestUFWPolicyKeyAndValue(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
require.Equal(t, "DEFAULT_INPUT_POLICY", fw.policyKey(DirInput))
|
||||||
|
require.Equal(t, "DEFAULT_OUTPUT_POLICY", fw.policyKey(DirOutput))
|
||||||
|
require.Equal(t, "DEFAULT_FORWARD_POLICY", fw.policyKey(DirForward))
|
||||||
|
require.Equal(t, `"ACCEPT"`, fw.policyValue(Accept))
|
||||||
|
require.Equal(t, `"DROP"`, fw.policyValue(Drop))
|
||||||
|
require.Equal(t, `"REJECT"`, fw.policyValue(Reject), "ufw supports a reject default policy")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ufw's native `limit` action can carry logging (`ufw limit log ...` -> a
|
||||||
|
// `limit_log` tuple in user.rules). Such a rule must stay on the tuple path, not
|
||||||
|
// be routed to before.rules, or Restore removes it from the wrong file and
|
||||||
|
// duplicates it.
|
||||||
|
func TestUFWLimitLogStaysNative(t *testing.T) {
|
||||||
|
f := new(UFW)
|
||||||
|
r, err := f.UnmarshalRule("limit_log tcp 22 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.True(t, r.Log, "limit_log carries logging")
|
||||||
|
require.NotNil(t, r.RateLimit)
|
||||||
|
require.Equal(t, RateLimit{Rate: 12, Unit: PerMinute, Burst: 6}, *r.RateLimit)
|
||||||
|
|
||||||
|
require.True(t, f.isNativeLimit(r), "a logged native limit is still native")
|
||||||
|
require.False(t, f.needsIPTablesRules(r),
|
||||||
|
"a logged native limit must stay on the tuple path, not go to before.rules")
|
||||||
|
|
||||||
|
// It marshals to a tuple (limit + log) rather than erroring.
|
||||||
|
out, err := f.MarshalRule(r)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, out, "limit")
|
||||||
|
require.Contains(t, out, "log")
|
||||||
|
|
||||||
|
// A native limit carrying a *custom* prefix cannot be a tuple, so it must still
|
||||||
|
// route to before.rules.
|
||||||
|
prefixed := &Rule{Action: Accept, Proto: TCP, Port: 22, Log: true, LogPrefix: "MINE",
|
||||||
|
RateLimit: &RateLimit{Rate: 12, Unit: PerMinute, Burst: 6}}
|
||||||
|
require.False(t, f.isNativeLimit(prefixed), "a custom-prefix limit is not tuple-expressible")
|
||||||
|
require.True(t, f.needsIPTablesRules(prefixed), "a custom-prefix limit goes to before.rules")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UFW editIPTablesRulesFile remove must preserve an adjacent foreign LOG line that
|
||||||
|
// is not the removal target's own LOG half, rather than discarding it unflushed.
|
||||||
|
func TestUFWRemovePreservesForeignLogLine(t *testing.T) {
|
||||||
|
fw := new(UFW)
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "before.rules")
|
||||||
|
content := "*filter\n:ufw-before-input - [0:0]\n" +
|
||||||
|
"-A ufw-before-input -p tcp --dport 4000 -j LOG --log-prefix \"[FOREIGN] \"\n" +
|
||||||
|
"-A ufw-before-input -p icmp --icmp-type 8 -j DROP\n" +
|
||||||
|
"COMMIT\n"
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
||||||
|
|
||||||
|
icmp := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Drop}
|
||||||
|
_, err := fw.editIPTablesRulesFile(path, icmp, IPv4, true)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
out, err := os.ReadFile(path)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Contains(t, string(out), "[FOREIGN]", "the unrelated foreign LOG line must survive removal")
|
||||||
|
require.NotContains(t, string(out), "icmp", "the icmp rule must be removed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseRules reads a ufw user.rules file, decoding the `### tuple ###` lines and
|
||||||
|
// the hex-encoded trailing comment (with the configured prefix split off).
|
||||||
|
func TestUFWParseRulesFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "user.rules")
|
||||||
|
// A plain allow, and a deny carrying a prefixed comment "myapp dns".
|
||||||
|
comment := hex.EncodeToString([]byte("myapp dns"))
|
||||||
|
content := "*filter\n" +
|
||||||
|
"### tuple ### allow tcp 22 0.0.0.0/0 any 0.0.0.0/0 in\n" +
|
||||||
|
"### tuple ### deny udp 53 0.0.0.0/0 any 0.0.0.0/0 in comment=" + comment + "\n" +
|
||||||
|
"### RULES ###\n" +
|
||||||
|
"-A ufw-user-input -p tcp --dport 22 -j ACCEPT\n" +
|
||||||
|
"COMMIT\n"
|
||||||
|
require.NoError(t, os.WriteFile(path, []byte(content), 0644))
|
||||||
|
|
||||||
|
f := &UFW{rulePrefix: "myapp"}
|
||||||
|
rules, err := f.ParseRules(path, IPv4)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, rules, 2, "only the two ### tuple ### lines are parsed")
|
||||||
|
|
||||||
|
require.Equal(t, Accept, rules[0].Action)
|
||||||
|
require.Equal(t, TCP, rules[0].Proto)
|
||||||
|
require.EqualValues(t, 22, rules[0].Port)
|
||||||
|
require.Empty(t, rules[0].Comment)
|
||||||
|
require.False(t, rules[0].HasPrefix, "an un-commented rule is not flagged as ours")
|
||||||
|
|
||||||
|
require.Equal(t, Drop, rules[1].Action)
|
||||||
|
require.Equal(t, UDP, rules[1].Proto)
|
||||||
|
require.EqualValues(t, 53, rules[1].Port)
|
||||||
|
require.Equal(t, "dns", rules[1].Comment, "the configured prefix is split off the comment")
|
||||||
|
require.True(t, rules[1].HasPrefix, "the prefixed comment marks the rule as ours")
|
||||||
|
}
|
||||||
166
utils.go
Normal file
166
utils.go
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func trimQuotes(s string) string {
|
||||||
|
return strings.Trim(s, "\"'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// readConfValue scans a shell-style "KEY = \"VALUE\"" config file (conf.apf,
|
||||||
|
// csf.conf) for key and returns its value, or "" if key is not set. Used for
|
||||||
|
// one-shot flags read at construction time, not the per-rule list edits (which
|
||||||
|
// each backend's own EditConf/EditRulePort scan handles in place).
|
||||||
|
func readConfValue(path, key string) (string, error) {
|
||||||
|
fd, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() { _ = fd.Close() }()
|
||||||
|
scanner := bufio.NewScanner(fd)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if ci := strings.IndexByte(line, '#'); ci >= 0 {
|
||||||
|
line = line[:ci]
|
||||||
|
}
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, found := strings.Cut(line, "=")
|
||||||
|
if !found {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(k) == key {
|
||||||
|
return trimQuotes(strings.TrimSpace(v)), nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", scanner.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCommand runs command and returns its stdout and any error. The context
|
||||||
|
// bounds the command's lifetime: cancelling it kills the process.
|
||||||
|
func runCommand(ctx context.Context, command string, args ...string) (out []string, err error) {
|
||||||
|
return runCommandStdin(ctx, "", command, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// runCommandStdin runs command with the provided string fed to stdin, returning its stdout and any error.
|
||||||
|
func runCommandStdin(ctx context.Context, stdin string, command string, args ...string) (out []string, err error) {
|
||||||
|
cmd := exec.CommandContext(ctx, command, args...)
|
||||||
|
|
||||||
|
// Force the C locale so the backend tools emit their canonical, English output.
|
||||||
|
// Several backends match tool output to drive control flow — ufw's "Invalid
|
||||||
|
// position"/"Could not delete non-existent rule" fallbacks, CSF/APF restart
|
||||||
|
// messages — and those strings are gettext-translated. Without a pinned locale a
|
||||||
|
// non-English host would break the idempotent-remove and insert-append fallbacks
|
||||||
|
// (leaving a rule removed-and-not-re-added, or a no-op remove turned into an
|
||||||
|
// error). LC_ALL wins over LANG/LC_* so appending it last is sufficient.
|
||||||
|
cmd.Env = append(os.Environ(), "LC_ALL=C")
|
||||||
|
|
||||||
|
// Feed stdin when provided.
|
||||||
|
if stdin != "" {
|
||||||
|
cmd.Stdin = strings.NewReader(stdin)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get output pipes.
|
||||||
|
var stdout, stderr io.ReadCloser
|
||||||
|
stdout, err = cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stderr, err = cmd.StderrPipe()
|
||||||
|
if err != nil {
|
||||||
|
_ = stdout.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the command. Close the pipes on failure so their file descriptors do
|
||||||
|
// not leak; a started command's pipes are closed by Wait below.
|
||||||
|
err = cmd.Start()
|
||||||
|
if err != nil {
|
||||||
|
_ = stdout.Close()
|
||||||
|
_ = stderr.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup wait group to wait for buffers to fully read.
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
// The default bufio.Scanner token cap is 64 KB, but some backends emit a
|
||||||
|
// single very long line — notably `nft -j list sets`, whose entire JSON
|
||||||
|
// result is one line and can far exceed 64 KB for a large blocklist. Give
|
||||||
|
// each scanner a generous max so such a line is not silently truncated, and
|
||||||
|
// surface scanner.Err() so a line that still overflows fails loudly rather
|
||||||
|
// than returning partial output as success.
|
||||||
|
const maxLine = 64 * 1024 * 1024
|
||||||
|
var scanErr error
|
||||||
|
var scanMu sync.Mutex
|
||||||
|
recordScanErr := func(e error) {
|
||||||
|
if e == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
scanMu.Lock()
|
||||||
|
if scanErr == nil {
|
||||||
|
scanErr = e
|
||||||
|
}
|
||||||
|
scanMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read stdout.
|
||||||
|
stdoutScanner := bufio.NewScanner(stdout)
|
||||||
|
stdoutScanner.Buffer(make([]byte, 0, 64*1024), maxLine)
|
||||||
|
go func() {
|
||||||
|
for stdoutScanner.Scan() {
|
||||||
|
out = append(out, stdoutScanner.Text())
|
||||||
|
}
|
||||||
|
recordScanErr(stdoutScanner.Err())
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Read stderr.
|
||||||
|
var stderrData strings.Builder
|
||||||
|
stderrScanner := bufio.NewScanner(stderr)
|
||||||
|
stderrScanner.Buffer(make([]byte, 0, 64*1024), maxLine)
|
||||||
|
go func() {
|
||||||
|
for stderrScanner.Scan() {
|
||||||
|
line := stderrScanner.Text()
|
||||||
|
stderrData.WriteString(line)
|
||||||
|
stderrData.WriteByte('\n')
|
||||||
|
}
|
||||||
|
recordScanErr(stderrScanner.Err())
|
||||||
|
wg.Done()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for the stdout and stderr reader goroutines to drain before calling cmd.Wait.
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Wait for the command to finish.
|
||||||
|
err = cmd.Wait()
|
||||||
|
if err != nil {
|
||||||
|
stderr := strings.TrimSpace(stderrData.String())
|
||||||
|
if stderr != "" {
|
||||||
|
err = fmt.Errorf("%s (stdout %s)", stderr, strings.Join(out, "\n"))
|
||||||
|
}
|
||||||
|
// A command can fail and also produce truncated output; surface both so a
|
||||||
|
// read error is never hidden behind the command's own failure.
|
||||||
|
if scanErr != nil {
|
||||||
|
err = fmt.Errorf("%v; scanner: %w", err, scanErr)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// The process succeeded; report a read/truncation error if one occurred so a
|
||||||
|
// caller never mistakes truncated output for a complete result.
|
||||||
|
if scanErr != nil {
|
||||||
|
err = scanErr
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
906
wf_windows.go
Normal file
906
wf_windows.go
Normal file
|
|
@ -0,0 +1,906 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
wapi "github.com/iamacarpet/go-win64api"
|
||||||
|
"go4.org/netipx"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// WFType is the backend type string reported by WF.Type.
|
||||||
|
WFType = "windows-firewall"
|
||||||
|
// The IP protocol numbers for the transport/tunnel protocols the model adds.
|
||||||
|
// Windows filters by raw protocol number, so these map directly.
|
||||||
|
wfProtocolGRE = 47
|
||||||
|
wfProtocolESP = 50
|
||||||
|
wfProtocolAH = 51
|
||||||
|
wfProtocolSCTP = 132
|
||||||
|
// wfManagedDescription is the default filter description for rules this
|
||||||
|
// library creates that carry no user comment. It is treated as "no comment"
|
||||||
|
// on read so it does not surface as a Rule.Comment.
|
||||||
|
wfManagedDescription = "Managed by private-network firewall manager"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WF manages firewall rules through the Windows Filtering Platform via the
|
||||||
|
// go-win64api binding, tagging its rules with the configured name prefix.
|
||||||
|
type WF struct {
|
||||||
|
rulePrefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWF constructs a WF backend using rulePrefix as its rule-name namespace,
|
||||||
|
// confirming the Windows firewall interface is reachable.
|
||||||
|
func NewWF(ctx context.Context, rulePrefix string) (*WF, error) {
|
||||||
|
// Honor an already-cancelled context before touching the Windows API, whose
|
||||||
|
// calls are synchronous and cannot be cancelled mid-flight.
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
wf := &WF{
|
||||||
|
rulePrefix: rulePrefix,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Confirm the firewall interface works.
|
||||||
|
_, err := wapi.FirewallCurrentProfiles()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the firewall pointer.
|
||||||
|
return wf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Type returns the backend type string for the Windows firewall.
|
||||||
|
func (f *WF) Type() string {
|
||||||
|
return WFType
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetZone reports no zone; Windows Firewall is profile-based, so an interface maps to no single zone.
|
||||||
|
func (f *WF) GetZone(ctx context.Context, iface string) (zoneName string, err error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows Firewall is profile-based (Domain, Private, Public), and an interface
|
||||||
|
// can carry multiple profiles, so an interface name maps to no single zone.
|
||||||
|
// Return empty to fall back to profile-wide rules.
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeAddress normalizes a Windows Firewall address string to a single CIDR/IP.
|
||||||
|
func (f *WF) decodeAddress(addr string) (newAddr string, err error) {
|
||||||
|
addr = strings.TrimSpace(addr)
|
||||||
|
|
||||||
|
// If wildcard or empty, return empty string.
|
||||||
|
if addr == "*" || addr == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// WFP built-in rules frequently carry a comma-separated address list, which the
|
||||||
|
// single-valued Rule model cannot hold faithfully. Decode the first entry so the
|
||||||
|
// rule still surfaces on read instead of being silently dropped; the remaining
|
||||||
|
// addresses are not represented (a documented limitation of this backend).
|
||||||
|
if i := strings.IndexByte(addr, ','); i >= 0 {
|
||||||
|
return f.decodeAddress(strings.TrimSpace(addr[:i]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse IP range to single prefix if possible.
|
||||||
|
if strings.Contains(addr, "-") {
|
||||||
|
// Make IP range from parts.
|
||||||
|
r, err := netipx.ParseIPRange(addr)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get prefixes from the range.
|
||||||
|
prefixes := r.Prefixes()
|
||||||
|
if len(prefixes) != 1 {
|
||||||
|
return "", fmt.Errorf("unable to parse range into single prefix")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return the found prefix.
|
||||||
|
return prefixes[0].String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse prefix from cidr or ip/netmask.
|
||||||
|
// Example: 192.168.1.0/24 OR 192.168.1.0/255.255.255.0
|
||||||
|
if strings.Contains(addr, "/") {
|
||||||
|
// Parse CIDR or IP/netmask.
|
||||||
|
var ipNet *net.IPNet
|
||||||
|
var ip net.IP
|
||||||
|
|
||||||
|
// Split into parts and confirm the length.
|
||||||
|
parts := strings.Split(addr, "/")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
err = fmt.Errorf("invalid prefix length")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// The length side is a dotted-quad (IPv4) or colon-form (IPv6) netmask
|
||||||
|
// rather than a plain prefix length. A plain prefix is all digits, so any
|
||||||
|
// "." or ":" marks the ip/netmask form.
|
||||||
|
if strings.ContainsAny(parts[1], ".:") {
|
||||||
|
// Parse the netmask.
|
||||||
|
netmask := net.ParseIP(parts[1])
|
||||||
|
if netmask == nil {
|
||||||
|
err = fmt.Errorf("invalid netmask")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the network address.
|
||||||
|
ip = net.ParseIP(parts[0])
|
||||||
|
if ip == nil {
|
||||||
|
err = fmt.Errorf("invalid ip")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize an IPv4 pair to 4-byte form so the mask width matches the
|
||||||
|
// address width; leave IPv6 in 16-byte form. A family mismatch between
|
||||||
|
// the address and the netmask is invalid.
|
||||||
|
if m4 := netmask.To4(); m4 != nil {
|
||||||
|
ip4 := ip.To4()
|
||||||
|
if ip4 == nil {
|
||||||
|
err = fmt.Errorf("ip/netmask family mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
netmask, ip = m4, ip4
|
||||||
|
} else if ip.To4() != nil {
|
||||||
|
err = fmt.Errorf("ip/netmask family mismatch")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
mask := net.IPMask(netmask)
|
||||||
|
|
||||||
|
// Make the IP network, masking host bits so the ip/netmask form
|
||||||
|
// normalizes identically to the equivalent CIDR (net.ParseCIDR masks
|
||||||
|
// the network address; "192.168.1.5/255.255.255.0" and
|
||||||
|
// "192.168.1.5/24" must both decode to "192.168.1.0/24" or the two
|
||||||
|
// spellings would compare unequal in Rule.Equal).
|
||||||
|
ipNet = &net.IPNet{
|
||||||
|
IP: ip.Mask(mask),
|
||||||
|
Mask: mask,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For standard CIDRs try and parse normally.
|
||||||
|
ip, ipNet, err = net.ParseCIDR(addr)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get CIDR string.
|
||||||
|
newAddr = ipNet.String()
|
||||||
|
|
||||||
|
// See if this is an individual IP and update new address.
|
||||||
|
ones, bits := ipNet.Mask.Size()
|
||||||
|
if ones == bits {
|
||||||
|
newAddr = ip.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returned the parsed address.
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle single IP
|
||||||
|
ip := net.ParseIP(addr)
|
||||||
|
if ip == nil {
|
||||||
|
err = fmt.Errorf("invalid IP")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
newAddr = ip.String()
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshallFWRule decodes a Windows FWRule into a Rule, returning nil for a rule the model cannot represent.
|
||||||
|
func (f *WF) UnmarshallFWRule(fr wapi.FWRule) *Rule {
|
||||||
|
r := &Rule{}
|
||||||
|
|
||||||
|
// A rule scoped by an attribute this model cannot represent — an application
|
||||||
|
// path, a Windows service, or a specific interface-type category (LAN,
|
||||||
|
// Wireless, RemoteAccess) — would decode into a bare, unscoped rule, silently
|
||||||
|
// widening a narrow foreign rule ("allow inbound TCP for program X") into a
|
||||||
|
// match-all one ("allow all inbound TCP"). Windows ships many such built-in
|
||||||
|
// rules; surfacing them misrepresented would let them compare equal to a
|
||||||
|
// genuinely bare rule and be reconciled or removed as if identical. Drop them
|
||||||
|
// from the view instead, as the ICMP-code and multi-pair cases below do. Our
|
||||||
|
// own rules never set these fields (MarshallFWRule cannot express an interface
|
||||||
|
// match and never sets an application or service), so this hides only foreign
|
||||||
|
// rules the model cannot faithfully hold.
|
||||||
|
if fr.ApplicationName != "" || fr.ServiceName != "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if it := strings.TrimSpace(fr.InterfaceTypes); it != "" && !strings.EqualFold(it, "All") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map direction.
|
||||||
|
if fr.Direction == wapi.NET_FW_RULE_DIR_OUT {
|
||||||
|
r.Direction = DirOutput
|
||||||
|
} else {
|
||||||
|
r.Direction = DirInput
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map action.
|
||||||
|
switch fr.Action {
|
||||||
|
case wapi.NET_FW_ACTION_ALLOW:
|
||||||
|
r.Action = Accept
|
||||||
|
case wapi.NET_FW_ACTION_BLOCK:
|
||||||
|
r.Action = Drop
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map protocol.
|
||||||
|
switch fr.Protocol {
|
||||||
|
case wapi.NET_FW_IP_PROTOCOL_TCP:
|
||||||
|
r.Proto = TCP
|
||||||
|
case wapi.NET_FW_IP_PROTOCOL_UDP:
|
||||||
|
r.Proto = UDP
|
||||||
|
case wapi.NET_FW_IP_PROTOCOL_ANY:
|
||||||
|
r.Proto = ProtocolAny
|
||||||
|
case wapi.NET_FW_IP_PROTOCOL_ICMPv6:
|
||||||
|
r.Proto = ICMPv6
|
||||||
|
case wapi.NET_FW_IP_PROTOCOL_ICMPv4:
|
||||||
|
r.Proto = ICMP
|
||||||
|
case wfProtocolSCTP:
|
||||||
|
r.Proto = SCTP
|
||||||
|
case wfProtocolGRE:
|
||||||
|
r.Proto = GRE
|
||||||
|
case wfProtocolESP:
|
||||||
|
r.Proto = ESP
|
||||||
|
case wfProtocolAH:
|
||||||
|
r.Proto = AH
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode an ICMP type from the "type:code" field. Only a single type is
|
||||||
|
// modeled; a "*" (or empty) type matches every type.
|
||||||
|
if r.Proto.IsICMP() {
|
||||||
|
raw := strings.TrimSpace(fr.ICMPTypesAndCodes)
|
||||||
|
if raw != "" && raw != "*" {
|
||||||
|
// Multiple type:code pairs cannot be represented by a single rule.
|
||||||
|
if strings.Contains(raw, ",") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
typePart, codePart, hasCode := strings.Cut(raw, ":")
|
||||||
|
typePart = strings.TrimSpace(typePart)
|
||||||
|
codePart = strings.TrimSpace(codePart)
|
||||||
|
// The Rule model carries an ICMP type but no code. A rule scoped to a
|
||||||
|
// specific code (e.g. "3:4") cannot be represented, and re-adding it would
|
||||||
|
// emit "3:*" — silently widening it to every code of that type. Drop it
|
||||||
|
// from the view (like the multi-pair case above) rather than misrepresent
|
||||||
|
// and then widen it.
|
||||||
|
if hasCode && codePart != "" && codePart != "*" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if typePart != "" && typePart != "*" {
|
||||||
|
// Resolve a named type through the family-appropriate table: ICMPv6
|
||||||
|
// reuses several ICMPv4 names for different numbers. Windows stores
|
||||||
|
// types numerically, where both tables agree, so this only matters if
|
||||||
|
// a rule carries a named type.
|
||||||
|
n, ok := parseICMPTypeFamily(typePart, r.Proto == ICMPv6)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.ICMPType = Ptr(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Windows uses local/remote ports; map them by direction. For an input rule the
|
||||||
|
// destination is the local port and the source is the remote port; for an output
|
||||||
|
// rule it is reversed. Windows expresses each as a string that may hold a list
|
||||||
|
// and dash ranges (e.g. "80,443,1000-2000").
|
||||||
|
destPortsRaw, srcPortsRaw := fr.LocalPorts, fr.RemotePorts
|
||||||
|
if r.IsOutput() {
|
||||||
|
destPortsRaw, srcPortsRaw = fr.RemotePorts, fr.LocalPorts
|
||||||
|
}
|
||||||
|
if r.Proto == TCP || r.Proto == UDP {
|
||||||
|
if destPortsRaw != "" && destPortsRaw != "*" {
|
||||||
|
specs, err := ParsePortRanges(destPortsRaw, ",")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
||||||
|
r.Port = specs[0].Start
|
||||||
|
} else {
|
||||||
|
r.Ports = specs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if srcPortsRaw != "" && srcPortsRaw != "*" {
|
||||||
|
specs, err := ParsePortRanges(srcPortsRaw, ",")
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(specs) == 1 && specs[0].Start == specs[0].End {
|
||||||
|
r.SourcePort = specs[0].Start
|
||||||
|
} else {
|
||||||
|
r.SourcePorts = specs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Based on direction, map the source and destination address.
|
||||||
|
// Our rule uses source/destination where as windows uses local/remote.
|
||||||
|
var srcRaw, dstRaw string
|
||||||
|
if r.IsOutput() {
|
||||||
|
srcRaw = fr.LocalAddresses
|
||||||
|
dstRaw = fr.RemoteAddresses
|
||||||
|
} else {
|
||||||
|
srcRaw = fr.RemoteAddresses
|
||||||
|
dstRaw = fr.LocalAddresses
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse addresses.
|
||||||
|
var err error
|
||||||
|
r.Source, err = f.decodeAddress(srcRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
r.Destination, err = f.decodeAddress(dstRaw)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map family.
|
||||||
|
r.Family = FamilyAny
|
||||||
|
if strings.Contains(r.Source, ":") || strings.Contains(r.Destination, ":") {
|
||||||
|
r.Family = IPv6
|
||||||
|
} else if strings.Contains(r.Source, ".") || strings.Contains(r.Destination, ".") {
|
||||||
|
r.Family = IPv4
|
||||||
|
}
|
||||||
|
|
||||||
|
// A description other than our managed default is a user comment.
|
||||||
|
if fr.Description != "" && fr.Description != wfManagedDescription {
|
||||||
|
r.Comment = fr.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasPrefix reports whether a listed rule's name carries the configured prefix
|
||||||
|
// (see MarshallFWRule), marking it as one this manager tagged. Everything else —
|
||||||
|
// notably Windows' many built-in rules — reports false. With no prefix the
|
||||||
|
// manager has no namespace of its own, so no rule reports HasPrefix.
|
||||||
|
func (f *WF) hasPrefix(fr wapi.FWRule) bool {
|
||||||
|
return f.rulePrefix != "" && strings.HasPrefix(fr.Name, f.rulePrefix+" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRules returns the existing filter rules from the zone.
|
||||||
|
func (f *WF) GetRules(ctx context.Context, zoneName string) (rules []*Rule, err error) {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
fwRules, err := wapi.FirewallRulesGet()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to fetch firewall rules: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by profile if a zone names one.
|
||||||
|
filterProfile, useFilter := f.profileFilter(zoneName)
|
||||||
|
|
||||||
|
// Parse all rules. Windows ships hundreds of built-in rules; rather than hide
|
||||||
|
// them, every rule is surfaced with HasPrefix reporting whether this manager
|
||||||
|
// tagged it (identified by the configured name prefix), so callers can tell
|
||||||
|
// them apart.
|
||||||
|
for _, fr := range fwRules {
|
||||||
|
// If filtered by profile, skip rules not scoped to exactly this profile.
|
||||||
|
if !f.profileMatches(fr.Profiles, filterProfile, useFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the rule and skip it if it cannot be decoded.
|
||||||
|
r := f.UnmarshallFWRule(fr)
|
||||||
|
if r == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
r.HasPrefix = f.hasPrefix(fr)
|
||||||
|
|
||||||
|
// Add decoded rule to list.
|
||||||
|
rules = append(rules, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapse each inbound rule and its outbound twin into one DirAny rule. WFP
|
||||||
|
// stores an inbound and an outbound filter as separate objects; the local/remote
|
||||||
|
// swap the marshal path applies means a both-directions allow reads back as an
|
||||||
|
// input (source) rule plus an output (destination) rule, which merge here.
|
||||||
|
rules = mergeDirections(rules)
|
||||||
|
|
||||||
|
return rules, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileFilter maps a zone name to the single Windows profile bit that GetRules
|
||||||
|
// and RemoveRule filter on, so both scope to the same rules. ok is false when the
|
||||||
|
// zone names no specific profile, meaning every profile is in scope (matching an
|
||||||
|
// AddRule that stored the rule under the all-profiles default). A rule matches only
|
||||||
|
// when its Profiles exactly equals this single bit: AddRule always stores a named
|
||||||
|
// zone's rule under exactly one profile bit or the all-profiles default (never a
|
||||||
|
// combination), so an exact-equality test is what keeps a specific zone's rules
|
||||||
|
// disjoint from another zone's and from an all-profiles rule. Testing overlap
|
||||||
|
// instead (fr.Profiles&profile != 0) would let a single-zone query and, worse, a
|
||||||
|
// single-zone RemoveRule/Sync match and delete an all-profiles rule — silently
|
||||||
|
// affecting every other zone too.
|
||||||
|
func (f *WF) profileFilter(zoneName string) (profile int32, ok bool) {
|
||||||
|
switch {
|
||||||
|
case strings.EqualFold(zoneName, "public"):
|
||||||
|
return wapi.NET_FW_PROFILE2_PUBLIC, true
|
||||||
|
case strings.EqualFold(zoneName, "private"):
|
||||||
|
return wapi.NET_FW_PROFILE2_PRIVATE, true
|
||||||
|
case strings.EqualFold(zoneName, "domain"):
|
||||||
|
return wapi.NET_FW_PROFILE2_DOMAIN, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileMatches reports whether a rule's Profiles bitmask is in scope for a
|
||||||
|
// profileFilter result: every rule matches when useFilter is false, otherwise
|
||||||
|
// only a rule scoped to exactly filterProfile (see profileFilter on why exact).
|
||||||
|
func (f *WF) profileMatches(rulesProfiles, filterProfile int32, useFilter bool) bool {
|
||||||
|
if !useFilter {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return rulesProfiles == filterProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshallFWRule encodes a Rule as a Windows FWRule for the given zone.
|
||||||
|
func (f *WF) MarshallFWRule(zoneName string, r *Rule) (*wapi.FWRule, error) {
|
||||||
|
// The Windows Firewall rule model has only inbound and outbound directions;
|
||||||
|
// forwarded (routed) traffic is handled out of band (RRAS/portproxy), so a
|
||||||
|
// forward rule cannot be expressed here.
|
||||||
|
if r.IsForward() {
|
||||||
|
return nil, unsupportedForward("windows firewall")
|
||||||
|
}
|
||||||
|
// Windows Filtering Platform cannot match a port without a concrete
|
||||||
|
// protocol; dropping the port would silently widen the rule to match all
|
||||||
|
// traffic, so reject it instead.
|
||||||
|
if r.PortNeedsConcreteProtocol() {
|
||||||
|
return nil, fmt.Errorf("a port requires a tcp, udp or sctp protocol")
|
||||||
|
}
|
||||||
|
// Windows only matches ports for TCP and UDP; a port on any other protocol
|
||||||
|
// (e.g. SCTP) has no representation, so reject rather than silently drop it.
|
||||||
|
if (r.HasPorts() || r.HasSourcePorts()) && r.Proto != TCP && r.Proto != UDP {
|
||||||
|
return nil, fmt.Errorf("windows firewall only matches ports for tcp or udp: %w", ErrUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features this backend cannot express are rejected up front rather than
|
||||||
|
// silently dropped. Windows firewall rules are stateful by default, and the
|
||||||
|
// go-win64api binding cannot set the interface fields, so neither a
|
||||||
|
// connection-state nor a per-rule interface match is expressible here.
|
||||||
|
if r.State != 0 {
|
||||||
|
return nil, fmt.Errorf("windows firewall does not support connection-state matching in this model: %w", ErrUnsupportedState)
|
||||||
|
}
|
||||||
|
if r.InInterface != "" || r.OutInterface != "" {
|
||||||
|
return nil, fmt.Errorf("windows firewall does not support per-rule interface matching in this model: %w", ErrUnsupportedInterface)
|
||||||
|
}
|
||||||
|
// WFP has no reject action; mapping Reject to Drop would make a rule added
|
||||||
|
// as Reject unreadable as Reject and impossible to reconcile. Reject it up
|
||||||
|
// front so callers use Drop instead.
|
||||||
|
if r.Action == Reject {
|
||||||
|
return nil, fmt.Errorf("windows firewall has no reject action: %w", ErrUnsupported)
|
||||||
|
}
|
||||||
|
// WFP cannot express address negation (decodeAddress rejects a '!'-prefixed
|
||||||
|
// token on read), so reject it up front rather than passing an invalid
|
||||||
|
// address string to Windows and producing a rule that can never be read back
|
||||||
|
// or removed.
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(r.Source), "!") || strings.HasPrefix(strings.TrimSpace(r.Destination), "!") {
|
||||||
|
return nil, fmt.Errorf("windows firewall does not support address negation in this model: %w", ErrUnsupported)
|
||||||
|
}
|
||||||
|
// A WFP rule carries an IP family only through an address or an ICMP protocol.
|
||||||
|
// An explicit Family with neither cannot be honored: applying the rule to both
|
||||||
|
// families widens it, and it reads back as FamilyAny so it can never reconcile.
|
||||||
|
// Reject it rather than widen.
|
||||||
|
if r.Family != FamilyAny && !r.Proto.IsICMP() &&
|
||||||
|
familyOfAddr(r.Source) == FamilyAny && familyOfAddr(r.Destination) == FamilyAny {
|
||||||
|
return nil, fmt.Errorf("windows firewall cannot scope a rule to an IP family without an address; use family any or add an address: %w", ErrUnsupported)
|
||||||
|
}
|
||||||
|
if err := r.checkICMPType(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup base rule. A user comment is carried in the filter description;
|
||||||
|
// otherwise the managed default marks the rule as ours.
|
||||||
|
fwRule := &wapi.FWRule{
|
||||||
|
Description: wfManagedDescription,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if r.Comment != "" {
|
||||||
|
fwRule.Description = r.Comment
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base rule name.
|
||||||
|
// Format: [prefix] [dir] [proto] [port X] [from S] [to D] [allow/block]
|
||||||
|
var nameParts []string
|
||||||
|
if f.rulePrefix != "" {
|
||||||
|
nameParts = append(nameParts, f.rulePrefix)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the profile based on provided zone, and fold the profile into the rule
|
||||||
|
// name. RemoveRule deletes by name, so two otherwise-identical rules added to
|
||||||
|
// different profiles must get distinct names — otherwise removing one deletes
|
||||||
|
// the other. An all-profiles (default) rule keeps the plain name.
|
||||||
|
switch strings.ToLower(zoneName) {
|
||||||
|
case "private":
|
||||||
|
fwRule.Profiles = wapi.NET_FW_PROFILE2_PRIVATE
|
||||||
|
nameParts = append(nameParts, "private")
|
||||||
|
case "public":
|
||||||
|
fwRule.Profiles = wapi.NET_FW_PROFILE2_PUBLIC
|
||||||
|
nameParts = append(nameParts, "public")
|
||||||
|
case "domain":
|
||||||
|
fwRule.Profiles = wapi.NET_FW_PROFILE2_DOMAIN
|
||||||
|
nameParts = append(nameParts, "domain")
|
||||||
|
default:
|
||||||
|
fwRule.Profiles = wapi.NET_FW_PROFILE2_ALL
|
||||||
|
}
|
||||||
|
|
||||||
|
// Based on the rule direction.
|
||||||
|
if r.IsOutput() {
|
||||||
|
nameParts = append(nameParts, "out")
|
||||||
|
fwRule.Direction = wapi.NET_FW_RULE_DIR_OUT
|
||||||
|
} else {
|
||||||
|
nameParts = append(nameParts, "in")
|
||||||
|
fwRule.Direction = wapi.NET_FW_RULE_DIR_IN
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the protocol.
|
||||||
|
fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_ANY
|
||||||
|
switch r.Proto {
|
||||||
|
case TCP:
|
||||||
|
fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_TCP
|
||||||
|
nameParts = append(nameParts, "tcp")
|
||||||
|
case UDP:
|
||||||
|
fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_UDP
|
||||||
|
nameParts = append(nameParts, "udp")
|
||||||
|
case ICMP:
|
||||||
|
fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_ICMPv4
|
||||||
|
nameParts = append(nameParts, "icmp")
|
||||||
|
case ICMPv6:
|
||||||
|
fwRule.Protocol = wapi.NET_FW_IP_PROTOCOL_ICMPv6
|
||||||
|
nameParts = append(nameParts, "icmpv6")
|
||||||
|
case SCTP:
|
||||||
|
fwRule.Protocol = wfProtocolSCTP
|
||||||
|
nameParts = append(nameParts, "sctp")
|
||||||
|
case GRE:
|
||||||
|
fwRule.Protocol = wfProtocolGRE
|
||||||
|
nameParts = append(nameParts, "gre")
|
||||||
|
case ESP:
|
||||||
|
fwRule.Protocol = wfProtocolESP
|
||||||
|
nameParts = append(nameParts, "esp")
|
||||||
|
case AH:
|
||||||
|
fwRule.Protocol = wfProtocolAH
|
||||||
|
nameParts = append(nameParts, "ah")
|
||||||
|
}
|
||||||
|
|
||||||
|
// An ICMP type match is expressed via the ICMPTypesAndCodes field as
|
||||||
|
// "type:code"; a "*" code matches every code of that type.
|
||||||
|
if r.Proto.IsICMP() && r.ICMPType != nil {
|
||||||
|
fwRule.ICMPTypesAndCodes = fmt.Sprintf("%d:*", *r.ICMPType)
|
||||||
|
nameParts = append(nameParts, "type", fmt.Sprintf("%d", *r.ICMPType))
|
||||||
|
}
|
||||||
|
|
||||||
|
// If TCP/UDP, and a port set is defined, add the ports. Windows firewall
|
||||||
|
// accepts a comma list with dash ranges (e.g. "80,443,1000-2000").
|
||||||
|
dstSpecs := r.PortSpecs()
|
||||||
|
if (r.Proto == TCP || r.Proto == UDP) && len(dstSpecs) > 0 {
|
||||||
|
portS := FormatPortRanges(dstSpecs, ",")
|
||||||
|
nameParts = append(nameParts, "port", portS)
|
||||||
|
|
||||||
|
// Set remote/local based on direction.
|
||||||
|
if r.IsOutput() {
|
||||||
|
fwRule.RemotePorts = portS
|
||||||
|
} else {
|
||||||
|
fwRule.LocalPorts = portS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Source ports are mapped to the opposite side of direction.
|
||||||
|
srcSpecs := r.SourcePortSpecs()
|
||||||
|
if (r.Proto == TCP || r.Proto == UDP) && len(srcSpecs) > 0 {
|
||||||
|
portS := FormatPortRanges(srcSpecs, ",")
|
||||||
|
nameParts = append(nameParts, "sport", portS)
|
||||||
|
|
||||||
|
if r.IsOutput() {
|
||||||
|
fwRule.LocalPorts = portS
|
||||||
|
} else {
|
||||||
|
fwRule.RemotePorts = portS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add addresses according to direction due to difference in local/remote vs source/dest.
|
||||||
|
if r.IsOutput() {
|
||||||
|
fwRule.LocalAddresses = r.Source
|
||||||
|
fwRule.RemoteAddresses = r.Destination
|
||||||
|
} else {
|
||||||
|
fwRule.RemoteAddresses = r.Source
|
||||||
|
fwRule.LocalAddresses = r.Destination
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append addresses to name if present.
|
||||||
|
if r.Source != "" {
|
||||||
|
nameParts = append(nameParts, "from", r.Source)
|
||||||
|
}
|
||||||
|
if r.Destination != "" {
|
||||||
|
nameParts = append(nameParts, "to", r.Destination)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the rule action.
|
||||||
|
if r.Action == Accept {
|
||||||
|
nameParts = append(nameParts, "allow")
|
||||||
|
fwRule.Action = wapi.NET_FW_ACTION_ALLOW
|
||||||
|
} else {
|
||||||
|
nameParts = append(nameParts, "block")
|
||||||
|
fwRule.Action = wapi.NET_FW_ACTION_BLOCK
|
||||||
|
}
|
||||||
|
|
||||||
|
fwRule.Name = strings.Join(nameParts, " ")
|
||||||
|
|
||||||
|
// Set the grouping
|
||||||
|
fwRule.Grouping = f.rulePrefix
|
||||||
|
|
||||||
|
return fwRule, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddRule adds a rule to the zone.
|
||||||
|
func (f *WF) AddRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if r == nil {
|
||||||
|
return fmt.Errorf("rule cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A DirAny rule fans out into an inbound filter plus its role-swapped outbound
|
||||||
|
// filter; WFP stores each direction as its own rule object.
|
||||||
|
if r.Direction == DirAny {
|
||||||
|
for _, sub := range expandDirections(r) {
|
||||||
|
if err := f.AddRule(ctx, zoneName, sub); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := r.rejectLogAndLimit(f.Type()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Encode the rule.
|
||||||
|
fwRule, err := f.MarshallFWRule(zoneName, r)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to marshall rule: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if an equivalent rule already exists: Windows rejects a duplicate rule
|
||||||
|
// name, and AddRule is expected to be idempotent like the other backends.
|
||||||
|
if existing, gerr := f.GetRules(ctx, zoneName); gerr == nil {
|
||||||
|
for _, e := range existing {
|
||||||
|
// Any equivalent rule already in the firewall counts as a duplicate
|
||||||
|
// (Windows rejects a duplicate rule name), so the add stays idempotent.
|
||||||
|
if e.EqualBase(r, true) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attempt to add the rule. FirewallRuleAddAdvanced reports success=false with a
|
||||||
|
// nil error in exactly one case: a rule already exists under this exact Name;
|
||||||
|
// every other failure carries a non-nil error. That is a benign duplicate (a
|
||||||
|
// name collision the EqualBase check above missed, e.g. a concurrent or foreign
|
||||||
|
// add), so treat it as a no-op rather than a failure.
|
||||||
|
success, err := wapi.FirewallRuleAddAdvanced(*fwRule)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to add firewall rule %q: %w", fwRule.Name, err)
|
||||||
|
}
|
||||||
|
if !success {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertRule is unsupported: Windows Filtering Platform rules are not
|
||||||
|
// positionally ordered in the same way as iptables/nftables.
|
||||||
|
func (f *WF) InsertRule(ctx context.Context, zoneName string, position int, r *Rule) error {
|
||||||
|
return unsupportedOrdering(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoveRule is unsupported for the same reason as InsertRule.
|
||||||
|
func (f *WF) MoveRule(ctx context.Context, zoneName string, r *Rule, position int) error {
|
||||||
|
return unsupportedOrdering(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveRule removes a rule from the zone.
|
||||||
|
func (f *WF) RemoveRule(ctx context.Context, zoneName string, r *Rule) error {
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// A DirAny target removes both its inbound and its role-swapped outbound filter.
|
||||||
|
if r.Direction == DirAny {
|
||||||
|
for _, sub := range expandDirections(r) {
|
||||||
|
if err := f.RemoveRule(ctx, zoneName, sub); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A WFP rule carries an IP family only through an address or an ICMP protocol,
|
||||||
|
// so a concrete-family rule with neither is unexpressible (see AddRule) — and a
|
||||||
|
// FamilyAny bare rule is stored as one dual-family filter. A concrete-family
|
||||||
|
// removal of that shape could only be honored by dropping the dual filter, which
|
||||||
|
// takes the untargeted family with it; the surviving single-family rule cannot
|
||||||
|
// be re-added without an address. Reject it rather than over-remove.
|
||||||
|
if r.Family != FamilyAny && !r.Proto.IsICMP() &&
|
||||||
|
familyOfAddr(r.Source) == FamilyAny && familyOfAddr(r.Destination) == FamilyAny {
|
||||||
|
return fmt.Errorf("windows firewall cannot scope a removal to an IP family without an address; use family any or add an address: %w", ErrUnsupported)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get a list of existing rules.
|
||||||
|
fwRules, err := wapi.FirewallRulesGet()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to list rules for deletion: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scope the deletion to the same profile GetRules/AddRule use for this zone, so
|
||||||
|
// each zone stays isolated: the manager adds and lists rules per profile, so it
|
||||||
|
// must also remove them per profile. profileMatches' exact-equality match (not
|
||||||
|
// overlap) is what keeps this from also deleting an all-profiles rule when
|
||||||
|
// zoneName names a single zone.
|
||||||
|
filterProfile, useFilter := f.profileFilter(zoneName)
|
||||||
|
|
||||||
|
// Delete every matching rule. EqualBase ignores the IP family because Windows
|
||||||
|
// records a concrete family on the rule it lists back even when the added rule
|
||||||
|
// left it unset (mirroring GetRules). Removal is idempotent, matching the other
|
||||||
|
// backends: a rule that is not present is not an error.
|
||||||
|
for _, fr := range fwRules {
|
||||||
|
// Skip rules outside the target profile, matching GetRules' filter.
|
||||||
|
if !f.profileMatches(fr.Profiles, filterProfile, useFilter) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode the rule, and skip if it can't be decoded.
|
||||||
|
rule := f.UnmarshallFWRule(fr)
|
||||||
|
if rule == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if r.EqualBase(rule, true) {
|
||||||
|
ok, err := wapi.FirewallRuleDelete(fr.Name)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to delete rule %q: %w", fr.Name, err)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("failed to delete rule %q: reported failure", fr.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload is a no-op; Windows Firewall applies rule changes immediately.
|
||||||
|
func (f *WF) Reload(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close releases any resources held by the backend; the Windows firewall holds none.
|
||||||
|
func (f *WF) Close(ctx context.Context) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capabilities returns the set of features the Windows firewall backend can express.
|
||||||
|
func (f *WF) Capabilities() Capabilities {
|
||||||
|
return Capabilities{
|
||||||
|
Output: true,
|
||||||
|
ICMPv6: true,
|
||||||
|
PortList: true,
|
||||||
|
ConnState: false,
|
||||||
|
InterfaceMatch: false,
|
||||||
|
Logging: false,
|
||||||
|
RateLimit: false,
|
||||||
|
ConnLimit: false,
|
||||||
|
NAT: false,
|
||||||
|
RuleOrdering: false,
|
||||||
|
DefaultPolicy: false,
|
||||||
|
RuleCounters: false,
|
||||||
|
AddressSets: false,
|
||||||
|
Comments: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDefaultPolicy is unsupported; the Windows firewall exposes no default policy in this model.
|
||||||
|
func (f *WF) GetDefaultPolicy(ctx context.Context, zoneName string) (*DefaultPolicy, error) {
|
||||||
|
return nil, unsupportedPolicy(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetDefaultPolicy is unsupported; the Windows firewall exposes no default policy in this model.
|
||||||
|
func (f *WF) SetDefaultPolicy(ctx context.Context, zoneName string, policy *DefaultPolicy) error {
|
||||||
|
return unsupportedPolicy(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAddressSets is unsupported; the Windows firewall backend has no address sets.
|
||||||
|
func (f *WF) GetAddressSets(ctx context.Context) ([]*AddressSet, error) {
|
||||||
|
return nil, unsupportedSet(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAddressSet is unsupported; the Windows firewall backend has no address sets.
|
||||||
|
func (f *WF) GetAddressSet(ctx context.Context, name string) (*AddressSet, error) {
|
||||||
|
return nil, unsupportedSet(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAddressSet is unsupported; the Windows firewall backend has no address sets.
|
||||||
|
func (f *WF) AddAddressSet(ctx context.Context, set *AddressSet) error {
|
||||||
|
return unsupportedSet(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAddressSet is unsupported; the Windows firewall backend has no address sets.
|
||||||
|
func (f *WF) RemoveAddressSet(ctx context.Context, name string) error {
|
||||||
|
return unsupportedSet(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddAddressSetEntry is unsupported; the Windows firewall backend has no address sets.
|
||||||
|
func (f *WF) AddAddressSetEntry(ctx context.Context, name, entry string) error {
|
||||||
|
return unsupportedSet(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveAddressSetEntry is unsupported; the Windows firewall backend has no address sets.
|
||||||
|
func (f *WF) RemoveAddressSetEntry(ctx context.Context, name, entry string) error {
|
||||||
|
return unsupportedSet(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup captures the current filter rules managed by this backend.
|
||||||
|
func (f *WF) Backup(ctx context.Context, zoneName string) (*Backup, error) {
|
||||||
|
rules, err := f.GetRules(ctx, zoneName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Backup captures the full filter rule state; Restore reconciles the live rules
|
||||||
|
// to this set, so every rule read is preserved.
|
||||||
|
return &Backup{Rules: rules}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restore replaces the managed rules with the contents of a Backup.
|
||||||
|
func (f *WF) Restore(ctx context.Context, zoneName string, backup *Backup) error {
|
||||||
|
if backup == nil {
|
||||||
|
return fmt.Errorf("backup cannot be nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reconcile the live rule set to the backup with a minimal add/remove diff
|
||||||
|
// rather than removing every rule and re-adding it. Removing all rules first
|
||||||
|
// leaves a window with no matching filter, and WFP drops in-flight connections
|
||||||
|
// that no longer match one — including a foreign inbound-allow rule the backup
|
||||||
|
// itself captured (e.g. the rule keeping this host reachable over SSH while a
|
||||||
|
// remote restore runs). Sync leaves a rule present in both the firewall and the
|
||||||
|
// backup untouched, so such a rule is never briefly removed. WFP has no NAT, so
|
||||||
|
// backup.NATRules is not applied here.
|
||||||
|
_, _, err := Sync(ctx, f, zoneName, backup.Rules)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetNATRules is unsupported; WFP is a stateful packet filter only and NAT on
|
||||||
|
// Windows is handled out of band (netsh portproxy or RRAS).
|
||||||
|
func (f *WF) GetNATRules(ctx context.Context, zoneName string) ([]*NATRule, error) {
|
||||||
|
return nil, unsupportedNAT(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
|
||||||
|
func (f *WF) AddNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
||||||
|
return unsupportedNAT(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// InsertNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
|
||||||
|
func (f *WF) InsertNATRule(ctx context.Context, zoneName string, position int, r *NATRule) error {
|
||||||
|
return unsupportedNAT(f.Type())
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveNATRule is unsupported; the Windows firewall backend has no NAT (see GetNATRules).
|
||||||
|
func (f *WF) RemoveNATRule(ctx context.Context, zoneName string, r *NATRule) error {
|
||||||
|
return unsupportedNAT(f.Type())
|
||||||
|
}
|
||||||
296
wf_windows_test.go
Normal file
296
wf_windows_test.go
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
package firewall
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
wapi "github.com/iamacarpet/go-win64api"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWFFeatureRules(t *testing.T) {
|
||||||
|
fw := &WF{rulePrefix: "test"}
|
||||||
|
|
||||||
|
// Round-trip the rule shapes the WFP backend supports: ICMP/ICMPv6
|
||||||
|
// protocols and single/list/range ports.
|
||||||
|
rules := []*Rule{
|
||||||
|
{Proto: ICMP, Action: Accept},
|
||||||
|
{Proto: ICMPv6, Action: Drop},
|
||||||
|
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||||
|
{Proto: ICMPv6, ICMPType: Ptr[uint8](135), Action: Drop},
|
||||||
|
{Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
|
||||||
|
{Direction: DirOutput, Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
fr, err := fw.MarshallFWRule("", r)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *r)
|
||||||
|
|
||||||
|
parsed := fw.UnmarshallFWRule(*fr)
|
||||||
|
require.NotNil(t, parsed, "failed to parse marshalled rule for %+v", *r)
|
||||||
|
require.True(t, parsed.Equal(r, true),
|
||||||
|
"round-trip mismatch: input %+v, output %+v", *r, parsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Features the WFP model cannot express are rejected.
|
||||||
|
unsupported := []*Rule{
|
||||||
|
{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
|
||||||
|
{InInterface: "Ethernet", Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
}
|
||||||
|
for _, r := range unsupported {
|
||||||
|
_, err := fw.MarshallFWRule("", r)
|
||||||
|
require.Error(t, err, "expected error marshalling unsupported rule %+v", *r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWFFamilyScopeRejected verifies that a rule scoped to a single IP family
|
||||||
|
// but carrying no family-determining match (no address, non-ICMP protocol) is
|
||||||
|
// rejected rather than silently applied to both families. WFP has no per-rule
|
||||||
|
// family selector, so honoring such a Family is impossible; the rule would widen
|
||||||
|
// to both stacks and read back as FamilyAny, churning on every Sync.
|
||||||
|
func TestWFFamilyScopeRejected(t *testing.T) {
|
||||||
|
fw := &WF{rulePrefix: "test"}
|
||||||
|
|
||||||
|
// Unrepresentable: an explicit family with nothing to carry it.
|
||||||
|
for _, r := range []*Rule{
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
{Family: IPv6, Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
{Family: IPv6, Proto: UDP, Action: Accept},
|
||||||
|
} {
|
||||||
|
_, err := fw.MarshallFWRule("", r)
|
||||||
|
require.Error(t, err, "expected a family-scope rejection for %+v", *r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Representable: FamilyAny (no scope), an address carries the family, or ICMP
|
||||||
|
// implies it. These round-trip.
|
||||||
|
for _, r := range []*Rule{
|
||||||
|
{Proto: TCP, Port: 22, Action: Accept},
|
||||||
|
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept},
|
||||||
|
{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept},
|
||||||
|
{Family: IPv4, Proto: ICMP, Action: Accept},
|
||||||
|
} {
|
||||||
|
fr, err := fw.MarshallFWRule("", r)
|
||||||
|
require.NoError(t, err, "expected %+v to marshal", *r)
|
||||||
|
parsed := fw.UnmarshallFWRule(*fr)
|
||||||
|
require.NotNil(t, parsed)
|
||||||
|
require.True(t, parsed.Equal(r, true), "round-trip mismatch: input %+v, output %+v", *r, parsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWFProtocolAndComment round-trips the added portless IP protocols (mapped
|
||||||
|
// to raw protocol numbers) and a rule comment (carried in the filter
|
||||||
|
// description). A port on a non-tcp/udp protocol is rejected.
|
||||||
|
func TestWFProtocolAndComment(t *testing.T) {
|
||||||
|
fw := &WF{rulePrefix: "test"}
|
||||||
|
|
||||||
|
rules := []*Rule{
|
||||||
|
{Proto: GRE, Action: Accept},
|
||||||
|
{Proto: ESP, Action: Accept},
|
||||||
|
{Proto: AH, Action: Drop},
|
||||||
|
{Proto: TCP, Port: 22, Action: Accept, Comment: "ssh access"},
|
||||||
|
}
|
||||||
|
for _, r := range rules {
|
||||||
|
fr, err := fw.MarshallFWRule("", r)
|
||||||
|
require.NoError(t, err, "failed to marshal %+v", *r)
|
||||||
|
parsed := fw.UnmarshallFWRule(*fr)
|
||||||
|
require.NotNil(t, parsed, "failed to parse %+v", *r)
|
||||||
|
require.True(t, parsed.Equal(r, true), "round-trip mismatch: %+v vs %+v", *r, parsed)
|
||||||
|
require.Equal(t, r.Comment, parsed.Comment, "comment round-trip for %+v", *r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCTP carries ports in the model, but WFP cannot match ports on it.
|
||||||
|
_, err := fw.MarshallFWRule("", &Rule{Proto: SCTP, Port: 9000, Action: Accept})
|
||||||
|
require.Error(t, err, "WFP should reject a port on SCTP")
|
||||||
|
|
||||||
|
// A portless SCTP rule is fine.
|
||||||
|
fr, err := fw.MarshallFWRule("", &Rule{Proto: SCTP, Action: Accept})
|
||||||
|
require.NoError(t, err)
|
||||||
|
parsed := fw.UnmarshallFWRule(*fr)
|
||||||
|
require.NotNil(t, parsed)
|
||||||
|
require.Equal(t, SCTP, parsed.Proto)
|
||||||
|
|
||||||
|
// Windows Firewall has only inbound/outbound directions, so a forward rule is
|
||||||
|
// rejected with the ErrUnsupportedForward sentinel.
|
||||||
|
_, err = fw.MarshallFWRule("", &Rule{Direction: DirForward, Proto: TCP, Port: 80, Action: Accept})
|
||||||
|
require.ErrorIs(t, err, ErrUnsupportedForward, "a forward rule must be rejected")
|
||||||
|
require.False(t, fw.Capabilities().Forward, "windows firewall does not advertise forward support")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A named ICMPv6 type must resolve through the ICMPv6 table, not the ICMPv4 one:
|
||||||
|
// several names (e.g. echo-request) map to different numbers per family. Windows
|
||||||
|
// stores types numerically so this path is dormant in practice, but the decode
|
||||||
|
// must still be family-correct.
|
||||||
|
func TestWFICMPv6NamedType(t *testing.T) {
|
||||||
|
fw := &WF{rulePrefix: "test"}
|
||||||
|
fr := wapi.FWRule{
|
||||||
|
Protocol: wapi.NET_FW_IP_PROTOCOL_ICMPv6,
|
||||||
|
ICMPTypesAndCodes: "echo-request:*",
|
||||||
|
Action: wapi.NET_FW_ACTION_ALLOW,
|
||||||
|
Direction: wapi.NET_FW_RULE_DIR_IN,
|
||||||
|
}
|
||||||
|
r := fw.UnmarshallFWRule(fr)
|
||||||
|
require.NotNil(t, r)
|
||||||
|
require.NotNil(t, r.ICMPType)
|
||||||
|
require.Equal(t, uint8(128), *r.ICMPType, "ICMPv6 echo-request is type 128, not the ICMPv4 value 8")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWFUnsupportedLogLimitAndNAT(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
fw := &WF{rulePrefix: "test"}
|
||||||
|
|
||||||
|
require.Error(t, fw.AddRule(ctx, "", &Rule{Port: 22, Proto: TCP, Action: Accept, Log: true}),
|
||||||
|
"WFP should reject logging")
|
||||||
|
require.Error(t, fw.AddRule(ctx, "", &Rule{Port: 22, Proto: TCP, Action: Accept, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}),
|
||||||
|
"WFP should reject rate limiting")
|
||||||
|
|
||||||
|
nat := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "1.2.3.4", ToPort: 8080}
|
||||||
|
require.Error(t, fw.AddNATRule(ctx, "", nat), "WFP should reject NAT")
|
||||||
|
require.Error(t, fw.RemoveNATRule(ctx, "", nat), "WFP should reject NAT")
|
||||||
|
_, err := fw.GetNATRules(ctx, "")
|
||||||
|
require.Error(t, err, "WFP should reject NAT listing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeAddress must surface a Windows rule that carries a comma-separated
|
||||||
|
// address list (built-in rules commonly do) by decoding its first entry, rather
|
||||||
|
// than erroring and letting UnmarshallFWRule drop the whole rule.
|
||||||
|
func TestWFDecodeAddressMultiValue(t *testing.T) {
|
||||||
|
fw := &WF{rulePrefix: "test"}
|
||||||
|
got, err := fw.decodeAddress("192.168.1.1,10.0.0.1")
|
||||||
|
require.NoError(t, err, "a comma-separated list must not be rejected")
|
||||||
|
require.Equal(t, "192.168.1.1", got, "the first address represents the rule")
|
||||||
|
|
||||||
|
got, err = fw.decodeAddress("2001:db8::1, 2001:db8::2")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, "2001:db8::1", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeAddress must parse an IPv6 address in netmask notation, not only IPv4,
|
||||||
|
// and must reject an address/netmask family mismatch.
|
||||||
|
func TestWFDecodeAddressNetmask(t *testing.T) {
|
||||||
|
fw := &WF{rulePrefix: "test"}
|
||||||
|
|
||||||
|
cases := []struct{ in, want string }{
|
||||||
|
{"192.168.1.5/255.255.255.0", "192.168.1.0/24"}, // IPv4 netmask
|
||||||
|
{"192.168.1.5/24", "192.168.1.0/24"}, // IPv4 CIDR
|
||||||
|
{"2001:db8::/ffff:ffff::", "2001:db8::/32"}, // IPv6 netmask
|
||||||
|
{"2001:db8::/32", "2001:db8::/32"}, // IPv6 CIDR
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, err := fw.decodeAddress(c.in)
|
||||||
|
require.NoError(t, err, "decodeAddress(%q)", c.in)
|
||||||
|
require.Equal(t, c.want, got, "decodeAddress(%q)", c.in)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An address/netmask family mismatch is rejected.
|
||||||
|
_, err := fw.decodeAddress("192.168.1.0/ffff::")
|
||||||
|
require.Error(t, err, "a v4 address with a v6 netmask must be rejected")
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnmarshallFWRule must drop a rule scoped by an attribute the Rule model cannot
|
||||||
|
// represent — an application path, a Windows service, or a specific interface-type
|
||||||
|
// category — rather than decode it into a bare, unscoped rule. Windows ships many
|
||||||
|
// program- and service-scoped built-in rules; surfacing one as a match-all rule
|
||||||
|
// would let it collide (via EqualBase) with a genuinely bare rule and be reconciled
|
||||||
|
// or removed as if identical. A rule carrying none of those scopes still decodes.
|
||||||
|
func TestWFUnmarshallDropsUnrepresentableScope(t *testing.T) {
|
||||||
|
fw := &WF{rulePrefix: "test"}
|
||||||
|
|
||||||
|
base := wapi.FWRule{
|
||||||
|
Protocol: wapi.NET_FW_IP_PROTOCOL_TCP,
|
||||||
|
Action: wapi.NET_FW_ACTION_ALLOW,
|
||||||
|
Direction: wapi.NET_FW_RULE_DIR_IN,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scoped by an unrepresentable attribute: dropped from the view.
|
||||||
|
dropped := []wapi.FWRule{
|
||||||
|
func() wapi.FWRule { r := base; r.ApplicationName = `C:\prog.exe`; return r }(),
|
||||||
|
func() wapi.FWRule { r := base; r.ServiceName = "Spooler"; return r }(),
|
||||||
|
func() wapi.FWRule { r := base; r.InterfaceTypes = "Wireless"; return r }(),
|
||||||
|
func() wapi.FWRule { r := base; r.InterfaceTypes = "Lan, Wireless"; return r }(),
|
||||||
|
}
|
||||||
|
for _, fr := range dropped {
|
||||||
|
require.Nil(t, fw.UnmarshallFWRule(fr),
|
||||||
|
"a rule scoped by an unrepresentable attribute must be dropped: %+v", fr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// No unrepresentable scope (interface-type "All" is the all-interfaces default):
|
||||||
|
// the bare rule still decodes.
|
||||||
|
for _, fr := range []wapi.FWRule{
|
||||||
|
base,
|
||||||
|
func() wapi.FWRule { r := base; r.InterfaceTypes = "All"; return r }(),
|
||||||
|
} {
|
||||||
|
parsed := fw.UnmarshallFWRule(fr)
|
||||||
|
require.NotNil(t, parsed, "a rule with no unrepresentable scope must decode: %+v", fr)
|
||||||
|
require.Equal(t, TCP, parsed.Proto)
|
||||||
|
require.Equal(t, Accept, parsed.Action)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// profileMatches must scope a single-zone query/removal to exactly that zone's
|
||||||
|
// profile bit — never to an all-profiles rule or a foreign multi-profile rule —
|
||||||
|
// so a zone-scoped GetRules/RemoveRule/Sync can never affect another zone. Only an
|
||||||
|
// unfiltered ("" zone) query is universal.
|
||||||
|
func TestWFProfileMatchesExcludesAllProfilesRule(t *testing.T) {
|
||||||
|
fw := new(WF)
|
||||||
|
privateFilter, ok := fw.profileFilter("private")
|
||||||
|
require.True(t, ok)
|
||||||
|
publicFilter, ok := fw.profileFilter("public")
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
rulesProfiles int32
|
||||||
|
filterProfile int32
|
||||||
|
useFilter bool
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "exact zone match",
|
||||||
|
rulesProfiles: privateFilter,
|
||||||
|
filterProfile: privateFilter,
|
||||||
|
useFilter: true,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "different zone does not match",
|
||||||
|
rulesProfiles: publicFilter,
|
||||||
|
filterProfile: privateFilter,
|
||||||
|
useFilter: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an all-profiles rule must not match a single-zone filter",
|
||||||
|
rulesProfiles: wapi.NET_FW_PROFILE2_ALL,
|
||||||
|
filterProfile: privateFilter,
|
||||||
|
useFilter: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "a foreign rule spanning more than one profile must not match a single-zone filter",
|
||||||
|
rulesProfiles: privateFilter | publicFilter,
|
||||||
|
filterProfile: privateFilter,
|
||||||
|
useFilter: true,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an unfiltered (all-zones) query matches an all-profiles rule",
|
||||||
|
rulesProfiles: wapi.NET_FW_PROFILE2_ALL,
|
||||||
|
filterProfile: 0,
|
||||||
|
useFilter: false,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "an unfiltered (all-zones) query matches a single-profile rule too",
|
||||||
|
rulesProfiles: privateFilter,
|
||||||
|
filterProfile: 0,
|
||||||
|
useFilter: false,
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got := fw.profileMatches(c.rulesProfiles, c.filterProfile, c.useFilter)
|
||||||
|
require.Equal(t, c.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue