first commit
This commit is contained in:
commit
0c7ce9e334
70 changed files with 39054 additions and 0 deletions
15
.gitignore
vendored
Normal file
15
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# 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
|
||||
/cmd/go-firewall/go-firewall.exe
|
||||
|
||||
# Bytecode cache Python writes when the refactor helpers in scripts/ are run.
|
||||
/scripts/__pycache__/
|
||||
|
||||
36
.golangci.yml
Normal file
36
.golangci.yml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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
|
||||
# linux backend plus the 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.
|
||||
#
|
||||
# The gap that leaves: a shared helper called only from pf.go (darwin/freebsd)
|
||||
# or wf_windows.go reads as dead under the one run that judges dead code. Those
|
||||
# carry a `//nolint:unused` naming the backend that needs them — check the
|
||||
# caller before deleting one.
|
||||
|
||||
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 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-integration every OS's integration (slow; big downloads)
|
||||
# make test general + every OS's integration
|
||||
# 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 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.*
|
||||
391
README.md
Normal file
391
README.md
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
# go-firewall
|
||||
|
||||
[](https://pkg.go.dev/github.com/grmrgecko/go-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/go-firewall>
|
||||
|
||||
```go
|
||||
import "github.com/grmrgecko/go-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/go-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 multi-state 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 port-carrying proto (`TCP`, `UDP`, `TCPUDP`, `SCTP`). |
|
||||
| `Ports` | Destination port list/ranges (`[]PortRange`). Overrides `Port` when non-empty. |
|
||||
| `Proto` | `ProtocolAny`, `TCP`, `UDP`, `TCPUDP`, `ICMP`, `ICMPv6`, `SCTP`, `GRE`, `ESP`, or `AH`. `TCPUDP` matches both transports; `ProtocolAny` matches *every* IP protocol and cannot carry a port. |
|
||||
| `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. |
|
||||
|
||||
`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`.
|
||||
|
||||
### Multi-state rules and coverage
|
||||
|
||||
Three values each describe a rule spanning both values of one axis:
|
||||
|
||||
- `FamilyAny` — both IPv4 and IPv6.
|
||||
- `TCPUDP` — both TCP and UDP; it carries a port just as `TCP` or `UDP` does. It is
|
||||
**not** `ProtocolAny`, which matches *every* IP protocol (ICMP, GRE, ESP, …).
|
||||
- `DirAny` — both the input and output directions (never forward). A `DirAny` rule
|
||||
is authored in the inbound frame, 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`.
|
||||
|
||||
Whether such a rule becomes **one** object in the firewall or **several** depends
|
||||
entirely on the backend's own model:
|
||||
|
||||
- **On add**, the rule is split only on the axes the backend cannot express.
|
||||
nftables stores a `FamilyAny` + `TCPUDP` rule as a single row; iptables must write
|
||||
a line per family, per transport and per chain, so a rule spanning all three axes
|
||||
becomes eight lines.
|
||||
- **On read**, `GetRules` reports the firewall's actual rows. It reports a
|
||||
multi-state value only for an entry that genuinely carries both values and never
|
||||
fabricates one by pairing up separately-stored rows, so that same rule reads back
|
||||
as one rule from nftables and as eight from iptables.
|
||||
- **On remove**, a target clears every row it covers. Where a stored row covers
|
||||
*more* than the target, the backend deletes it and re-adds the remainder in its
|
||||
place so the untargeted coverage survives; where its model cannot express the
|
||||
remainder, it returns `ErrUnsupported` rather than over-removing.
|
||||
- Where an axis does not exist at all — `Capabilities().Output` is false, as on
|
||||
firewalld — a `DirAny` rule degrades to its input half (`DirInput`, same fields)
|
||||
rather than being rejected.
|
||||
|
||||
Because the read-back shape is backend-specific, a caller cannot check for a rule
|
||||
with `==`. `Covers` and `CoveredBy` express the relation directly, and are the
|
||||
supported way to test a multi-state rule against what the firewall actually holds:
|
||||
|
||||
```go
|
||||
// Does this one rule contain that one?
|
||||
want := &fw.Rule{Family: fw.FamilyAny, Proto: fw.TCPUDP, Direction: fw.DirAny, Port: 53, Action: fw.Accept}
|
||||
want.Covers(&fw.Rule{Family: fw.IPv4, Proto: fw.UDP, Direction: fw.DirInput, Port: 53, Action: fw.Accept}) // true
|
||||
|
||||
// Is this rule fully present across a set — even if no single rule contains it?
|
||||
existing, _ := mgr.GetRules(ctx, "")
|
||||
if !want.CoveredBy(existing) {
|
||||
_ = mgr.AddRule(ctx, "", want)
|
||||
}
|
||||
```
|
||||
|
||||
`Covers` is asymmetric: a `TCPUDP` rule covers its TCP half, never the reverse.
|
||||
`CoveredBy` is its set-valued inverse — it expands the receiver across all three
|
||||
axes and requires every resulting cell to be covered by *some* rule in the set. That
|
||||
is what makes it work against a fan-out backend, where no single stored row covers
|
||||
the rule but the rows together do, and why a rule spanning both transports is not
|
||||
reported present when only its TCP half is. `NATRule.Covers` and `NATRule.CoveredBy`
|
||||
mirror them over family, the only axis a NAT rule spans. `Sync` compares this way,
|
||||
which is why it stays a no-op against its own output whichever representation the
|
||||
backend chose.
|
||||
|
||||
## 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. A
|
||||
false field means the corresponding operation returns an unsupported error,
|
||||
except `RuleCounters` and `Comments`, where it means `GetRules` reports the value
|
||||
empty. Features every backend supports are not advertised as booleans. The matrix
|
||||
below documents each backend's coverage, booleans and unconditional features
|
||||
alike.
|
||||
|
||||
| Feature | firewalld | ufw | CSF | APF | iptables | nftables | pf | WFP |
|
||||
| ---------------- | --------- | --- | --- | --- | -------- | -------- | -------------------- | ------------------------ |
|
||||
| Forward rules | no | yes | yes | yes | yes | yes | no | no |
|
||||
| IPv6 | yes | yes | yes | yes | yes | yes | yes | yes |
|
||||
| ICMP | yes | yes | yes | yes | yes | yes | yes | yes |
|
||||
| ICMP type | yes | yes | yes | yes | yes | yes | yes | yes |
|
||||
| SCTP/GRE/ESP/AH | yes | yes | yes | yes | yes | yes | yes | partial (no SCTP port) |
|
||||
| Comment | no | yes | yes | yes | yes | yes | yes | yes |
|
||||
| Port range | yes | yes | yes | yes | yes | yes | yes | yes |
|
||||
| Port list | no | yes | yes | yes | yes | yes | yes | yes |
|
||||
| Source port | partial (not with a destination port) | yes | yes | yes | yes | yes | yes | yes |
|
||||
| Connection state | no | yes | yes | yes | yes | yes | no | no |
|
||||
| Interface match | no | yes | yes | yes | yes | yes | yes | no |
|
||||
| Logging | yes | yes | yes | yes | yes | yes | partial (no prefix) | no |
|
||||
| Rate limit | yes | yes | yes | yes | yes | yes | partial (per-source) | no |
|
||||
| Connection limit | no | yes | yes | yes | yes | yes | partial (per-source) | no |
|
||||
| NAT | partial (forward-port and masquerade only) | yes | yes | yes | yes | yes | partial (no redirect) | no |
|
||||
|
||||
A `yes` means the feature is fully expressible, whatever route the backend takes
|
||||
to get there: firewalld reaches several through a rich rule, ufw through its
|
||||
route ruleset, and CSF and APF write the shapes their own config files cannot
|
||||
hold through their managed pre-hook. A `partial` cell means the backend can only
|
||||
express the narrowed form named beside it. Two of the `no` cells have a
|
||||
backend-specific reason: firewalld binds interfaces to zones rather than matching
|
||||
them per rule, and pf keeps state on a pass rule automatically but exposes no
|
||||
equivalent of the connection-state match this model carries.
|
||||
|
||||
`Capabilities().DenyActionFromConfig` (true for CSF and APF) flags a backend whose
|
||||
native deny store carries no per-entry action: the tool applies the action its own
|
||||
config names (`csf.conf` `DROP`, `conf.apf` `ALL_STOP`), so a deny added with the
|
||||
config's action is stored natively, one with a differing action is expressed
|
||||
through the backend's pre-hook, and `RemoveRule` clears a native deny entry
|
||||
whatever action the removal target names.
|
||||
|
||||
The `IPv6` row above is the one capability resolved per host rather than per
|
||||
backend, and it covers every IPv6 rule shape, ICMPv6 included. CSF and APF report
|
||||
it false when their own config disables IPv6 (`csf.conf` `IPV6`, `conf.apf`
|
||||
`USE_IPV6`), since neither tool then keeps an IPv6 ruleset in sync. iptables
|
||||
reports it false on a host whose packaging ships no ip6tables save file — a
|
||||
system built without IPv6, or one that never installed the ip6tables package —
|
||||
and manages IPv4 alone: reads report IPv4 rows only, a `FamilyAny` write narrows
|
||||
to the IPv4 file, a concrete-IPv6 write returns `ErrUnsupported`, and a removal
|
||||
is a no-op (there is nothing IPv6 to remove).
|
||||
|
||||
## 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 | ipset commands in the managed pre-hook |
|
||||
| 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
|
||||
|
||||
The Makefile drives every test path; run these from the repo root.
|
||||
|
||||
```sh
|
||||
make test-general # unit/parser tests — fast, no root, no VM
|
||||
make test-integration-linux # Linux backends end-to-end in a throwaway QEMU VM
|
||||
make test-integration-freebsd # pf end-to-end in a throwaway FreeBSD VM
|
||||
make test-integration-windows # Windows Firewall end-to-end in a throwaway Windows VM
|
||||
make test-integration # all three integration suites
|
||||
make test # test-general plus every integration suite
|
||||
make lint # golangci-lint across every target GOOS
|
||||
```
|
||||
|
||||
`make test-general` is the plain `go test ./...` suite — rule encoding/decoding,
|
||||
capability and helper logic. It never touches a live firewall and needs no root,
|
||||
so it is the one to run while iterating.
|
||||
|
||||
The `test-integration*` targets boot disposable QEMU VMs and run the
|
||||
capability-driven suite against the real backends inside them, so nothing on the
|
||||
host is modified. Backend detection and rule application need the corresponding
|
||||
firewall installed and running, which is what the VMs provide. Limit the Linux
|
||||
run to particular backends with `BACKENDS`:
|
||||
|
||||
```sh
|
||||
BACKENDS="nft firewalld ufw iptables apf csf" make test-integration-linux
|
||||
```
|
||||
|
||||
They require `qemu-system-x86_64`, KVM (`/dev/kvm`), `genisoimage`, and
|
||||
`python3` for the FreeBSD image. VM artifacts are cached under `./.cache`;
|
||||
`make clean` removes that cache and the compiled test binaries.
|
||||
|
||||
macOS cannot be automated in a VM, so run its pf backend manually on a Mac:
|
||||
|
||||
```sh
|
||||
sudo go test -tags integration -run TestIntegration
|
||||
```
|
||||
1951
apf_linux.go
Normal file
1951
apf_linux.go
Normal file
File diff suppressed because it is too large
Load diff
798
apf_linux_test.go
Normal file
798
apf_linux_test.go
Normal file
|
|
@ -0,0 +1,798 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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 := fw.MarshalAdvRule(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)
|
||||
}
|
||||
|
||||
// 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, so a Drop deny is written rather than skipped while AddRule
|
||||
// reports success and leaves the port 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 := fw.MarshalAdvRule(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)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
func TestAPFPortAndICMPConfig(t *testing.T) {
|
||||
fw := new(APF)
|
||||
|
||||
// Port lists parse single ports and underscore ranges.
|
||||
rules := fw.ParsePorts("21,22,6000_7000", TCP, DirInput)
|
||||
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, DirInput)
|
||||
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")
|
||||
}
|
||||
|
||||
// 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, DirInput)
|
||||
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, DirOutput)
|
||||
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}))
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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, DirInput)
|
||||
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")
|
||||
}
|
||||
|
||||
// A port-only deny must not corrupt the rule's family. apf requires an address
|
||||
// field, so it writes an "any" placeholder matching the rule's family. A
|
||||
// family-neutral rule writes one line per family: two rows that cover the rule
|
||||
// between them.
|
||||
//
|
||||
// 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) {
|
||||
// IPv6 enabled, so a family-neutral deny fans out to both families (see filterFamiliesIPv6).
|
||||
fw := &APF{ipv6Enabled: true}
|
||||
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))
|
||||
|
||||
// A concrete-family rule is one line; a family-neutral one is a line per family.
|
||||
// Either way the rows read back cover exactly the rule that was written.
|
||||
wantRows := 1
|
||||
if rule.impliedFamily() == FamilyAny {
|
||||
wantRows = 2
|
||||
}
|
||||
got, err := fw.ParseIPList(deny, Drop)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, wantRows, "port-only deny (%s) must round-trip to %d row(s)", rule.Family, wantRows)
|
||||
require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule; want family=%s", rule.Family)
|
||||
for _, g := range got {
|
||||
require.True(t, rule.Covers(g), "read-back row must not widen the written rule: %+v", g)
|
||||
}
|
||||
|
||||
// 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 port-only deny fans out across family, but the file may already hold a subset of
|
||||
// those lines — a prior single-family add, a manual edit, or the same rule added
|
||||
// twice by a reconcile. The add must note each fan-out line present and write only
|
||||
// the rest. A single-"exists" gate does not cover a family-neutral target: one line
|
||||
// present must not count as the whole rule, and a present IPv4 line must not leave
|
||||
// the IPv6 twin unwritten.
|
||||
func TestAPFPortOnlyDenyHealsAndDoesNotDuplicate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// IPv6 enabled, so the deny fans out to an IPv4 and an IPv6 line.
|
||||
fw := &APF{ipv6Enabled: true}
|
||||
dir := t.TempDir()
|
||||
|
||||
// Adding the same family-neutral deny twice must leave one line per family.
|
||||
path := filepath.Join(dir, "deny_hosts.rules")
|
||||
require.NoError(t, os.WriteFile(path, nil, 0o644))
|
||||
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop}
|
||||
require.NoError(t, fw.EditIPList(ctx, path, Drop, deny, false))
|
||||
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"),
|
||||
"re-adding the rule must not duplicate the IPv4 line")
|
||||
require.Equal(t, 1, strings.Count(text, "tcp:in:d=80:s=[::/0]"),
|
||||
"re-adding the rule must not duplicate the IPv6 line")
|
||||
|
||||
// A file holding only the IPv4 line must gain the missing IPv6 one, so IPv6:80 is
|
||||
// actually blocked rather than reported blocked while open.
|
||||
path2 := filepath.Join(dir, "deny_hosts2.rules")
|
||||
require.NoError(t, os.WriteFile(path2, []byte("tcp:in:d=80:s=0.0.0.0/0\n"), 0o644))
|
||||
require.NoError(t, fw.EditIPList(ctx, path2, Drop, deny, false))
|
||||
|
||||
data2, err := os.ReadFile(path2)
|
||||
require.NoError(t, err)
|
||||
text2 := string(data2)
|
||||
require.Equal(t, 1, strings.Count(text2, "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(text2, "tcp:in:d=80:s=[::/0]"),
|
||||
"the missing IPv6 line must be added")
|
||||
}
|
||||
|
||||
// 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 (shapeNeedsHook) 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")
|
||||
}
|
||||
|
||||
// TestAPFMultiPortRemovalSweepsCPortsTokens covers removal of a multi-port
|
||||
// accept against a conf.apf CPORTS list: the rule lives in the hook now, but an
|
||||
// earlier per-port add (or a manual edit) may hold the same ports as list
|
||||
// tokens, so removeFamilyAnyPort's EditConf sweep must strip exactly the
|
||||
// target's tokens and no others.
|
||||
func TestAPFMultiPortRemovalSweepsCPortsTokens(t *testing.T) {
|
||||
fw := new(APF)
|
||||
target := &Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
|
||||
require.Equal(t, `IG_TCP_CPORTS="22"`,
|
||||
fw.EditRulePort(`IG_TCP_CPORTS="22,80,443"`, "IG_TCP_CPORTS", "22,80,443", target, true),
|
||||
"a multi-port removal must strip each of its own port tokens and keep the rest")
|
||||
}
|
||||
|
||||
// APF EditIPList must write the missing IPv6 line when adding the IPv6 twin of an
|
||||
// existing IPv4 port-only deny; the family-specific EqualBase check must not
|
||||
// treat the IPv4 line as covering IPv6 and write 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")
|
||||
}
|
||||
|
||||
// TestAPFTCPUDPCPortsFanOut covers the write half of the both-transports port accept:
|
||||
// conf.apf's CPORTS lists are per-transport, so a TCPUDP port must be added to (and
|
||||
// removed from) both. EditRulePort must key a TCPUDP rule into both the TCP and UDP
|
||||
// lists, not match neither and leave the port unopened.
|
||||
func TestAPFTCPUDPCPortsFanOut(t *testing.T) {
|
||||
fw := new(APF)
|
||||
r := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirInput}
|
||||
|
||||
require.Equal(t, `IG_TCP_CPORTS="22,80"`,
|
||||
fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22", r, false),
|
||||
"a tcpudp port must be added to the tcp list")
|
||||
require.Equal(t, `IG_UDP_CPORTS="53,80"`,
|
||||
fw.EditRulePort(`IG_UDP_CPORTS="53"`, "IG_UDP_CPORTS", "53", r, false),
|
||||
"a tcpudp port must be added to the udp list")
|
||||
|
||||
// Removal clears it from both lists.
|
||||
require.Equal(t, `IG_TCP_CPORTS="22"`,
|
||||
fw.EditRulePort(`IG_TCP_CPORTS="22,80"`, "IG_TCP_CPORTS", "22,80", r, true))
|
||||
require.Equal(t, `IG_UDP_CPORTS="53"`,
|
||||
fw.EditRulePort(`IG_UDP_CPORTS="53,80"`, "IG_UDP_CPORTS", "53,80", r, true))
|
||||
|
||||
// A concrete transport still touches only its own list.
|
||||
tcp := &Rule{Proto: TCP, Port: 80, Action: Accept, Direction: DirInput}
|
||||
require.Equal(t, `IG_UDP_CPORTS="53"`,
|
||||
fw.EditRulePort(`IG_UDP_CPORTS="53"`, "IG_UDP_CPORTS", "53", tcp, false),
|
||||
"a tcp rule must not open the udp port")
|
||||
|
||||
// An outbound rule touches only the egress lists.
|
||||
out := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirOutput}
|
||||
require.Equal(t, `IG_TCP_CPORTS="22"`,
|
||||
fw.EditRulePort(`IG_TCP_CPORTS="22"`, "IG_TCP_CPORTS", "22", out, false))
|
||||
require.Equal(t, `EG_TCP_CPORTS="22,80"`,
|
||||
fw.EditRulePort(`EG_TCP_CPORTS="22"`, "EG_TCP_CPORTS", "22", out, false))
|
||||
}
|
||||
|
||||
// TestAPFTCPUDPCPortsReadBack covers the read half: apf's CPORTS lists are keyed per
|
||||
// transport, so a TCPUDP port is one entry in each and reads back as one rule per
|
||||
// list — two dual-stack rules that together cover the TCPUDP rule that was written.
|
||||
// A port in only one list covers only its own transport.
|
||||
func TestAPFTCPUDPCPortsReadBack(t *testing.T) {
|
||||
fw := new(APF)
|
||||
rules := append(fw.ParsePorts("80", TCP, DirInput), fw.ParsePorts("80", UDP, DirInput)...)
|
||||
require.Len(t, rules, 2, "the two lists parse independently")
|
||||
for _, r := range rules {
|
||||
require.Equal(t, FamilyAny, r.Family, "a CPORTS entry is dual-stack")
|
||||
}
|
||||
|
||||
both := &Rule{Proto: TCPUDP, Port: 80, Action: Accept, Direction: DirInput}
|
||||
require.True(t, both.CoveredBy(rules), "the tcp+udp CPORTS entries cover the TCPUDP rule")
|
||||
|
||||
// A port in only one list leaves the other transport uncovered.
|
||||
tcpOnly := fw.ParsePorts("80", TCP, DirInput)
|
||||
require.Len(t, tcpOnly, 1)
|
||||
require.Equal(t, TCP, tcpOnly[0].Proto)
|
||||
require.False(t, both.CoveredBy(tcpOnly), "the tcp entry alone must not cover a TCPUDP rule")
|
||||
require.True(t, (&Rule{Proto: TCP, Port: 80, Action: Accept, Direction: DirInput}).CoveredBy(tcpOnly))
|
||||
}
|
||||
|
||||
// TestAPFTCPUDPAdvRule: apf's advanced rule treats a missing protocol field as both
|
||||
// transports (its trust parser derives a -p tcp and a -p udp rule from it), so TCPUDP
|
||||
// is written by omitting the field and must read back as TCPUDP — never ProtocolAny,
|
||||
// which would claim every IP protocol is matched.
|
||||
func TestAPFTCPUDPAdvRule(t *testing.T) {
|
||||
fw := new(APF)
|
||||
r := &Rule{Proto: TCPUDP, Port: 80, Source: "192.0.2.1", Action: Accept, Direction: DirInput}
|
||||
|
||||
line := fw.MarshalAdvRule(r)
|
||||
require.Equal(t, "in:d=80:s=192.0.2.1", line, "the protocol field is omitted for both transports")
|
||||
|
||||
back := fw.ParseAdvRule(line, Accept)
|
||||
require.NotNil(t, back)
|
||||
require.Equal(t, TCPUDP, back.Proto, "a protocol-less advanced line is tcp+udp, not every protocol")
|
||||
require.True(t, back.EqualBase(r, true))
|
||||
|
||||
// A concrete transport names itself and round-trips unchanged.
|
||||
line = fw.MarshalAdvRule(&Rule{Proto: TCP, Port: 80, Source: "192.0.2.1", Action: Accept})
|
||||
require.Equal(t, "tcp:in:d=80:s=192.0.2.1", line)
|
||||
require.Equal(t, TCP, fw.ParseAdvRule(line, Accept).Proto)
|
||||
}
|
||||
|
||||
// With conf.apf's USE_IPV6 off, apf installs no IPv6 rule from its config, so a
|
||||
// family-neutral port-only deny must be written as the IPv4 line alone (see
|
||||
// filterFamiliesIPv6). Removal still sweeps both families.
|
||||
func TestAPFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
off := new(APF)
|
||||
|
||||
path := filepath.Join(dir, "deny_hosts.rules")
|
||||
require.NoError(t, os.WriteFile(path, nil, 0o644))
|
||||
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop}
|
||||
require.NoError(t, off.EditIPList(ctx, path, Drop, deny, false))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), "tcp:in:d=80:s=0.0.0.0/0", "the IPv4 line must be written")
|
||||
require.NotContains(t, string(data), "[::/0]",
|
||||
"no IPv6 line may be written while apf's IPv6 handling is off")
|
||||
|
||||
got, err := off.ParseIPList(path, Drop)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, IPv4, got[0].impliedFamily())
|
||||
|
||||
// Switching IPv6 off must not strand the v6 line written while it was on.
|
||||
on := &APF{ipv6Enabled: true}
|
||||
bothPath := filepath.Join(dir, "deny_hosts.both.rules")
|
||||
require.NoError(t, os.WriteFile(bothPath, nil, 0o644))
|
||||
require.NoError(t, on.EditIPList(ctx, bothPath, Drop, deny, false))
|
||||
data, err = os.ReadFile(bothPath)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), "[::/0]")
|
||||
require.NoError(t, off.EditIPList(ctx, bothPath, Drop, deny, true))
|
||||
data, err = os.ReadFile(bothPath)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(data), "d=80",
|
||||
"removal must sweep the stale IPv6 line even with IPv6 off")
|
||||
}
|
||||
|
||||
// With USE_IPV6 off a family-agnostic NAT rule is written for IPv4 only and a
|
||||
// concrete-IPv6 one is rejected outright; removal still sweeps both families so
|
||||
// a stale v6 line does not survive an IPv6 switch-off.
|
||||
func TestAPFNATIPv6Gating(t *testing.T) {
|
||||
off := new(APF)
|
||||
masq := &NATRule{Kind: Masquerade, Interface: "eth0"}
|
||||
rows := filterNATFamiliesIPv6(false, masq)
|
||||
require.Len(t, rows, 1, "a family-agnostic write must narrow to IPv4 while IPv6 is off")
|
||||
require.Equal(t, IPv4, rows[0].impliedFamily())
|
||||
// Removal still sweeps both families: the family-agnostic target covers the
|
||||
// stale IPv6 line through EqualForRemoval's family check.
|
||||
v6 := *masq
|
||||
v6.Family = IPv6
|
||||
require.True(t, v6.EqualForRemoval(masq), "removal must still cover the IPv6 line")
|
||||
|
||||
err := off.AddNATRule(context.Background(), "", &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 8080, ToAddress: "2001:db8::5"})
|
||||
require.ErrorIs(t, err, ErrUnsupportedNAT, "a concrete-IPv6 nat add must be rejected while IPv6 is off")
|
||||
|
||||
rows = filterNATFamiliesIPv6(true, masq)
|
||||
require.Len(t, rows, 2, "with IPv6 on a family-agnostic write fans out to both families")
|
||||
}
|
||||
|
||||
// confKeyApplies mirrors EditRulePort's routing guards; EditConf keys its
|
||||
// missing-config-line detection on it.
|
||||
func TestAPFConfKeyApplies(t *testing.T) {
|
||||
fw := new(APF)
|
||||
port := &Rule{Proto: TCP, Port: 80, Action: Accept}
|
||||
require.True(t, fw.confKeyApplies("IG_TCP_CPORTS", port))
|
||||
require.False(t, fw.confKeyApplies("IG_UDP_CPORTS", port))
|
||||
require.False(t, fw.confKeyApplies("EG_TCP_CPORTS", port))
|
||||
|
||||
both := &Rule{Proto: TCPUDP, Port: 53, Action: Accept}
|
||||
require.True(t, fw.confKeyApplies("IG_TCP_CPORTS", both))
|
||||
require.True(t, fw.confKeyApplies("IG_UDP_CPORTS", both))
|
||||
|
||||
icmp6 := &Rule{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept}
|
||||
require.True(t, fw.confKeyApplies("IG_ICMPV6_TYPES", icmp6))
|
||||
require.False(t, fw.confKeyApplies("IG_ICMP_TYPES", icmp6))
|
||||
|
||||
climit := &Rule{Proto: TCP, Port: 80, Action: Reject, ConnLimit: &ConnLimit{Count: 5, PerSource: true}}
|
||||
require.True(t, fw.confKeyApplies("IG_TCP_CLIMIT", climit))
|
||||
require.False(t, fw.confKeyApplies("IG_TCP_CPORTS", climit),
|
||||
"a connlimit rule must never touch the accept port lists")
|
||||
}
|
||||
|
||||
// apfLiveSave is a trimmed `iptables-save -c -t filter` capture from a host
|
||||
// running apf, covering each shape it generates: a conf.apf port-list accept, an
|
||||
// ICMP-type accept carrying apf's ICMP_LIM framing, a bare trust-list address
|
||||
// (one entry, one row per direction), and an advanced trust line.
|
||||
var apfLiveSave = []string{
|
||||
"*filter",
|
||||
":INPUT DROP [0:0]",
|
||||
"[9:540] -A INPUT -j TALLOW",
|
||||
"[3:180] -A INPUT -p tcp -m tcp --dport 22 -j ACCEPT",
|
||||
"[5:300] -A INPUT -p udp -m udp --dport 53 -j ACCEPT",
|
||||
"[4:336] -A INPUT -p icmp -m icmp --icmp-type 8 -m limit --limit 30/sec -j ACCEPT",
|
||||
"[2:120] -A OUTPUT -p tcp -m tcp --dport 25 -j ACCEPT",
|
||||
"[1:60] -A TALLOW -s 172.31.5.5/32 -j ACCEPT",
|
||||
"[1:44] -A TALLOW -d 172.31.5.5/32 -j ACCEPT",
|
||||
"[7:420] -A TALLOW -s 172.31.7.7/32 -p tcp -m multiport --dports 8443 -j ACCEPT",
|
||||
"[8:480] -A TDENY -s 172.31.9.9/32 -j DROP",
|
||||
"COMMIT",
|
||||
}
|
||||
|
||||
// TestAPFParseLiveRules verifies apf's framing is undone on read: the rate it
|
||||
// attaches to every accepted ICMP type is dropped, and a row in its trust chains
|
||||
// — which are entered from both INPUT and OUTPUT — takes its direction from the
|
||||
// address side it matches on.
|
||||
func TestAPFParseLiveRules(t *testing.T) {
|
||||
fw := new(APF)
|
||||
// The capture's ICMP row carries apf's stock ICMP_LIM; state the rate here
|
||||
// rather than reading conf.apf, which is not present under test.
|
||||
rules := fw.decodeLiveRules(apfLiveSave, IPv4, &RateLimit{Rate: 30, Unit: PerSecond})
|
||||
require.Len(t, rules, 8, "the jump into TALLOW models no rule of its own")
|
||||
|
||||
require.EqualValues(t, 22, rules[0].Port)
|
||||
require.EqualValues(t, 3, rules[0].Packets)
|
||||
|
||||
icmp := rules[2]
|
||||
require.Equal(t, ICMP, icmp.Proto)
|
||||
require.Nil(t, icmp.RateLimit, "apf's ICMP_LIM framing is not part of the rule")
|
||||
require.EqualValues(t, 4, icmp.Packets)
|
||||
|
||||
// The trust chains carry no direction, so the row's address side supplies it.
|
||||
require.Equal(t, DirInput, rules[4].Direction, "a source match is the inbound half")
|
||||
require.Equal(t, DirOutput, rules[5].Direction, "a destination match is the outbound half")
|
||||
require.Equal(t, DirInput, rules[6].Direction)
|
||||
require.EqualValues(t, 8443, rules[6].Port)
|
||||
}
|
||||
|
||||
// TestAPFApplyCounters verifies an apf entry that spans more than one axis sums
|
||||
// every row it materializes into: a CPORTS entry is dual-stack (FamilyAny) and a
|
||||
// bare trust address is bidirectional (DirAny).
|
||||
func TestAPFApplyCounters(t *testing.T) {
|
||||
fw := new(APF)
|
||||
live := fw.decodeLiveRules(apfLiveSave, IPv4, &RateLimit{Rate: 30, Unit: PerSecond})
|
||||
|
||||
port := &Rule{Direction: DirInput, Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept}
|
||||
host := &Rule{Direction: DirAny, Family: IPv4, Source: "172.31.5.5", Action: Accept}
|
||||
adv := &Rule{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 8443, Source: "172.31.7.7", Action: Accept}
|
||||
applyLiveCounters([]*Rule{port, host, adv}, live)
|
||||
|
||||
require.EqualValues(t, 3, port.Packets, "a dual-stack CPORTS entry counts this family's row")
|
||||
require.EqualValues(t, 2, host.Packets, "a bare trust address sums both directions")
|
||||
require.EqualValues(t, 104, host.Bytes)
|
||||
require.EqualValues(t, 7, adv.Packets, "an advanced trust line matches its single row")
|
||||
}
|
||||
|
||||
// TestAPFCountableRulesSpansFamilies verifies a dual-stack entry is offered to
|
||||
// both families' rulesets, so its counters accumulate across the two rather than
|
||||
// reporting only IPv4.
|
||||
func TestAPFCountableRulesSpansFamilies(t *testing.T) {
|
||||
dual := &Rule{Direction: DirInput, Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept}
|
||||
v4only := &Rule{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 80, Action: Accept}
|
||||
rules := []*Rule{dual, v4only}
|
||||
|
||||
require.Len(t, countableRules(rules, IPv4), 2)
|
||||
require.Equal(t, []*Rule{dual}, countableRules(rules, IPv6),
|
||||
"only the family-agnostic rule is offered to the IPv6 ruleset")
|
||||
|
||||
// Both families' rows add up onto the one reported rule.
|
||||
v4 := []*Rule{{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Packets: 3, Bytes: 180}}
|
||||
v6 := []*Rule{{Direction: DirInput, Family: IPv6, Proto: TCP, Port: 22, Action: Accept, Packets: 2, Bytes: 160}}
|
||||
applyLiveCounters(countableRules(rules, IPv4), v4)
|
||||
applyLiveCounters(countableRules(rules, IPv6), v6)
|
||||
require.EqualValues(t, 5, dual.Packets)
|
||||
require.EqualValues(t, 340, dual.Bytes)
|
||||
}
|
||||
148
atomicfile.go
Normal file
148
atomicfile.go
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"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. Writes pass through an internal bufio.Writer that
|
||||
// Commit flushes, so a line-by-line rewrite of a large save file does not issue
|
||||
// a syscall per line; the embedded fd remains reachable for the metadata calls
|
||||
// Commit makes.
|
||||
type atomicFile struct {
|
||||
*os.File
|
||||
w *bufio.Writer
|
||||
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.
|
||||
// A symlinked destination is resolved so the rename replaces the link's target
|
||||
// rather than turning the link itself into a regular file.
|
||||
func newAtomicFile(dst string, defaultMode os.FileMode) (*atomicFile, error) {
|
||||
if resolved, err := filepath.EvalSymlinks(dst); err == nil {
|
||||
dst = resolved
|
||||
}
|
||||
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,
|
||||
w: bufio.NewWriter(fd),
|
||||
dst: dst,
|
||||
tmp: fd.Name(),
|
||||
mode: mode,
|
||||
uid: uid,
|
||||
gid: gid,
|
||||
chown: chown,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Write buffers p into the staged file. It shadows the embedded fd's own Write
|
||||
// so every caller — the line-by-line rewrites and the whole-buffer writers
|
||||
// alike — shares one bufio.Writer; Commit flushes it before installing the
|
||||
// file, and Abort discards it with the temp file.
|
||||
func (a *atomicFile) Write(p []byte) (int, error) {
|
||||
return a.w.Write(p)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
// Flush the buffered writes down to the fd before any of the metadata and
|
||||
// durability steps below act on it.
|
||||
if err := a.w.Flush(); err != nil {
|
||||
return a.fail(err)
|
||||
}
|
||||
// Apply mode and ownership through the fd so the staged file is never
|
||||
// briefly installed with the wrong permissions.
|
||||
if err := a.Chmod(a.mode); err != nil {
|
||||
return a.fail(err)
|
||||
}
|
||||
if a.chown {
|
||||
if err := a.Chown(a.uid, a.gid); err != nil && !errors.Is(err, os.ErrPermission) {
|
||||
return a.fail(err)
|
||||
}
|
||||
}
|
||||
// Sync before the rename: these files are reboot-persistence-critical
|
||||
// (csf.conf, iptables save files), and a crash could otherwise reorder the
|
||||
// rename ahead of the data reaching disk, installing a truncated config.
|
||||
if err := a.Sync(); err != nil {
|
||||
return a.fail(err)
|
||||
}
|
||||
if err := a.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.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.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
atomicfile_other.go
Normal file
11
atomicfile_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
|
||||
}
|
||||
182
atomicfile_test.go
Normal file
182
atomicfile_test.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
//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()
|
||||
}
|
||||
|
||||
// readConfValue must not truncate a quoted value containing '#', and the last
|
||||
// assignment of a key wins, matching the shell that sources these files.
|
||||
func TestReadConfValueQuotingAndLastAssignment(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "conf")
|
||||
body := `
|
||||
# A comment line.
|
||||
PREFIX = "pre#fix" # trailing comment
|
||||
USE_IPV6 = "0"
|
||||
USE_IPV6 = "1"
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0644))
|
||||
|
||||
v, err := readConfValue(path, "PREFIX")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "pre#fix", v, "a '#' inside quotes is part of the value")
|
||||
|
||||
v, err = readConfValue(path, "USE_IPV6")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "1", v, "the last assignment wins")
|
||||
|
||||
v, err = readConfValue(path, "MISSING")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "", v)
|
||||
}
|
||||
18
atomicfile_unix.go
Normal file
18
atomicfile_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
|
||||
}
|
||||
280
backup.go
Normal file
280
backup.go
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Backup JSON serialization so snapshots can be persisted to disk or moved
|
||||
// between hosts. Enum values are marshaled as their stable string names to keep
|
||||
// backups readable across library versions. The two generic helpers below keep
|
||||
// the per-type boilerplate to one line.
|
||||
|
||||
// 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. The sentinel "invalid"
|
||||
// (NATInvalid) is accepted on the wire for round-trip fidelity even though
|
||||
// ParseNATKind rejects it as caller input, mirroring Action.
|
||||
func (k *NATKind) UnmarshalJSON(data []byte) error {
|
||||
v, err := unmarshalEnum(data, func(s string) (NATKind, error) {
|
||||
if strings.EqualFold(strings.TrimSpace(s), "invalid") {
|
||||
return NATInvalid, nil
|
||||
}
|
||||
return ParseNATKind(s)
|
||||
})
|
||||
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, and Restore then leaves the policy as
|
||||
// it finds it. A cancelled context is not that case — it would silently
|
||||
// produce a snapshot missing the policy a default-drop host depends on — so
|
||||
// it still fails the backup.
|
||||
policy, err := mgr.GetDefaultPolicy(ctx, zoneName)
|
||||
if err != nil && ctx.Err() != nil {
|
||||
return err
|
||||
}
|
||||
if 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-reconcile (ipset -exist, pfctl -T replace).
|
||||
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)
|
||||
}
|
||||
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/go-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/go-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)
|
||||
}
|
||||
}
|
||||
37
cmd/go-firewall/go.mod
Normal file
37
cmd/go-firewall/go.mod
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
module github.com/grmrgecko/go-firewall/cmd/go-firewall
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kong v1.15.0
|
||||
github.com/grmrgecko/go-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/v22 v22.5.0 // 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/google/go-cmp v0.6.0 // indirect
|
||||
github.com/google/nftables v0.3.0 // 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/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||
github.com/mdlayher/socket v0.5.0 // 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
|
||||
github.com/vishvananda/netlink v1.3.1 // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.6.0 // indirect
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
)
|
||||
|
||||
replace github.com/grmrgecko/go-firewall => ../..
|
||||
161
cmd/go-firewall/go.sum
Normal file
161
cmd/go-firewall/go.sum
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
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/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
|
||||
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
|
||||
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/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
|
||||
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
|
||||
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
|
||||
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/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
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/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/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/go-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/go-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/go-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)
|
||||
}
|
||||
253
cmd/go-firewall/nat.go
Normal file
253
cmd/go-firewall/nat.go
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
fw "github.com/grmrgecko/go-firewall"
|
||||
)
|
||||
|
||||
// This file holds the NAT command group: list, add, insert, move, 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."`
|
||||
Move NATMoveCmd `cmd:"" help:"Move a NAT rule to a new 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")
|
||||
}
|
||||
|
||||
// NATMoveCmd moves an existing NAT rule to a new 1-based position within its nat
|
||||
// chain (1 = first; a position past the end moves the rule to the end). The
|
||||
// flags describe the rule to move (matched by identity). Only ordered backends
|
||||
// support this; others return an unsupported-ordering error.
|
||||
type NATMoveCmd 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 move the rule to."`
|
||||
natFlags
|
||||
}
|
||||
|
||||
// Run moves the matched NAT rule to the requested 1-based position.
|
||||
func (c *NATMoveCmd) 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.MoveNATRule(context.Background(), zone, rule, c.Position); err != nil {
|
||||
return fmt.Errorf("move NAT rule: %w", err)
|
||||
}
|
||||
if err := g.reload(mgr); err != nil {
|
||||
return fmt.Errorf("reload: %w", err)
|
||||
}
|
||||
return g.emitStatus("moved")
|
||||
}
|
||||
|
||||
// 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/go-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/go-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/go-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/go-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/go-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")
|
||||
}
|
||||
201
container.go
Normal file
201
container.go
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"net"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Container runtimes and CNI plugins do not merely add firewall rules, they own
|
||||
// and continuously reconcile them: dockerd, podman/netavark, kube-proxy and the
|
||||
// CNI plugins each rewrite their rules on daemon start, network create and
|
||||
// container start. Rewriting or deleting one of those rules severs live container
|
||||
// networking for services the consumer never asked this library to touch, and the
|
||||
// owning daemon reinstates it on its own schedule, so the change does not even
|
||||
// hold. Every read path therefore treats such a rule as out of scope: it never
|
||||
// reaches a caller, so it never enters a desired set, and the file-rewrite paths
|
||||
// preserve its line verbatim.
|
||||
//
|
||||
// This is not the ownership filter the package deliberately avoids. The library
|
||||
// still manages foreign rules — a hand-written system rule is reconciled like any
|
||||
// other. The distinction is a live, self-reconciling owner, not authorship.
|
||||
|
||||
// containerRuntimeIfaces names interfaces a container runtime or CNI plugin
|
||||
// creates and manages. A rule matching on one of these is that runtime's.
|
||||
var containerRuntimeIfaces = map[string]bool{
|
||||
// Docker.
|
||||
"docker0": true,
|
||||
"docker_gwbridge": true,
|
||||
// Podman.
|
||||
"cni-podman0": true,
|
||||
// Kubernetes CNI plugins.
|
||||
"cni0": true,
|
||||
"flannel.1": true,
|
||||
"tunl0": true,
|
||||
"vxlan.calico": true,
|
||||
"cilium_host": true,
|
||||
"cilium_net": true,
|
||||
"cilium_vxlan": true,
|
||||
"weave": true,
|
||||
"kube-bridge": true,
|
||||
"kube-ipvs0": true,
|
||||
"nodelocaldns": true,
|
||||
}
|
||||
|
||||
// containerRuntimeIfacePrefixes names interface prefixes a container runtime or
|
||||
// CNI plugin allocates per network or per container, where the suffix is a
|
||||
// generated index or identifier.
|
||||
var containerRuntimeIfacePrefixes = []string{
|
||||
"podman", // podman0, podman1, ... per podman network.
|
||||
"cali", // Calico's per-workload interfaces; matches its own `cali+` rules.
|
||||
"lxc", // Cilium's per-endpoint interfaces, and LXC's container interfaces.
|
||||
}
|
||||
|
||||
// dockerBridgeRe matches the bridge Docker creates for a user-defined network:
|
||||
// "br-" followed by the first 12 hex digits of the network ID. The pattern is
|
||||
// deliberately exact rather than a "br-" prefix so an operator's own bridge —
|
||||
// br-lan, br-wan, br-100 — is still managed normally.
|
||||
var dockerBridgeRe = regexp.MustCompile(`^br-[0-9a-f]{12}$`)
|
||||
|
||||
// isDockerBridge reports whether name is a bridge Docker generated for a
|
||||
// user-defined network. Beyond the name pattern it requires at least one numeral,
|
||||
// which rules out a hand-named bridge that happens to be spelled in hex letters
|
||||
// (br-deadbeefcafe) while costing Docker nothing: a network ID is random hex, so
|
||||
// the odds of its first 12 digits holding no numeral are about 1 in 195,000.
|
||||
//
|
||||
// This stays a heuristic. A bridge renamed through the network's
|
||||
// com.docker.network.bridge.name option is not detectable by name at all, and a
|
||||
// bridge deliberately named as 12 hex digits including a numeral is a false
|
||||
// positive. Both are accepted: the pattern covers the default every
|
||||
// docker compose project produces, which is the case that actually occurs.
|
||||
func isDockerBridge(name string) bool {
|
||||
return dockerBridgeRe.MatchString(name) && strings.ContainsAny(name, "0123456789")
|
||||
}
|
||||
|
||||
// containerRuntimeChains names the iptables/nftables chains a container runtime
|
||||
// or CNI plugin creates and reconciles. A trailing "-" or "_" marks a prefix
|
||||
// whose remainder is generated (a network name, a service hash, an index).
|
||||
var containerRuntimeChains = []string{
|
||||
// Docker: DOCKER itself, plus DOCKER-USER, DOCKER-INGRESS, DOCKER-ISOLATION-*
|
||||
// and the DOCKER-FORWARD/BRIDGE/CT/INTERNAL set Docker 28 introduced.
|
||||
"DOCKER", "DOCKER-",
|
||||
// Podman: netavark, and the CNI stack older releases used.
|
||||
"NETAVARK", "NETAVARK_", "NETAVARK-", "CNI-",
|
||||
// Kubernetes kube-proxy and kubelet.
|
||||
"KUBE-",
|
||||
// CNI plugins.
|
||||
"CALI-", "cali-", "CILIUM_", "FLANNEL-", "WEAVE", "WEAVE-",
|
||||
}
|
||||
|
||||
// containerRuntimeTables names the nftables tables a container runtime or CNI
|
||||
// plugin owns outright. Every rule in one of these is the runtime's, whatever
|
||||
// chain it sits in — Docker's native nftables backend, for example, uses generic
|
||||
// chain names (filter-forward-in, nat-postrouting-out) that carry no marker of
|
||||
// their own.
|
||||
var containerRuntimeTables = map[string]bool{
|
||||
"docker-bridges": true, // dockerd with firewall-backend=nftables.
|
||||
"netavark": true, // podman/netavark.
|
||||
"kube-proxy": true,
|
||||
"kube-router": true,
|
||||
"calico": true,
|
||||
"cilium": true,
|
||||
}
|
||||
|
||||
// isContainerRuntimeIface reports whether name is an interface a container
|
||||
// runtime or CNI plugin manages.
|
||||
func isContainerRuntimeIface(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
// An interface match may be negated; the underlying name still identifies the
|
||||
// runtime, and a negated match is just as much its rule.
|
||||
name = strings.TrimPrefix(name, "!")
|
||||
name = strings.TrimSpace(name)
|
||||
if containerRuntimeIfaces[name] {
|
||||
return true
|
||||
}
|
||||
if isDockerBridge(name) {
|
||||
return true
|
||||
}
|
||||
// An iptables interface match may carry a trailing "+" wildcard (cali+).
|
||||
bare := strings.TrimSuffix(name, "+")
|
||||
for _, p := range containerRuntimeIfacePrefixes {
|
||||
if strings.HasPrefix(bare, p) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isContainerRuntimeChain reports whether name is a chain a container runtime or
|
||||
// CNI plugin creates and reconciles.
|
||||
func isContainerRuntimeChain(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range containerRuntimeChains {
|
||||
// A generated-suffix entry matches by prefix; an exact entry must match whole
|
||||
// so a user chain merely starting with one is still managed.
|
||||
if strings.HasSuffix(c, "-") || strings.HasSuffix(c, "_") {
|
||||
if strings.HasPrefix(name, c) {
|
||||
return true
|
||||
}
|
||||
continue
|
||||
}
|
||||
if name == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isContainerRuntimeTable reports whether an nftables table is one a container
|
||||
// runtime or CNI plugin owns outright. It accepts either a bare table name or the
|
||||
// "family name" form the ruleset listing produces (e.g. "ip docker-bridges").
|
||||
func isContainerRuntimeTable(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
if i := strings.LastIndex(name, " "); i >= 0 {
|
||||
name = name[i+1:]
|
||||
}
|
||||
return containerRuntimeTables[name]
|
||||
}
|
||||
|
||||
// isContainerRuntime reports whether the rule belongs to a container runtime or
|
||||
// CNI plugin, by the interfaces it matches on. Chain and table membership are
|
||||
// checked by the backends that can see them, before a rule is ever built.
|
||||
func (r *Rule) isContainerRuntime() bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
return isContainerRuntimeIface(r.InInterface) || isContainerRuntimeIface(r.OutInterface)
|
||||
}
|
||||
|
||||
// isHairpinMasquerade reports whether the NAT rule is a container runtime's
|
||||
// hairpin (loopback) masquerade: the row Docker and podman add per published port
|
||||
// so a container reaching its own published address is NATed back to itself. It
|
||||
// carries no interface match, so it is the one container NAT rule the interface
|
||||
// signal cannot see, and it is identified by its shape instead — a masquerade
|
||||
// whose source and destination are the same single host. Nothing else generates
|
||||
// that: masquerading a host's traffic to itself is meaningless outside a
|
||||
// port-publishing loopback.
|
||||
func (r *NATRule) isHairpinMasquerade() bool {
|
||||
if r.Kind != Masquerade || r.Source == "" || !addrEqual(r.Source, r.Destination) {
|
||||
return false
|
||||
}
|
||||
// canonAddr collapses a host prefix (/32, /128) onto the bare address, so a
|
||||
// source that canonicalizes back to a plain IP is a single host, not a network.
|
||||
// A masquerade of a whole subnet onto itself is not the hairpin shape.
|
||||
c, ok := canonAddr(r.Source)
|
||||
return ok && net.ParseIP(strings.TrimPrefix(c, "!")) != nil
|
||||
}
|
||||
|
||||
// isContainerRuntime reports whether the NAT rule belongs to a container runtime
|
||||
// or CNI plugin. It is the NATRule counterpart of Rule.isContainerRuntime.
|
||||
func (r *NATRule) isContainerRuntime() bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
return isContainerRuntimeIface(r.Interface) || r.isHairpinMasquerade()
|
||||
}
|
||||
102
container_test.go
Normal file
102
container_test.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The detector must catch every interface a container runtime allocates without
|
||||
// swallowing an operator's own interfaces, which are managed normally. The
|
||||
// br-<12 hex> case is the sharp edge: Docker's generated bridges must match while
|
||||
// a hand-named br-lan must not.
|
||||
func TestIsContainerRuntimeIface(t *testing.T) {
|
||||
runtime := []string{
|
||||
"docker0", "docker_gwbridge", "br-1a2b3c4d5e6f",
|
||||
"podman0", "podman1", "cni-podman0",
|
||||
"cni0", "flannel.1", "cilium_host", "weave", "kube-ipvs0",
|
||||
"cali1234567890a", "lxc00aa",
|
||||
"!docker0", "cali+", // Negated and wildcard forms still identify the runtime.
|
||||
}
|
||||
for _, name := range runtime {
|
||||
require.True(t, isContainerRuntimeIface(name), "%q must be detected as a container-runtime interface", name)
|
||||
}
|
||||
|
||||
operator := []string{
|
||||
"", "eth0", "ens3", "wlan0", "bridge0",
|
||||
"br-1a2b3c4d5e6", // Too short for a Docker network ID.
|
||||
"br-1A2B3C4D5E6F", // Docker lowercases its hex; an uppercase name is not its.
|
||||
"dockerish0", // Not an interface Docker creates.
|
||||
// An operator's bridge that happens to be 12 hex characters. A generated
|
||||
// network ID practically always carries a numeral, so requiring one keeps
|
||||
// names spelled in hex letters managed.
|
||||
"br-deadbeefcafe", "br-cafedecadeff",
|
||||
"br-1a2b3c4d5e6f7", // One character too long for a network ID.
|
||||
"br-lan", "br-wan", "br-100", "vmbr0", "virbr0", "lxdbr0",
|
||||
// A veth pair is not a container-runtime signal on its own: systemd-nspawn,
|
||||
// libvirt and hand-built netns setups all use them, and a runtime's own rules
|
||||
// match the bridge rather than the container-side leg.
|
||||
"veth1a2b3c", "vethwe-bridge",
|
||||
"tun0", "wg0", "bond0", "lo",
|
||||
}
|
||||
for _, name := range operator {
|
||||
require.False(t, isContainerRuntimeIface(name), "%q is the operator's interface and must stay managed", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsContainerRuntimeChain(t *testing.T) {
|
||||
runtime := []string{
|
||||
"DOCKER", "DOCKER-USER", "DOCKER-ISOLATION-STAGE-1", "DOCKER-INGRESS",
|
||||
"DOCKER-FORWARD", "DOCKER-BRIDGE", "DOCKER-CT", "DOCKER-INTERNAL",
|
||||
"NETAVARK_FORWARD", "NETAVARK-ISOLATION-2", "NETAVARK-HOSTPORT-DNAT",
|
||||
"CNI-FORWARD", "CNI-ADMIN",
|
||||
"KUBE-SERVICES", "KUBE-NODEPORTS", "KUBE-FIREWALL",
|
||||
"CALI-INPUT", "cali-fw-cali123", "CILIUM_POST_mangle", "FLANNEL-FWD", "WEAVE",
|
||||
}
|
||||
for _, name := range runtime {
|
||||
require.True(t, isContainerRuntimeChain(name), "%q must be detected as a container-runtime chain", name)
|
||||
}
|
||||
|
||||
operator := []string{"", "INPUT", "OUTPUT", "FORWARD", "ufw-user-input", "f2b-sshd", "DOCKERISH"}
|
||||
for _, name := range operator {
|
||||
require.False(t, isContainerRuntimeChain(name), "%q must stay managed", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Docker's native nftables backend names its chains generically
|
||||
// (filter-forward-in, nat-postrouting-out), so the table name is the only signal.
|
||||
// The listing form carries the family, which must be tolerated.
|
||||
func TestIsContainerRuntimeTable(t *testing.T) {
|
||||
for _, name := range []string{"docker-bridges", "ip docker-bridges", "ip6 docker-bridges", "inet netavark", "ip kube-proxy"} {
|
||||
require.True(t, isContainerRuntimeTable(name), "%q must be detected as a container-runtime table", name)
|
||||
}
|
||||
for _, name := range []string{"", "inet filter", "ip nat", "inet go-firewall", "filter"} {
|
||||
require.False(t, isContainerRuntimeTable(name), "%q must stay managed", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Docker and podman add a hairpin masquerade per published port, and it carries
|
||||
// no interface match, so shape is the only signal. It must not swallow an
|
||||
// operator's ordinary masquerade or a whole-subnet self-masquerade.
|
||||
func TestIsHairpinMasquerade(t *testing.T) {
|
||||
hairpin := []*NATRule{
|
||||
{Kind: Masquerade, Source: "172.17.0.2/32", Destination: "172.17.0.2/32", Proto: TCP, Port: 80},
|
||||
{Kind: Masquerade, Source: "10.89.0.4", Destination: "10.89.0.4/32", Proto: UDP, Port: 53},
|
||||
{Kind: Masquerade, Source: "fd00::2/128", Destination: "fd00::2/128", Proto: TCP, Port: 443},
|
||||
}
|
||||
for _, r := range hairpin {
|
||||
require.True(t, r.isContainerRuntime(), "hairpin masquerade %s must be out of scope", r.Source)
|
||||
}
|
||||
|
||||
managed := []*NATRule{
|
||||
{Kind: Masquerade, Interface: "eth0"}, // Ordinary egress NAT.
|
||||
{Kind: Masquerade, Source: "10.0.0.0/24", Destination: "10.0.0.0/24"}, // A subnet, not a host.
|
||||
{Kind: Masquerade, Source: "10.0.0.5/32", Destination: "10.0.0.6/32"}, // Different hosts.
|
||||
{Kind: Masquerade, Source: "10.0.0.5/32"}, // No destination.
|
||||
{Kind: SNAT, Source: "10.0.0.5/32", Destination: "10.0.0.5/32", ToAddress: "203.0.113.1"},
|
||||
{Kind: DNAT, Destination: "203.0.113.1/32", ToAddress: "10.0.0.5"},
|
||||
}
|
||||
for _, r := range managed {
|
||||
require.False(t, r.isContainerRuntime(), "%+v is the operator's NAT rule and must stay managed", *r)
|
||||
}
|
||||
}
|
||||
1940
csf_linux.go
Normal file
1940
csf_linux.go
Normal file
File diff suppressed because it is too large
Load diff
863
csf_linux_test.go
Normal file
863
csf_linux_test.go
Normal file
|
|
@ -0,0 +1,863 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A TCPUDP 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 TestCSFTCPUDPRejectFansOut(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: TCPUDP, 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, so a Drop deny is written rather than skipped while AddRule
|
||||
// reports success and leaves the port 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 TCPUDP 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 TCPUDP rule, and be fully
|
||||
// removed by one RemoveRule. The add/remove matcher must treat a TCPUDP rule as
|
||||
// covering its tcp and udp lines, not compare it exactly and let re-adds
|
||||
// duplicate the pair while removal is a silent no-op.
|
||||
func TestCSFTCPUDPPortDenyRoundTrip(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: TCPUDP, Port: 80, Action: Drop}
|
||||
|
||||
// Add fans the TCPUDP 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 TCPUDP 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 TCPUDP deny must not duplicate its udp line")
|
||||
|
||||
// The fanned lines read back as their own rules and cover the TCPUDP deny.
|
||||
parsed, err := fw.ParseIPList(path, Drop)
|
||||
require.NoError(t, err)
|
||||
require.True(t, deny.CoveredBy(parsed), "the tcp+udp deny lines must cover the TCPUDP rule")
|
||||
for _, g := range parsed {
|
||||
require.True(t, deny.Covers(g), "a fanned line must not widen the rule: %+v", g)
|
||||
}
|
||||
|
||||
// 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 TCPUDP 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. The gate must check every fan-out
|
||||
// line, not skip the whole fan-out on a single "exists" match.
|
||||
func TestCSFPortOnlyDenyHealsMissingFamily(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// IPv6 enabled, so the deny fans out across families and the missing v6 line heals.
|
||||
fw := &CSF{ipv6Enabled: true}
|
||||
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 TCPUDP 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: TCPUDP, 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 TCPUDP cannot be expressed as a
|
||||
// single line: csf.pl defaults a protocol-less line to tcp, so udp would be silently
|
||||
// left open. AddRule must therefore fan the rule into a tcp rule and a udp rule
|
||||
// before it reaches MarshalAdvRule, each of which marshals to its own line.
|
||||
func TestCSFAdvRuleTCPUDPWithAddressFansOut(t *testing.T) {
|
||||
fw := new(CSF)
|
||||
both := &Rule{Family: IPv4, Proto: TCPUDP, Port: 443, Source: "192.0.2.10", Action: Drop}
|
||||
subs := expandProtocols(both)
|
||||
require.Len(t, subs, 2, "a TCPUDP rule must fan out before it is marshalled")
|
||||
|
||||
var lines []string
|
||||
for _, sub := range subs {
|
||||
lines = append(lines, fw.MarshalAdvRule(sub))
|
||||
}
|
||||
require.Equal(t, []string{"tcp|in|d=443|s=192.0.2.10", "udp|in|d=443|s=192.0.2.10"}, lines,
|
||||
"each transport must get its own line so udp is not left open")
|
||||
}
|
||||
|
||||
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 := fw.MarshalAdvRule(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, DirInput)
|
||||
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")
|
||||
}
|
||||
|
||||
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 := fw.MarshalAdvRule(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)
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
// 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 := fw.MarshalNATRule(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)
|
||||
}
|
||||
|
||||
// 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.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")
|
||||
}
|
||||
|
||||
// csf.conf's CONNLIMIT is a single dual-stack config key (it caps both v4 and v6
|
||||
// connections; there is no separate v6 variant), 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.
|
||||
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")
|
||||
}
|
||||
|
||||
// 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 a family-neutral rule
|
||||
// writes one line per family, which cover it between them.
|
||||
func TestCSFPortOnlyRejectRoundTrip(t *testing.T) {
|
||||
// IPv6 enabled, so a family-neutral reject writes a line per family.
|
||||
fw := &CSF{ipv6Enabled: true}
|
||||
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)
|
||||
|
||||
// A concrete-family rule is one line; a family-neutral one is a line per family.
|
||||
wantRows := 1
|
||||
if rule.impliedFamily() == FamilyAny {
|
||||
wantRows = 2
|
||||
}
|
||||
got, err := fw.ParseIPList(deny, Reject)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, wantRows, "port-only reject (%s) must round-trip to %d row(s)", rule.Family, wantRows)
|
||||
require.True(t, rule.CoveredBy(got), "read-back rows must cover the written rule: %+v", got)
|
||||
for _, g := range got {
|
||||
require.True(t, rule.Covers(g), "read-back row must not widen the written rule: %+v", g)
|
||||
}
|
||||
|
||||
// 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 (shapeNeedsHook) 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 — EditIPList matches an existing line 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 — EditIPList's EqualForDedup/
|
||||
// EqualForRemoval gate must not disturb the FamilyAny case.
|
||||
func TestCSFFamilyAnyAdvDenyRoundTrip(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
// IPv6 enabled, so a FamilyAny deny fans out to both placeholder lines.
|
||||
fw := &CSF{ipv6Enabled: true}
|
||||
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")
|
||||
}
|
||||
|
||||
// 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 neither a TCPUDP/IPv4 rule nor
|
||||
// its IPv6 twin may be reported as present against them — treating the pair as one
|
||||
// both-transports rule drops a family's coverage and makes Sync churn forever.
|
||||
func TestCSFCrossFamilyPairCoversNeitherTransportPair(t *testing.T) {
|
||||
stored := []*Rule{
|
||||
{Family: IPv4, Proto: TCP, Port: 53, Action: Accept},
|
||||
{Family: IPv6, Proto: UDP, Port: 53, Action: Accept},
|
||||
}
|
||||
|
||||
require.False(t, (&Rule{Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored),
|
||||
"udp/IPv6 must not stand in for the missing udp/IPv4 open")
|
||||
require.False(t, (&Rule{Family: IPv6, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored),
|
||||
"tcp/IPv4 must not stand in for the missing tcp/IPv6 open")
|
||||
require.False(t, (&Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}).CoveredBy(stored))
|
||||
|
||||
// Each stored rule still covers exactly its own cell.
|
||||
require.True(t, (&Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}).CoveredBy(stored))
|
||||
require.True(t, (&Rule{Family: IPv6, Proto: UDP, Port: 53, Action: Accept}).CoveredBy(stored))
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// With csf.conf's IPV6 off, csf installs no IPv6 rule from its config, so a
|
||||
// family-neutral port-only deny must be written as the IPv4 line alone. An IPv6
|
||||
// placeholder line would sit inert in csf.deny and read back as an IPv6 rule csf does
|
||||
// not enforce and AddRule would reject. Removal still matches the target against every
|
||||
// line, so a v6 line written while IPv6 was on is swept regardless.
|
||||
func TestCSFPortOnlyDenyIPv6DisabledWritesV4Only(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
dir := t.TempDir()
|
||||
off := new(CSF)
|
||||
|
||||
path := filepath.Join(dir, "csf.deny")
|
||||
require.NoError(t, os.WriteFile(path, nil, 0644))
|
||||
deny := &Rule{Family: FamilyAny, Proto: TCP, Port: 80, Action: Drop}
|
||||
require.NoError(t, off.EditIPList(ctx, path, Drop, deny, false))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), "tcp|in|d=80|s=0.0.0.0/0", "the IPv4 line must be written")
|
||||
require.NotContains(t, string(data), "::/0",
|
||||
"no IPv6 line may be written while csf's IPv6 handling is off")
|
||||
|
||||
// The rows read back cover the rule, and only for the family csf enforces.
|
||||
got, err := off.ParseIPList(path, Drop)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got, 1)
|
||||
require.Equal(t, IPv4, got[0].impliedFamily())
|
||||
|
||||
// A rule pinned to IPv6 still writes its line: the AddRule IPv6 gate stops a
|
||||
// fresh add, and Restore bypasses that gate on purpose to reproduce a snapshot.
|
||||
v6path := filepath.Join(dir, "csf.deny.v6")
|
||||
require.NoError(t, os.WriteFile(v6path, nil, 0644))
|
||||
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 80, Action: Drop}
|
||||
require.NoError(t, off.EditIPList(ctx, v6path, Drop, v6, false))
|
||||
data, err = os.ReadFile(v6path)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), "tcp|in|d=80|s=::/0")
|
||||
|
||||
// Switching IPv6 off must not strand the v6 line written while it was on.
|
||||
on := &CSF{ipv6Enabled: true}
|
||||
bothPath := filepath.Join(dir, "csf.deny.both")
|
||||
require.NoError(t, os.WriteFile(bothPath, nil, 0644))
|
||||
require.NoError(t, on.EditIPList(ctx, bothPath, Drop, deny, false))
|
||||
data, err = os.ReadFile(bothPath)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(data), "::/0")
|
||||
require.NoError(t, off.EditIPList(ctx, bothPath, Drop, deny, true))
|
||||
data, err = os.ReadFile(bothPath)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(data), "d=80",
|
||||
"removal must sweep the stale IPv6 line even with IPv6 off")
|
||||
}
|
||||
|
||||
// A protocol-less advanced line is enforced by csf.pl as `-p tcp` (its protocol
|
||||
// default), so it must read back as TCP: a ProtocolAny read-back would report an
|
||||
// all-protocol rule csf does not enforce, and one whose removal the iptables
|
||||
// validity check rejects.
|
||||
func TestCSFAdvRuleProtocolDefaultsTCP(t *testing.T) {
|
||||
fw := new(CSF)
|
||||
r := fw.ParseAdvRule("in|d=80|s=192.0.2.1", Drop)
|
||||
require.NotNil(t, r)
|
||||
require.Equal(t, TCP, r.Proto, "csf enforces a protocol-less advanced line as tcp")
|
||||
require.EqualValues(t, 80, r.Port)
|
||||
require.Equal(t, "192.0.2.1", r.Source)
|
||||
}
|
||||
|
||||
// csf.pl accepts a colon-delimited advanced line (converting `:` to `|` when the
|
||||
// line has no pipe), so the parser must read it; an IPv6 literal keeps parsing as
|
||||
// a plain address, never as a colon-delimited rule.
|
||||
func TestCSFColonDelimitedAdvRule(t *testing.T) {
|
||||
fw := new(CSF)
|
||||
r := fw.parseListLine("tcp:in:d=22:s=192.0.2.1", Drop)
|
||||
require.NotNil(t, r, "a colon-delimited advanced line must parse")
|
||||
require.Equal(t, TCP, r.Proto)
|
||||
require.EqualValues(t, 22, r.Port)
|
||||
require.Equal(t, "192.0.2.1", r.Source)
|
||||
|
||||
v6 := fw.parseListLine("2001:db8::7", Drop)
|
||||
require.NotNil(t, v6)
|
||||
require.Equal(t, "2001:db8::7", v6.Source, "an IPv6 literal is a plain address line")
|
||||
require.Equal(t, DirAny, v6.Direction)
|
||||
}
|
||||
|
||||
// With csf.conf's IPV6 off, a family-agnostic port add must not touch the *6_*
|
||||
// lists (their entries would be inert), a removal must still sweep them, and a
|
||||
// concrete-IPv6 row written by Restore keeps its family.
|
||||
func TestCSFEditRulePortIPv6Disabled(t *testing.T) {
|
||||
off := &CSF{}
|
||||
anyFam := &Rule{Proto: TCP, Port: 8080, Action: Accept}
|
||||
|
||||
got := off.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", anyFam, false)
|
||||
require.Equal(t, `TCP6_IN = "22"`, got, "a family-agnostic add must not touch TCP6_IN while IPv6 is off")
|
||||
got = off.EditRulePort(`TCP_IN = "22"`, "TCP_IN", "22", anyFam, false)
|
||||
require.Contains(t, got, "8080", "the IPv4 list still takes the add")
|
||||
|
||||
// Removal sweeps the inert v6 entry so it does not outlive the rule.
|
||||
got = off.EditRulePort(`TCP6_IN = "22,8080"`, "TCP6_IN", "22,8080", anyFam, true)
|
||||
require.NotContains(t, got, "8080", "a removal must sweep the v6 list even with IPv6 off")
|
||||
|
||||
// A concrete-IPv6 rule (Restore reproducing a snapshot) keeps its family.
|
||||
v6 := &Rule{Family: IPv6, Proto: TCP, Port: 8080, Action: Accept}
|
||||
got = off.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", v6, false)
|
||||
require.Contains(t, got, "8080", "a concrete-IPv6 write keeps its family")
|
||||
|
||||
on := &CSF{ipv6Enabled: true}
|
||||
got = on.EditRulePort(`TCP6_IN = "22"`, "TCP6_IN", "22", anyFam, false)
|
||||
require.Contains(t, got, "8080", "with IPv6 on the v6 list takes the add")
|
||||
}
|
||||
|
||||
// With IPV6 off a concrete-IPv6 NAT rule is rejected outright: csf neither
|
||||
// applies a v6 redirect nor flushes the v6 nat table, so neither store could
|
||||
// keep the rule in sync (the NAT analog of the AddRule IPv6 gate, mirroring apf).
|
||||
func TestCSFNATIPv6Gating(t *testing.T) {
|
||||
off := new(CSF)
|
||||
err := off.AddNATRule(context.Background(), "", &NATRule{Kind: DNAT, Family: IPv6, Proto: TCP, Port: 8080, ToAddress: "2001:db8::5"})
|
||||
require.ErrorIs(t, err, ErrUnsupportedNAT, "a concrete-IPv6 nat add must be rejected while IPV6 is off")
|
||||
}
|
||||
|
||||
// csfLiveSave is a trimmed `iptables-save -c -t filter` capture from a host
|
||||
// running csf, covering each shape it generates: a config port-list accept (its
|
||||
// interface frame plus a NEW state match), an allow-list address in the
|
||||
// per-direction ALLOW chains, a deny-list address whose outbound half jumps into
|
||||
// csf's logging drop chain, and a raw pre-hook rule carrying no frame at all.
|
||||
var csfLiveSave = []string{
|
||||
"*filter",
|
||||
":INPUT DROP [0:0]",
|
||||
"[9:540] -A INPUT ! -i lo -j LOCALINPUT",
|
||||
"[3:180] -A INPUT ! -i lo -p tcp -m conntrack --ctstate NEW -m tcp --dport 22 -j ACCEPT",
|
||||
"[5:300] -A INPUT ! -i lo -p tcp -m conntrack --ctstate NEW -m tcp --dport 80 -j ACCEPT",
|
||||
"[7:420] -A INPUT -p tcp -m conntrack --ctstate NEW -m tcp --dport 9100 -m comment --comment gofw -j ACCEPT",
|
||||
"[2:120] -A OUTPUT ! -o lo -p tcp -m conntrack --ctstate NEW -m tcp --dport 25 -j ACCEPT",
|
||||
"[1:60] -A ALLOWIN -s 203.0.113.5/32 ! -i lo -j ACCEPT",
|
||||
"[1:40] -A ALLOWOUT -d 203.0.113.5/32 ! -o lo -j ACCEPT",
|
||||
"[4:240] -A DENYIN -s 203.0.113.9/32 ! -i lo -j DROP",
|
||||
"[6:360] -A DENYOUT -d 203.0.113.9/32 ! -o lo -j LOGDROPOUT",
|
||||
"[6:360] -A LOGDROPOUT -j REJECT --reject-with icmp-port-unreachable",
|
||||
"COMMIT",
|
||||
}
|
||||
|
||||
// TestCSFParseLiveRules verifies csf's own framing is undone on read: the
|
||||
// interface match and the NEW state it stamps on a config rule are dropped, a
|
||||
// jump into its logging drop chain is restored to the action that chain applies,
|
||||
// and a pre-hook rule (which carries no frame) keeps the state it was written
|
||||
// with. csf's internal chains contribute no rules of their own.
|
||||
func TestCSFParseLiveRules(t *testing.T) {
|
||||
fw := new(CSF)
|
||||
rules := fw.parseLiveRules(csfLiveSave, IPv4)
|
||||
require.Len(t, rules, 8, "the LOCALINPUT jump and the LOGDROPOUT chain body model no rule")
|
||||
|
||||
// A config port-list accept: framing gone, counters kept.
|
||||
require.EqualValues(t, 22, rules[0].Port)
|
||||
require.Equal(t, DirInput, rules[0].Direction)
|
||||
require.Empty(t, rules[0].InInterface, "csf's interface frame is not part of the rule")
|
||||
require.Zero(t, rules[0].State, "csf's NEW state frame is not part of the rule")
|
||||
require.EqualValues(t, 3, rules[0].Packets)
|
||||
|
||||
// A pre-hook rule carries no frame, so its own state match survives.
|
||||
require.EqualValues(t, 9100, rules[2].Port)
|
||||
require.Equal(t, StateNew, rules[2].State, "a rule csf did not frame keeps the state it matches on")
|
||||
|
||||
// The deny list's outbound half reads back as the action its chain ends in.
|
||||
require.Equal(t, Drop, rules[6].Action)
|
||||
require.Equal(t, Reject, rules[7].Action, "LOGDROPOUT applies csf.conf's DROP_OUT")
|
||||
require.EqualValues(t, 6, rules[7].Packets)
|
||||
}
|
||||
|
||||
// TestCSFApplyCountersLists verifies the allow and deny lists count both halves
|
||||
// of a bidirectional entry: the allow entry sums its two rows through ordinary
|
||||
// coverage, and the deny entry's outbound row — which carries DROP_OUT while the
|
||||
// entry reads back as DROP — is claimed by claimDenyOutRows.
|
||||
func TestCSFApplyCountersLists(t *testing.T) {
|
||||
fw := new(CSF)
|
||||
live := fw.parseLiveRules(csfLiveSave, IPv4)
|
||||
|
||||
allow := &Rule{Direction: DirAny, Family: IPv4, Source: "203.0.113.5", Action: Accept}
|
||||
deny := &Rule{Direction: DirAny, Family: IPv4, Source: "203.0.113.9", Action: Drop}
|
||||
port := &Rule{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
|
||||
targets := []*Rule{allow, deny, port}
|
||||
|
||||
leftover := applyLiveCounters(targets, live)
|
||||
fw.claimDenyOutRows(targets, leftover)
|
||||
|
||||
require.EqualValues(t, 3, port.Packets, "a config port rule counts its framed row")
|
||||
require.EqualValues(t, 2, allow.Packets, "a bidirectional allow sums its ALLOWIN and ALLOWOUT rows")
|
||||
require.EqualValues(t, 100, allow.Bytes)
|
||||
require.EqualValues(t, 10, deny.Packets, "a bidirectional deny sums its DENYIN and DENYOUT rows")
|
||||
require.EqualValues(t, 600, deny.Bytes)
|
||||
}
|
||||
|
||||
// TestCSFClaimDenyOutRowsIsScoped verifies the outbound-deny claim does not
|
||||
// absorb a row belonging to a different entry: it must still match every field
|
||||
// but the action, and it only applies to a bidirectional rule.
|
||||
func TestCSFClaimDenyOutRowsIsScoped(t *testing.T) {
|
||||
fw := new(CSF)
|
||||
other := &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.1", Action: Drop}
|
||||
oneWay := &Rule{Direction: DirOutput, Family: IPv4, Destination: "203.0.113.9", Action: Reject}
|
||||
leftover := []*Rule{
|
||||
{Direction: DirOutput, Family: IPv4, Destination: "203.0.113.9", Action: Reject, Packets: 6, Bytes: 360},
|
||||
}
|
||||
fw.claimDenyOutRows([]*Rule{other, oneWay}, leftover)
|
||||
|
||||
require.Zero(t, other.Packets, "an unrelated deny entry must not absorb the row")
|
||||
require.Zero(t, oneWay.Packets, "a one-way deny matches on identity and is not claimed here")
|
||||
}
|
||||
1991
firewall.go
Normal file
1991
firewall.go
Normal file
File diff suppressed because it is too large
Load diff
370
firewall_test.go
Normal file
370
firewall_test.go
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Rule identity compares addresses semantically: a bare host, its /32 (or /128)
|
||||
// form, and a differently spelled but equal IPv6 address are the same address.
|
||||
// Backends re-spell addresses on read (nft strips a /32, iptables-save adds it),
|
||||
// so an exact string compare would report the rule as changed on every reconcile.
|
||||
func TestAddrEqualCanonicalizesHostPrefix(t *testing.T) {
|
||||
require.True(t, addrEqual("1.2.3.4", "1.2.3.4/32"))
|
||||
require.True(t, addrEqual("2001:db8::1", "2001:0db8::1/128"))
|
||||
require.True(t, addrEqual("10.0.0.5/24", "10.0.0.0/24"), "host bits are masked to the network")
|
||||
require.True(t, addrEqual("!1.2.3.4", "!1.2.3.4/32"), "negation is preserved")
|
||||
require.False(t, addrEqual("1.2.3.4", "1.2.3.5"))
|
||||
require.False(t, addrEqual("1.2.3.4", "!1.2.3.4/32"), "a negation must not match its non-negated form")
|
||||
require.False(t, addrEqual("", "0.0.0.0/0"), "an any-address is not the empty match")
|
||||
require.False(t, addrEqual("myset", "myotherset"), "non-IP tokens compare verbatim")
|
||||
|
||||
a := &Rule{Family: IPv4, Source: "1.2.3.4/32", Proto: TCP, Port: 22, Action: Accept}
|
||||
b := &Rule{Family: IPv4, Source: "1.2.3.4", Proto: TCP, Port: 22, Action: Accept}
|
||||
require.True(t, a.EqualBase(b, true), "a /32 host and its bare form are the same rule")
|
||||
}
|
||||
|
||||
// A Backup serializes to portable JSON and decodes back identically, including
|
||||
// enum fields, pointers, port lists, limits and comments.
|
||||
func TestBackupJSONRoundTrip(t *testing.T) {
|
||||
icmpType := uint8(8)
|
||||
original := &Backup{
|
||||
Rules: []*Rule{
|
||||
{
|
||||
Direction: DirOutput, Priority: 5, Family: IPv4,
|
||||
Source: "10.0.0.0/8", Destination: "!192.168.1.5",
|
||||
Port: 443, Ports: []PortRange{{Start: 8000, End: 8100}, {Start: 9000, End: 9000}},
|
||||
SourcePort: 53,
|
||||
Proto: TCP, State: StateNew | StateEstablished,
|
||||
InInterface: "eth0", OutInterface: "eth1",
|
||||
Action: Accept, Log: true, LogPrefix: "https",
|
||||
RateLimit: &RateLimit{Rate: 20, Unit: PerSecond, Burst: 10},
|
||||
ConnLimit: &ConnLimit{Count: 100, PerSource: true},
|
||||
Comment: "ingress",
|
||||
},
|
||||
{Family: IPv6, Proto: ICMPv6, ICMPType: &icmpType, Action: Drop},
|
||||
{Family: FamilyAny, Proto: GRE, Action: Reject},
|
||||
},
|
||||
NATRules: []*NATRule{
|
||||
{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 8080, ToAddress: "10.0.0.5", ToPort: 80, Interface: "eth0"},
|
||||
{Kind: Masquerade, Family: IPv4, Interface: "eth1"},
|
||||
{Kind: Redirect, Family: IPv4, Proto: UDP, Port: 5353, ToPort: 5353},
|
||||
},
|
||||
// A direction left ActionInvalid (Forward here) must survive the round-trip
|
||||
// as "invalid" so SetDefaultPolicy leaves it unchanged on restore.
|
||||
DefaultPolicy: &DefaultPolicy{Input: Drop, Output: Accept, Forward: ActionInvalid},
|
||||
AddressSets: []*AddressSet{
|
||||
{Name: "blocklist", Family: IPv4, Type: SetHashNet, Entries: []string{"192.0.2.0/24", "198.51.100.0/24"}},
|
||||
{Name: "allow6", Family: IPv6, Type: SetHashIP, Entries: []string{"2001:db8::1"}},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, WriteBackup(&buf, original))
|
||||
|
||||
// The encoding carries the stable names, not bare numbers.
|
||||
require.Contains(t, buf.String(), `"accept"`)
|
||||
require.Contains(t, buf.String(), `"ipv4"`)
|
||||
require.Contains(t, buf.String(), `"dnat"`)
|
||||
require.Contains(t, buf.String(), `"hash:net"`) // set type as a stable name
|
||||
require.Contains(t, buf.String(), `"invalid"`) // ActionInvalid policy direction
|
||||
require.NotContains(t, buf.String(), `"Family":1`) // no numeric family encoding
|
||||
|
||||
got, err := ReadBackup(&buf)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got.Rules, len(original.Rules))
|
||||
require.Len(t, got.NATRules, len(original.NATRules))
|
||||
|
||||
for i := range original.Rules {
|
||||
require.True(t, reflect.DeepEqual(original.Rules[i], got.Rules[i]),
|
||||
"rule %d: want %+v got %+v", i, original.Rules[i], got.Rules[i])
|
||||
}
|
||||
for i := range original.NATRules {
|
||||
require.True(t, reflect.DeepEqual(original.NATRules[i], got.NATRules[i]),
|
||||
"nat rule %d: want %+v got %+v", i, original.NATRules[i], got.NATRules[i])
|
||||
}
|
||||
require.True(t, reflect.DeepEqual(original.DefaultPolicy, got.DefaultPolicy),
|
||||
"default policy: want %+v got %+v", original.DefaultPolicy, got.DefaultPolicy)
|
||||
require.True(t, reflect.DeepEqual(original.AddressSets, got.AddressSets),
|
||||
"address sets: want %+v got %+v", original.AddressSets, got.AddressSets)
|
||||
}
|
||||
|
||||
// isSetRef must treat the "any" wildcard as an address, not a named set, so a
|
||||
// backend never emits set-match syntax for the match-all token.
|
||||
func TestIsSetRefTreatsAnyAsWildcard(t *testing.T) {
|
||||
for _, a := range []string{"any", "!any", " any ", ""} {
|
||||
require.False(t, isSetRef(a), "isSetRef(%q) must be false", a)
|
||||
}
|
||||
for _, a := range []string{"1.2.3.4", "10.0.0.0/8", "!192.168.1.1"} {
|
||||
require.False(t, isSetRef(a), "isSetRef(%q) is an address", a)
|
||||
}
|
||||
for _, a := range []string{"myset", "!blocklist"} {
|
||||
require.True(t, isSetRef(a), "isSetRef(%q) is a set reference", a)
|
||||
}
|
||||
}
|
||||
|
||||
// A port set whose ranges are contiguous or overlapping must compare equal to
|
||||
// the coalesced form a backend lists back (nft merges adjacent ranges on read),
|
||||
// so rule identity is coalescing-invariant and Sync does not churn.
|
||||
func TestPortRangesEqualCoalesces(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
a, b []PortRange
|
||||
want bool
|
||||
}{
|
||||
{"contiguous singletons and range", []PortRange{{22, 22}, {23, 23}, {24, 30}}, []PortRange{{22, 30}}, true},
|
||||
{"adjacent ranges merge", []PortRange{{100, 200}, {201, 300}}, []PortRange{{100, 300}}, true},
|
||||
{"single plus adjacent range", []PortRange{{80, 80}, {81, 90}}, []PortRange{{80, 90}}, true},
|
||||
{"overlapping ranges", []PortRange{{100, 200}, {150, 250}}, []PortRange{{100, 250}}, true},
|
||||
{"order independence", []PortRange{{443, 443}, {80, 80}}, []PortRange{{80, 80}, {443, 443}}, true},
|
||||
{"discrete singletons equal to their span", []PortRange{{80, 80}, {81, 81}}, []PortRange{{80, 81}}, true},
|
||||
{"non-contiguous stays distinct", []PortRange{{80, 80}, {443, 443}}, []PortRange{{80, 443}}, false},
|
||||
{"gap of one is not contiguous", []PortRange{{80, 80}, {82, 82}}, []PortRange{{80, 82}}, false},
|
||||
{"top-of-range does not wrap", []PortRange{{65534, 65535}}, []PortRange{{65534, 65534}, {65535, 65535}}, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
require.Equalf(t, c.want, portRangesEqual(c.a, c.b), "portRangesEqual(%v,%v)", c.a, c.b)
|
||||
require.Equalf(t, c.want, portRangesEqual(c.b, c.a), "portRangesEqual is symmetric for %v,%v", c.a, c.b)
|
||||
}
|
||||
}
|
||||
|
||||
// A rule spanning all three axes has eight cells, so a backend that can store none of
|
||||
// them reports it as eight physical rows. Those rows cover it exactly, and dropping
|
||||
// any one of them breaks the coverage — which is what keeps Sync from re-adding a
|
||||
// rule that is already fully installed, and from calling a half-installed rule done.
|
||||
func TestAllThreeAxesCoverage(t *testing.T) {
|
||||
// No address, so nothing pins the family: the rule genuinely spans both.
|
||||
var rows []*Rule
|
||||
for _, fam := range []Family{IPv4, IPv6} {
|
||||
for _, proto := range []Protocol{TCP, UDP} {
|
||||
rows = append(rows,
|
||||
&Rule{Family: fam, Proto: proto, Port: 53, Direction: DirInput, Action: Accept},
|
||||
&Rule{Family: fam, Proto: proto, SourcePort: 53, Direction: DirOutput, Action: Accept},
|
||||
)
|
||||
}
|
||||
}
|
||||
require.Len(t, rows, 8)
|
||||
|
||||
want := &Rule{Family: FamilyAny, Proto: TCPUDP, Port: 53, Direction: DirAny, Action: Accept}
|
||||
require.Len(t, want.cells(true), 8, "the rule spans eight concrete cells")
|
||||
require.True(t, want.CoveredBy(rows), "the eight rows cover the rule")
|
||||
|
||||
for i := range rows {
|
||||
missing := append(append([]*Rule{}, rows[:i]...), rows[i+1:]...)
|
||||
require.False(t, want.CoveredBy(missing), "dropping row %d must break coverage", i)
|
||||
}
|
||||
|
||||
// An address pins the family, so the same rule then spans only four cells: the
|
||||
// direction swap moves it to the destination, and both halves stay IPv4.
|
||||
addressed := &Rule{Proto: TCPUDP, Source: "192.0.2.1", Port: 53, Direction: DirAny, Action: Accept}
|
||||
require.Len(t, addressed.cells(true), 4, "an IPv4 source pins the family axis")
|
||||
}
|
||||
|
||||
// The port axis is a merged axis like family/transport/direction: a list rule
|
||||
// spans one cell per spec, a stored set of single-port rows covers it, and a
|
||||
// range covers its interior. This keeps Sync from re-adding a list rule whose
|
||||
// ports are already installed as single-port rows on a backend that fans lists
|
||||
// out (firewalld, pf), and from keeping a wider row the desired set narrowed.
|
||||
func TestPortAxisCoverage(t *testing.T) {
|
||||
list := &Rule{Family: IPv4, Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
|
||||
rows := []*Rule{
|
||||
{Family: IPv4, Proto: TCP, Port: 80, Action: Accept},
|
||||
{Family: IPv4, Proto: TCP, Port: 443, Action: Accept},
|
||||
}
|
||||
|
||||
require.Len(t, list.cells(true), 2, "a two-port list spans two cells")
|
||||
require.True(t, list.CoveredBy(rows), "single-port rows cover the list rule")
|
||||
require.True(t, list.Covers(rows[0]), "the list covers each single-port row")
|
||||
require.False(t, rows[0].Covers(list), "coverage is asymmetric: a single port cannot cover the list")
|
||||
require.True(t, rows[0].CoveredBy([]*Rule{list}), "a single-port row is covered by the list")
|
||||
require.False(t, list.CoveredBy(rows[:1]), "dropping one port breaks coverage")
|
||||
|
||||
// A range covers the ports inside it; a port never covers the range.
|
||||
rng := &Rule{Proto: TCP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}
|
||||
require.True(t, rng.Covers(&Rule{Proto: TCP, Port: 1500, Action: Accept}))
|
||||
require.False(t, (&Rule{Proto: TCP, Port: 1500, Action: Accept}).Covers(rng))
|
||||
|
||||
// A discrete set covers only the ports it names, never the span between them.
|
||||
require.False(t, list.Covers(&Rule{Proto: TCP, Ports: []PortRange{{Start: 80, End: 443}}, Action: Accept}))
|
||||
|
||||
// The cross product with the other axes: a TCPUDP two-port list spans four
|
||||
// cells (two transports times two ports, family pinned), and every one must
|
||||
// be covered.
|
||||
both := &Rule{Family: IPv4, Proto: TCPUDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}
|
||||
require.Len(t, both.cells(true), 4)
|
||||
require.True(t, both.CoveredBy([]*Rule{
|
||||
{Proto: TCP, Port: 80, Action: Accept},
|
||||
{Proto: TCP, Port: 443, Action: Accept},
|
||||
{Proto: UDP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept},
|
||||
}), "a merged row covers the udp cells while single-port rows cover tcp")
|
||||
|
||||
// Source ports expand independently of destination ports.
|
||||
src := &Rule{Family: IPv4, Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 1024}, {Start: 2048, End: 2048}}, Action: Accept}
|
||||
require.Len(t, src.cells(true), 2)
|
||||
require.True(t, src.CoveredBy([]*Rule{
|
||||
{Proto: TCP, SourcePort: 1024, Action: Accept},
|
||||
{Proto: TCP, SourcePort: 2048, Action: Accept},
|
||||
}))
|
||||
}
|
||||
|
||||
// TestRuleCovers pins the exported coverage relation: a merged rule contains its
|
||||
// concrete halves on every axis, never the reverse, and ProtocolAny is not a merged
|
||||
// value.
|
||||
func TestRuleCovers(t *testing.T) {
|
||||
merged := &Rule{Family: FamilyAny, Proto: TCPUDP, Direction: DirAny, Port: 53, Action: Accept}
|
||||
cell := &Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 53, Action: Accept}
|
||||
|
||||
require.True(t, merged.Covers(cell), "a rule merged on every axis covers each of its cells")
|
||||
require.False(t, cell.Covers(merged), "coverage is asymmetric: a concrete rule cannot cover a merged one")
|
||||
require.True(t, cell.Covers(cell), "a rule covers itself")
|
||||
|
||||
// Each axis independently.
|
||||
require.True(t, (&Rule{Family: FamilyAny, Proto: TCP, Port: 53, Action: Accept}).
|
||||
Covers(&Rule{Family: IPv6, Proto: TCP, Port: 53, Action: Accept}))
|
||||
require.True(t, (&Rule{Proto: TCPUDP, Port: 53, Action: Accept}).
|
||||
Covers(&Rule{Proto: UDP, Port: 53, Action: Accept}))
|
||||
require.True(t, (&Rule{Proto: TCP, Direction: DirAny, Port: 53, Action: Accept}).
|
||||
Covers(&Rule{Proto: TCP, Direction: DirOutput, SourcePort: 53, Action: Accept}),
|
||||
"a DirAny rule covers its role-swapped output half")
|
||||
|
||||
// Siblings never cover each other.
|
||||
require.False(t, (&Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}).
|
||||
Covers(&Rule{Family: IPv6, Proto: TCP, Port: 53, Action: Accept}))
|
||||
require.False(t, (&Rule{Proto: TCP, Port: 53, Action: Accept}).
|
||||
Covers(&Rule{Proto: UDP, Port: 53, Action: Accept}))
|
||||
|
||||
// ProtocolAny matches every IP protocol; it is not the merged tcp/udp value and
|
||||
// so covers neither transport.
|
||||
require.False(t, (&Rule{Proto: ProtocolAny, Action: Accept}).Covers(&Rule{Proto: TCP, Action: Accept}))
|
||||
require.True(t, (&Rule{Proto: ProtocolAny, Action: Accept}).Covers(&Rule{Proto: ProtocolAny, Action: Accept}))
|
||||
|
||||
// An ordinary field must still match exactly.
|
||||
require.False(t, merged.Covers(&Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 53, Action: Drop}),
|
||||
"a different action is a different rule")
|
||||
require.False(t, merged.Covers(&Rule{Family: IPv4, Proto: TCP, Direction: DirInput, Port: 54, Action: Accept}))
|
||||
}
|
||||
|
||||
// TestNATCoveredBy mirrors Rule.CoveredBy over the axes NAT merges on: family,
|
||||
// and the match ports. A Redirect
|
||||
// carries no translation address, so its family is genuinely FamilyAny — a DNAT's
|
||||
// ToAddress would pin the family through impliedFamily.
|
||||
func TestNATCoveredBy(t *testing.T) {
|
||||
want := &NATRule{Kind: Redirect, Family: FamilyAny, Proto: TCP, Port: 80, ToPort: 8080}
|
||||
v4 := &NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}
|
||||
v6 := &NATRule{Kind: Redirect, Family: IPv6, Proto: TCP, Port: 80, ToPort: 8080}
|
||||
|
||||
require.True(t, want.Covers(v4))
|
||||
require.False(t, v4.Covers(want))
|
||||
require.False(t, v4.Covers(v6))
|
||||
|
||||
require.False(t, want.CoveredBy([]*NATRule{v4}))
|
||||
require.True(t, want.CoveredBy([]*NATRule{v4, v6}))
|
||||
require.True(t, v6.CoveredBy([]*NATRule{want}))
|
||||
|
||||
// A DNAT's translation address pins the family, so a FamilyAny DNAT to an IPv4
|
||||
// target is already an IPv4 rule and one concrete rule covers it.
|
||||
dnatAny := &NATRule{Kind: DNAT, Family: FamilyAny, Proto: TCP, Port: 80, ToAddress: "192.0.2.9"}
|
||||
dnatV4 := &NATRule{Kind: DNAT, Family: IPv4, Proto: TCP, Port: 80, ToAddress: "192.0.2.9"}
|
||||
require.True(t, dnatAny.CoveredBy([]*NATRule{dnatV4}),
|
||||
"the translation address already pins this rule to IPv4")
|
||||
|
||||
// The match ports are the second axis: a port-list DNAT spans one cell per
|
||||
// port and is covered by the single-port rows a fanned-out backend stores.
|
||||
list := &NATRule{Kind: DNAT, Proto: TCP, Ports: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, ToAddress: "192.0.2.9", ToPort: 8080}
|
||||
p80 := &NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "192.0.2.9", ToPort: 8080}
|
||||
p443 := &NATRule{Kind: DNAT, Proto: TCP, Port: 443, ToAddress: "192.0.2.9", ToPort: 8080}
|
||||
require.Len(t, list.cells(), 2, "a two-port list spans two cells")
|
||||
require.True(t, list.Covers(p80), "the list covers each single-port row")
|
||||
require.False(t, p80.Covers(list), "a single port cannot cover the list")
|
||||
require.True(t, list.CoveredBy([]*NATRule{p80, p443}))
|
||||
require.False(t, list.CoveredBy([]*NATRule{p80}), "dropping one port breaks coverage")
|
||||
require.True(t, p80.CoveredBy([]*NATRule{list}), "a single-port row is covered by the list")
|
||||
}
|
||||
|
||||
// impliedFamily resolves the family a rule effectively targets from any of its
|
||||
// sources: the Family field alone (a port-only rule carries no address), an
|
||||
// address, or a family-pinned ICMP protocol. Backends key family fan-outs and
|
||||
// the csf/apf IPv6 gates on it, so a concrete family must win over an absent
|
||||
// one and a rule pinning nothing must stay FamilyAny.
|
||||
func TestImpliedFamily(t *testing.T) {
|
||||
v6 := []*Rule{
|
||||
{Proto: ProtocolAny, Source: "2001:db8::1", Action: Accept},
|
||||
{Family: IPv6, Proto: TCP, Port: 22, Source: "2001:db8::1", Action: Accept},
|
||||
{Family: IPv6, Proto: TCP, Port: 8080, Action: Drop},
|
||||
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
|
||||
{Proto: ICMPv6, ICMPType: Ptr[uint8](128), State: StateEstablished, Action: Accept},
|
||||
}
|
||||
for _, r := range v6 {
|
||||
require.Equal(t, IPv6, r.impliedFamily(), "expected %+v to imply IPv6", *r)
|
||||
}
|
||||
|
||||
v4 := []*Rule{
|
||||
{Family: IPv4, Proto: TCP, Port: 22, Source: "192.0.2.1", Action: Accept},
|
||||
{Proto: ProtocolAny, Source: "192.0.2.1", Action: Accept},
|
||||
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||
}
|
||||
for _, r := range v4 {
|
||||
require.Equal(t, IPv4, r.impliedFamily(), "expected %+v to imply IPv4", *r)
|
||||
}
|
||||
|
||||
anyFam := []*Rule{
|
||||
{Proto: TCP, Port: 8080, Action: Drop},
|
||||
{Proto: TCP, Port: 22, Action: Accept, State: StateNew},
|
||||
}
|
||||
for _, r := range anyFam {
|
||||
require.Equal(t, FamilyAny, r.impliedFamily(), "expected %+v to imply neither family", *r)
|
||||
}
|
||||
}
|
||||
|
||||
// setRefFamilyFrom is the shared core every backend's set-family resolution
|
||||
// runs through: it pins a set-referencing rule to its set's single family from
|
||||
// whatever store the backend's lookup reads, and an unknown set or a
|
||||
// mixed-family pair cannot produce a loadable rule, so both error.
|
||||
func TestSetRefFamilyFrom(t *testing.T) {
|
||||
lookup := func(name string) (Family, bool, error) {
|
||||
switch name {
|
||||
case "v4set":
|
||||
return IPv4, true, nil
|
||||
case "v6set":
|
||||
return IPv6, true, nil
|
||||
case "macset":
|
||||
// A family-untyped set (hash:mac and friends).
|
||||
return FamilyAny, true, nil
|
||||
}
|
||||
return FamilyAny, false, nil
|
||||
}
|
||||
|
||||
fam, err := setRefFamilyFrom(lookup, "v4set", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IPv4, fam)
|
||||
|
||||
fam, err = setRefFamilyFrom(lookup, "", "v6set")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IPv6, fam)
|
||||
|
||||
// The "!" negation and "@" set marker are stripped before lookup.
|
||||
fam, err = setRefFamilyFrom(lookup, "!@v6set", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IPv6, fam)
|
||||
|
||||
// A family-untyped set matches as IPv4.
|
||||
fam, err = setRefFamilyFrom(lookup, "macset", "")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IPv4, fam)
|
||||
|
||||
// Both fields naming same-family sets agree.
|
||||
fam, err = setRefFamilyFrom(lookup, "v4set", "v4set")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IPv4, fam)
|
||||
|
||||
// Sets of different families cannot share one rule.
|
||||
_, err = setRefFamilyFrom(lookup, "v4set", "v6set")
|
||||
require.ErrorContains(t, err, "different families")
|
||||
|
||||
// An unknown set errors rather than guessing a family.
|
||||
_, err = setRefFamilyFrom(lookup, "ghost", "")
|
||||
require.ErrorContains(t, err, `"ghost"`)
|
||||
}
|
||||
1788
firewalld_linux.go
Normal file
1788
firewalld_linux.go
Normal file
File diff suppressed because it is too large
Load diff
617
firewalld_linux_test.go
Normal file
617
firewalld_linux_test.go
Normal file
|
|
@ -0,0 +1,617 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"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: an action-scoped limit
|
||||
// (after accept) is the traffic limit RateLimit models.
|
||||
rule, err := fw.UnmarshalRichRule(`rule family="ipv4" source address="192.168.0.0/24" port port=23 protocol=udp log audit accept limit value="1/m"`)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Re-encode the rule which should result in expected rich rule. The log and
|
||||
// rate limit 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")
|
||||
|
||||
// A log-scoped limit (directly after log) throttles only the logging, which
|
||||
// the model cannot hold; reading it as a traffic limit would let a rewrite
|
||||
// change what the rule enforces, so it must stay unmodeled.
|
||||
_, 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.Error(t, err, "a log-scoped limit must be rejected")
|
||||
|
||||
// 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="203.0.113.10" port port="4789" protocol="tcp" accept`,
|
||||
`rule priority="10" family="ipv4" destination address="203.0.113.10" 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")
|
||||
}
|
||||
|
||||
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. A port list is not among
|
||||
// them: AddRule/RemoveRule fan it into a row per spec with expandPorts before
|
||||
// the marshaller runs, so a list never reaches it.
|
||||
unsupported := []*Rule{
|
||||
{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
|
||||
{InInterface: "eth0", Proto: TCP, Port: 22, 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, 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.False(t, parsed.IsOutput(),
|
||||
"a destination element is an inbound-frame match, not a direction signal")
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// A rich rule carries a single port element, so these cannot be expressed. A
|
||||
// source-port list is not tested here: AddRule/RemoveRule fan it into a row per
|
||||
// spec with expandPorts before the marshaller runs, so a list never reaches it.
|
||||
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 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 destination+source port rejection reports the source-port sentinel.
|
||||
_, err := fw.MarshalRichRule(&Rule{Proto: TCP, Port: 80, SourcePort: 1024, Action: Accept})
|
||||
require.ErrorIs(t, err, ErrUnsupportedSourcePort)
|
||||
}
|
||||
|
||||
// A concrete-family bare-port accept is stored as a rich rule (zoneEntryEligible
|
||||
// requires FamilyAny), so a family-agnostic port opened per family becomes two rich
|
||||
// rules. Removing it with a FamilyAny target must locate both against the stored rich
|
||||
// rules with EqualForRemoval — the family-strict Equal matches neither and leaves
|
||||
// the port open. The firewalld family-agnostic remove must not no-op.
|
||||
func TestFirewallDFamilyAnyRichRuleRemovable(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)
|
||||
|
||||
// The two stored rich rules cover the family-agnostic rule between them.
|
||||
target := &Rule{Family: FamilyAny, Proto: TCP, Port: 3492, Action: Accept}
|
||||
require.True(t, target.CoveredBy([]*Rule{v4, v6}))
|
||||
|
||||
// The family-strict matcher finds neither stored rich rule.
|
||||
require.False(t, target.Equal(v4, false))
|
||||
require.False(t, target.Equal(v6, false))
|
||||
|
||||
// EqualForRemoval finds both, so RemoveRule clears every rich rule the target
|
||||
// covers.
|
||||
require.True(t, v4.EqualForRemoval(target, false))
|
||||
require.True(t, v6.EqualForRemoval(target, false))
|
||||
}
|
||||
|
||||
// A merged TCPUDP rule has no single rich-rule form (a rich rule port element
|
||||
// carries one protocol), so AddRule and RemoveRule fan it into a tcp row and a
|
||||
// udp row with expandProtocols before the marshaller runs.
|
||||
func TestFirewallDMarshalRejectsMergedProtocol(t *testing.T) {
|
||||
fw := new(FirewallD)
|
||||
|
||||
// expandProtocols yields a tcp row and a udp row, each of which marshals cleanly.
|
||||
subs := expandProtocols(&Rule{Port: 80, Proto: TCPUDP, Action: Accept})
|
||||
require.Len(t, subs, 2, "a TCPUDP rule fans into a tcp row and a udp row")
|
||||
require.Equal(t, TCP, subs[0].Proto)
|
||||
require.Equal(t, UDP, subs[1].Proto)
|
||||
for _, sub := range subs {
|
||||
rich, merr := fw.MarshalRichRule(sub)
|
||||
require.NoError(t, merr, "an expanded transport row must marshal: %+v", *sub)
|
||||
require.Contains(t, rich, `protocol="`+sub.Proto.String()+`"`)
|
||||
}
|
||||
}
|
||||
|
||||
// A rich rule's port element carries one protocol, so a port opened for both tcp and
|
||||
// udp in the same zone is two rich rules. Each reads back as its own rule (both
|
||||
// family-agnostic, since neither names a family), and together they cover the TCPUDP
|
||||
// rule that was written. A TCPUDP target reaches both on removal.
|
||||
func TestFirewallDTCPUDPRichRulePair(t *testing.T) {
|
||||
fw := new(FirewallD)
|
||||
tcp, err := fw.UnmarshalRichRule(`rule port port="3492" protocol="tcp" accept`)
|
||||
require.NoError(t, err)
|
||||
udp, err := fw.UnmarshalRichRule(`rule port port="3492" protocol="udp" accept`)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, FamilyAny, tcp.impliedFamily(), "a familyless rich rule covers both families")
|
||||
|
||||
target := &Rule{Proto: TCPUDP, Port: 3492, Action: Accept}
|
||||
require.True(t, target.CoveredBy([]*Rule{tcp, udp}), "the tcp/udp rich-rule pair covers the TCPUDP rule")
|
||||
require.False(t, target.CoveredBy([]*Rule{tcp}), "the tcp rich rule alone leaves udp uncovered")
|
||||
|
||||
// The TCPUDP target reaches each concrete stored row on removal.
|
||||
require.True(t, tcp.EqualForRemoval(target, false))
|
||||
require.True(t, udp.EqualForRemoval(target, 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. The raw-burst guard
|
||||
// must honor 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. firewalld 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.
|
||||
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")
|
||||
}
|
||||
|
||||
// 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 port list never reaches forwardPort:
|
||||
// AddNATRule/RemoveNATRule fan it into one forward-port per spec beforehand.
|
||||
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"}},
|
||||
{"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)
|
||||
}
|
||||
|
||||
// modeledForwardPort must reject the forward-port shapes the write side
|
||||
// (forwardPort) cannot re-create — otherwise GetNATRules would surface entries
|
||||
// that can never be removed or restored, and Restore would destroy them.
|
||||
func TestFirewallDModeledForwardPort(t *testing.T) {
|
||||
fw := new(FirewallD)
|
||||
|
||||
dnat := fw.modeledForwardPort(firewalld.ForwardPort{Port: "8080", Protocol: "tcp", ToPort: "80", ToAddr: "10.0.0.5"})
|
||||
require.NotNil(t, dnat)
|
||||
require.Equal(t, DNAT, dnat.Kind)
|
||||
require.EqualValues(t, 8080, dnat.Port)
|
||||
require.EqualValues(t, 80, dnat.ToPort)
|
||||
|
||||
redirect := fw.modeledForwardPort(firewalld.ForwardPort{Port: "8443", Protocol: "udp", ToPort: "443"})
|
||||
require.NotNil(t, redirect)
|
||||
require.Equal(t, Redirect, redirect.Kind)
|
||||
|
||||
// Shapes forwardPort rejects stay unmodeled: a non-tcp/udp protocol and a
|
||||
// target-port range.
|
||||
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "9999", Protocol: "sctp", ToPort: "80", ToAddr: "10.0.0.5"}))
|
||||
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "9999", Protocol: "dccp"}))
|
||||
require.Nil(t, fw.modeledForwardPort(firewalld.ForwardPort{Port: "8080", Protocol: "tcp", ToPort: "8080-8090", ToAddr: "10.0.0.5"}))
|
||||
}
|
||||
32
go.mod
Normal file
32
go.mod
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
module github.com/grmrgecko/go-firewall
|
||||
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be
|
||||
github.com/coreos/go-systemd/v22 v22.5.0
|
||||
github.com/google/nftables v0.3.0
|
||||
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
|
||||
github.com/vishvananda/netlink v1.3.1
|
||||
go4.org/netipx v0.0.0-20220725152314-7e7bdc8411bf
|
||||
golang.org/x/sys v0.40.0
|
||||
)
|
||||
|
||||
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/google/go-cmp v0.6.0 // indirect
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 // indirect
|
||||
github.com/mdlayher/socket v0.5.0 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/scjalliance/comshim v0.0.0-20190308082608-cf06d2532c4e // indirect
|
||||
github.com/vishvananda/netns v0.0.5 // indirect
|
||||
golang.org/x/net v0.33.0 // indirect
|
||||
golang.org/x/sync v0.6.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
138
go.sum
Normal file
138
go.sum
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
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/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/nftables v0.3.0 h1:bkyZ0cbpVeMHXOrtlFc8ISmfVqq5gPJukoYieyVmITg=
|
||||
github.com/google/nftables v0.3.0/go.mod h1:BCp9FsrbF1Fn/Yu6CLUc9GGZFw/+hsxfluNXXmxBfRM=
|
||||
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/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42 h1:A1Cq6Ysb0GM0tpKMbdCXCIfBclan4oHk1Jb+Hrejirg=
|
||||
github.com/mdlayher/netlink v1.7.3-0.20250113171957-fbb4dce95f42/go.mod h1:BB4YCPDOzfy7FniQ/lxuYQ3dgmM2cZumHbK8RpTjN2o=
|
||||
github.com/mdlayher/socket v0.5.0 h1:ilICZmJcQz70vrWVes1MFera4jGiWNocSkykwwoy3XI=
|
||||
github.com/mdlayher/socket v0.5.0/go.mod h1:WkcBFfvyG8QENs5+hfQPl1X6Jpd2yeLIYgrGFmJiJxI=
|
||||
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=
|
||||
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
|
||||
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
|
||||
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
|
||||
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
|
||||
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/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sys v0.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.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.10.0/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=
|
||||
1175
hooks_linux.go
Normal file
1175
hooks_linux.go
Normal file
File diff suppressed because it is too large
Load diff
1111
hooks_linux_test.go
Normal file
1111
hooks_linux_test.go
Normal file
File diff suppressed because it is too large
Load diff
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) }},
|
||||
})
|
||||
}
|
||||
308
integration_linux_test.go
Normal file
308
integration_linux_test.go
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
//go:build integration
|
||||
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"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())
|
||||
}
|
||||
|
||||
// rawCopyPlanter returns a function that writes a rule directly into the backend's
|
||||
// raw-iptables side store — the csf/apf pre-hook, ufw's before.rules — standing in
|
||||
// for a copy a customer added by hand, or nil when the backend keeps no such store.
|
||||
// It lives here rather than in the shared suite because it names the Linux-only
|
||||
// backend types, which do not compile for the pf and Windows targets.
|
||||
func rawCopyPlanter(mgr Manager) func(*Rule) error {
|
||||
switch b := mgr.(type) {
|
||||
case *APF:
|
||||
return func(r *Rule) error { _, err := b.hook().edit(r, false); return err }
|
||||
case *CSF:
|
||||
return func(r *Rule) error { _, err := b.hook().edit(r, false); return err }
|
||||
case *UFW:
|
||||
return func(r *Rule) error { return b.editIPTablesRules(r, false) }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// foreignSeeder returns a function seeding a foreign rule with the backend's own
|
||||
// tooling, or nil when the backend has no seeder on this platform. The seeding
|
||||
// commands/paths are inherently backend-specific; the assertions in the shared
|
||||
// foreignrule subtest are not.
|
||||
func foreignSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||
switch mgr.Type() {
|
||||
case IPTablesType:
|
||||
// The iptables backend manages the persistent save files (rules.v4 /
|
||||
// rules.v6), so the operator-style seed is a hand-edited save-file line,
|
||||
// not a live `iptables -A` (which the file model deliberately never sees).
|
||||
ipt, ok := mgr.(*IPTables)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return func(string) (*foreignSeed, error) {
|
||||
undo, err := insertSaveFileRule(ipt.IP4Path, "-A INPUT -p tcp --dport 8123 -j ACCEPT")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &foreignSeed{
|
||||
rule: &Rule{Family: IPv4, Proto: TCP, Port: 8123, Action: Accept},
|
||||
inScope: true,
|
||||
undo: undo,
|
||||
}, nil
|
||||
}
|
||||
case NFTType:
|
||||
return func(string) (*foreignSeed, error) {
|
||||
const table = "foreignseed"
|
||||
// The seed carries an address match on purpose: an ip table is its own
|
||||
// family qualifier, so the row states no nfproto and the reader must take
|
||||
// the family from the table or the network-header offsets are ambiguous
|
||||
// and the whole row reads back as an opaque slot.
|
||||
cmds := [][]string{
|
||||
{"add", "table", "ip", table},
|
||||
{"add", "chain", "ip", table, "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "policy", "accept", ";", "}"},
|
||||
{"add", "rule", "ip", table, "input", "ip", "saddr", "192.0.2.10", "tcp", "dport", "8123", "accept"},
|
||||
}
|
||||
for _, c := range cmds {
|
||||
if out, err := exec.Command("nft", c...).CombinedOutput(); err != nil {
|
||||
_ = exec.Command("nft", "delete", "table", "ip", table).Run()
|
||||
return nil, fmt.Errorf("nft %s: %v: %s", strings.Join(c, " "), err, out)
|
||||
}
|
||||
}
|
||||
return &foreignSeed{
|
||||
rule: &Rule{Family: IPv4, Source: "192.0.2.10", Proto: TCP, Port: 8123, Action: Accept},
|
||||
inScope: false, // nft reports foreign tables but writes only to its own.
|
||||
undo: func() { _ = exec.Command("nft", "delete", "table", "ip", table).Run() },
|
||||
}, nil
|
||||
}
|
||||
case UFWType:
|
||||
return func(string) (*foreignSeed, error) {
|
||||
if out, err := exec.Command("ufw", "allow", "8123/tcp").CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("ufw: %v: %s", err, out)
|
||||
}
|
||||
return &foreignSeed{
|
||||
rule: &Rule{Proto: TCP, Port: 8123, Action: Accept},
|
||||
inScope: true,
|
||||
undo: func() { _ = exec.Command("ufw", "--force", "delete", "allow", "8123/tcp").Run() },
|
||||
}, nil
|
||||
}
|
||||
case FirewallDType:
|
||||
return func(zone string) (*foreignSeed, error) {
|
||||
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-port=8123/tcp").CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
|
||||
}
|
||||
return &foreignSeed{
|
||||
rule: &Rule{Proto: TCP, Port: 8123, Action: Accept},
|
||||
// firewalld's container is the zone itself, so every rule read from
|
||||
// it — foreign included — carries the informational flag.
|
||||
hasPrefix: true,
|
||||
inScope: true,
|
||||
undo: func() {
|
||||
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-port=8123/tcp").Run()
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
case CSFType:
|
||||
return func(string) (*foreignSeed, error) {
|
||||
undo, err := appendConfigLine(CSFAllow, "198.51.100.99")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &foreignSeed{
|
||||
rule: &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.99", Action: Accept},
|
||||
inScope: true,
|
||||
undo: undo,
|
||||
}, nil
|
||||
}
|
||||
case APFType:
|
||||
return func(string) (*foreignSeed, error) {
|
||||
undo, err := appendConfigLine(APFAllow, "198.51.100.99")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &foreignSeed{
|
||||
rule: &Rule{Direction: DirAny, Family: IPv4, Source: "198.51.100.99", Action: Accept},
|
||||
inScope: true,
|
||||
undo: undo,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// foreignMACSeeder returns a function seeding a foreign MAC zone source with the
|
||||
// backend's own tooling, or nil when the backend has no MAC-source construct on
|
||||
// this platform. Only firewalld models one (a zone source) and only firewall-cmd
|
||||
// can seed it; the assertions in the shared foreignmacsource subtest are not
|
||||
// backend-specific.
|
||||
func foreignMACSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||
if mgr.Type() != FirewallDType {
|
||||
return nil
|
||||
}
|
||||
return func(zone string) (*foreignSeed, error) {
|
||||
const mac = "00:11:22:33:44:55"
|
||||
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-source="+mac).CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
|
||||
}
|
||||
return &foreignSeed{
|
||||
rule: &Rule{Source: mac, Action: Accept},
|
||||
hasPrefix: true,
|
||||
inScope: true,
|
||||
undo: func() {
|
||||
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-source="+mac).Run()
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// foreignProtocolSeeder returns a function seeding a foreign bare-protocol allow
|
||||
// with the backend's own tooling, or nil when the backend has no distinct
|
||||
// protocol-entry construct on this platform. Only firewalld stores one (a zone
|
||||
// protocol entry, distinct from the rich-rule form the library writes).
|
||||
func foreignProtocolSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||
if mgr.Type() != FirewallDType {
|
||||
return nil
|
||||
}
|
||||
return func(zone string) (*foreignSeed, error) {
|
||||
const proto = "gre"
|
||||
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--add-protocol="+proto).CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
|
||||
}
|
||||
return &foreignSeed{
|
||||
rule: &Rule{Proto: GRE, Action: Accept},
|
||||
hasPrefix: true,
|
||||
inScope: true,
|
||||
undo: func() {
|
||||
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zone, "--remove-protocol="+proto).Run()
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// unmanagedRawRuleSeeder returns a function injecting a parseable rule into a
|
||||
// raw rules file the backend deliberately does not manage, or nil when the
|
||||
// backend keeps no such file on this platform. Only ufw has the before/after
|
||||
// split: the library writes raw rules into before.rules only, so a rule seeded
|
||||
// into after.rules must stay invisible to GetRules. The returned probe is what
|
||||
// the seeded line would read back as if it were (wrongly) surfaced.
|
||||
func unmanagedRawRuleSeeder(mgr Manager) func() (*Rule, func(), error) {
|
||||
if mgr.Type() != UFWType {
|
||||
return nil
|
||||
}
|
||||
return func() (*Rule, func(), error) {
|
||||
const afterPath = "/etc/ufw/after.rules"
|
||||
orig, err := os.ReadFile(afterPath)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
// Inject before the real COMMIT directive. after.rules carries a "# don't
|
||||
// delete the 'COMMIT' line" comment, so match the standalone COMMIT line
|
||||
// rather than the first literal.
|
||||
lines := strings.Split(string(orig), "\n")
|
||||
placed := false
|
||||
for i, l := range lines {
|
||||
if strings.TrimSpace(l) == "COMMIT" {
|
||||
lines = append(lines[:i:i], append([]string{"-A ufw-after-input -p tcp -m tcp --dport 8765 -j ACCEPT"}, lines[i:]...)...)
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !placed {
|
||||
return nil, nil, fmt.Errorf("%s has no COMMIT directive to inject before", afterPath)
|
||||
}
|
||||
if err := os.WriteFile(afterPath, []byte(strings.Join(lines, "\n")), 0o640); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
probe := &Rule{Family: IPv4, Proto: TCP, Port: 8765, Action: Accept}
|
||||
return probe, func() { _ = os.WriteFile(afterPath, orig, 0o640) }, nil
|
||||
}
|
||||
}
|
||||
|
||||
// zoneInterfaceSeeder returns a function binding an interface to a named zone
|
||||
// out of band (permanent config only, so nothing filters at runtime), or nil
|
||||
// when the backend has no interface-to-zone mapping on this platform. Only
|
||||
// firewalld models one and only firewall-cmd can seed it; the assertions in the
|
||||
// shared zones subtest are not backend-specific.
|
||||
func zoneInterfaceSeeder(mgr Manager) func(iface, zoneName string) (func(), error) {
|
||||
if mgr.Type() != FirewallDType {
|
||||
return nil
|
||||
}
|
||||
return func(iface, zoneName string) (func(), error) {
|
||||
if out, err := exec.Command("firewall-cmd", "--permanent", "--zone="+zoneName, "--add-interface="+iface).CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("firewall-cmd: %v: %s", err, out)
|
||||
}
|
||||
return func() {
|
||||
_ = exec.Command("firewall-cmd", "--permanent", "--zone="+zoneName, "--remove-interface="+iface).Run()
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// insertSaveFileRule inserts one iptables-save rule line into path's *filter
|
||||
// section, before its COMMIT, returning an undo that restores the original
|
||||
// content byte for byte.
|
||||
func insertSaveFileRule(path, line string) (func(), error) {
|
||||
orig, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(string(orig), "\n")
|
||||
inFilter, placed := false, false
|
||||
for i, l := range lines {
|
||||
trimmed := strings.TrimSpace(l)
|
||||
if strings.HasPrefix(trimmed, "*") {
|
||||
inFilter = trimmed == "*filter"
|
||||
continue
|
||||
}
|
||||
if inFilter && trimmed == "COMMIT" {
|
||||
lines = append(lines[:i:i], append([]string{line}, lines[i:]...)...)
|
||||
placed = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !placed {
|
||||
return nil, fmt.Errorf("%s has no *filter COMMIT to insert before", path)
|
||||
}
|
||||
if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() { _ = os.WriteFile(path, orig, 0o600) }, nil
|
||||
}
|
||||
|
||||
// appendConfigLine appends one line to a config file, returning an undo that
|
||||
// restores the original content byte for byte.
|
||||
func appendConfigLine(path, line string) (func(), error) {
|
||||
orig, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
content := string(orig)
|
||||
if content != "" && !strings.HasSuffix(content, "\n") {
|
||||
content += "\n"
|
||||
}
|
||||
// The file exists, so WriteFile keeps its mode; the permission argument only
|
||||
// applies on create.
|
||||
if err := os.WriteFile(path, []byte(content+line+"\n"), 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() { _ = os.WriteFile(path, orig, 0o600) }, nil
|
||||
}
|
||||
43
integration_nohook_test.go
Normal file
43
integration_nohook_test.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
//go:build integration && !linux
|
||||
|
||||
package firewall
|
||||
|
||||
// rawCopyPlanter reports that no backend on this platform keeps a raw-iptables side
|
||||
// store: the csf/apf pre-hook and ufw's before.rules are Linux-only constructs. The
|
||||
// shared suite skips its raw-copy probe on a nil result. See the Linux implementation
|
||||
// in integration_linux_test.go.
|
||||
func rawCopyPlanter(mgr Manager) func(*Rule) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// foreignSeeder reports no out-of-band foreign-rule seeder on this platform; the
|
||||
// Linux implementation in integration_linux_test.go covers the Linux backends.
|
||||
// The shared suite skips its foreignrule probe on a nil result. Seeding pf (pfctl
|
||||
// anchors) and wf (netsh advfirewall) the same way is still open.
|
||||
func foreignSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// foreignMACSeeder reports no MAC zone-source seeder on this platform: the MAC
|
||||
// source is a firewalld construct and firewalld is Linux-only.
|
||||
func foreignMACSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// foreignProtocolSeeder reports no zone protocol-entry seeder on this platform,
|
||||
// for the same reason as foreignMACSeeder.
|
||||
func foreignProtocolSeeder(mgr Manager) func(zone string) (*foreignSeed, error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// unmanagedRawRuleSeeder reports no unmanaged raw rules file on this platform:
|
||||
// the before/after split is a ufw construct and ufw is Linux-only.
|
||||
func unmanagedRawRuleSeeder(mgr Manager) func() (*Rule, func(), error) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// zoneInterfaceSeeder reports no interface-to-zone seeder on this platform: the
|
||||
// interface binding is a firewalld construct and firewalld is Linux-only.
|
||||
func zoneInterfaceSeeder(mgr Manager) func(iface, zoneName string) (func(), error) {
|
||||
return nil
|
||||
}
|
||||
2473
integration_test.go
Normal file
2473
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) }},
|
||||
})
|
||||
}
|
||||
314
ipset_linux.go
Normal file
314
ipset_linux.go
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/vishvananda/netlink"
|
||||
"github.com/vishvananda/netlink/nl"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// ipsetLayoutInstalled reports the ipset staging file and restore service a
|
||||
// packaging uses, or empty strings when its persistence mechanism is not
|
||||
// installed. The Debian layout restores sets through a netfilter-persistent
|
||||
// plugin (proven by ipsetPlugin's presence); the RHEL and Arch layouts use a
|
||||
// dedicated ipset service (proven by its unit or init.d script existing).
|
||||
func ipsetLayoutInstalled(ctx context.Context, layout iptLayout) (path, service string) {
|
||||
if layout.ipsetPath == "" {
|
||||
return "", ""
|
||||
}
|
||||
if layout.ipsetPlugin != "" {
|
||||
if matches, _ := filepath.Glob(layout.ipsetPlugin); len(matches) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
return layout.ipsetPath, layout.ipsetService
|
||||
}
|
||||
if !serviceInstalled(ctx, layout.ipsetService) {
|
||||
return "", ""
|
||||
}
|
||||
return layout.ipsetPath, layout.ipsetService
|
||||
}
|
||||
|
||||
// detectIPSetPersistence reports the first installed ipset persistence mechanism
|
||||
// among the known packagings, independent of which one manages the rules save
|
||||
// files. It serves backends that keep their rules elsewhere but stage their sets
|
||||
// as plain ipsets — ufw, whose host commonly carries the ipset package without
|
||||
// iptables-persistent, so probing by rules layout would find nothing.
|
||||
func detectIPSetPersistence(ctx context.Context) (path, service string) {
|
||||
for _, l := range iptLayouts {
|
||||
if p, s := ipsetLayoutInstalled(ctx, l); p != "" {
|
||||
return p, s
|
||||
}
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// ipsetLiveFamily reports the family of a live kernel ipset by name: the shared
|
||||
// set-family source for backends whose address sets are plain ipsets created
|
||||
// live (iptables, and ufw through its iptables set helper). It is a variable so
|
||||
// tests can substitute a fake kernel.
|
||||
var ipsetLiveFamily = netlinkIPSetFamily
|
||||
|
||||
// ipsetRefFamily resolves the single family of the set(s) a rule references for
|
||||
// every backend whose address sets are kernel ipsets: declared — the backend's
|
||||
// own staged sets, its config file or hook — is consulted first, because those
|
||||
// backends stage set changes and activate them on Reload, so a set staged but
|
||||
// not yet loaded is the family the rule will actually match once both are
|
||||
// applied. An unrelated live ipset sharing the name must not shadow it. The live
|
||||
// kernel is the fallback, covering a set that exists only in the kernel (a host
|
||||
// with no persistence mechanism, where sets are created live). declared runs
|
||||
// lazily, at most once per resolve. A live query failure (netlink blocked, no
|
||||
// privileges) just means not found live. Backends whose sets are not kernel
|
||||
// ipsets (nftables named sets, firewalld's D-Bus ipsets) must not resolve
|
||||
// through here.
|
||||
func ipsetRefFamily(source, destination string, declared func() ([]*AddressSet, error)) (Family, error) {
|
||||
var sets []*AddressSet
|
||||
loaded := false
|
||||
return setRefFamilyFrom(func(name string) (Family, bool, error) {
|
||||
if !loaded {
|
||||
loaded = true
|
||||
var err error
|
||||
if sets, err = declared(); err != nil {
|
||||
return FamilyAny, false, err
|
||||
}
|
||||
}
|
||||
for _, s := range sets {
|
||||
if s.Name == name {
|
||||
return s.Family, true, nil
|
||||
}
|
||||
}
|
||||
if fam, found, err := ipsetLiveFamily(name); err == nil && found {
|
||||
return fam, true, nil
|
||||
}
|
||||
return FamilyAny, false, nil
|
||||
}, source, destination)
|
||||
}
|
||||
|
||||
// --- live kernel ipsets -----------------------------------------------------
|
||||
//
|
||||
// Every kernel-side set operation but the staged bulk load goes through netlink
|
||||
// here. The `ipset` binary is kept only for that load (ipset restore), where the
|
||||
// tool owns the file format, parses the entries and negotiates each type's
|
||||
// revision; doing that over netlink would mean reimplementing all three.
|
||||
//
|
||||
// Reading these back is deliberately kept in step with the save-format decode in
|
||||
// iptables_linux.go: the same address set must look the same whether it was read
|
||||
// from a staging file or from the kernel.
|
||||
|
||||
// ipsetCall runs a netlink ipset operation, turning the library's panic on a
|
||||
// kernel error it cannot type-assert to syscall.Errno into an ordinary error.
|
||||
// Every call into the library must be wrapped: the assertion is unchecked, so
|
||||
// any non-Errno failure (a closed socket, a short read) would otherwise take the
|
||||
// caller's process down.
|
||||
func ipsetCall(op string, fn func() error) (err error) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("ipset %s failed: %v", op, r)
|
||||
}
|
||||
}()
|
||||
return fn()
|
||||
}
|
||||
|
||||
// ipsetNoSuchSet reports whether err is the kernel's answer for an operation on
|
||||
// a set that does not exist, which every removal treats as already done.
|
||||
func ipsetNoSuchSet(err error) bool {
|
||||
return errors.Is(err, syscall.ENOENT)
|
||||
}
|
||||
|
||||
// ipsetInUse reports whether err is the kernel refusing to destroy a set that a
|
||||
// loaded rule still matches on. The staged model reloads the rules before the
|
||||
// destroys it owes, so this means a rule outside the staging file holds the set.
|
||||
func ipsetInUse(err error) bool {
|
||||
var e nl.IPSetError
|
||||
return errors.As(err, &e) && (int(e) == nl.IPSET_ERR_BUSY || int(e) == nl.IPSET_ERR_REFERENCED)
|
||||
}
|
||||
|
||||
// ipsetUnavailable reports whether err means the host has no ipset support at
|
||||
// all — the kernel module is absent and nfnetlink has no subsystem to hand the
|
||||
// request to. Read as no sets rather than an error, matching a host where the
|
||||
// feature was never installed.
|
||||
func ipsetUnavailable(err error) bool {
|
||||
return errors.Is(err, syscall.EOPNOTSUPP) || errors.Is(err, syscall.EPROTONOSUPPORT)
|
||||
}
|
||||
|
||||
// ipsetNLFamily maps a family to the value the kernel's ipset API expects.
|
||||
func ipsetNLFamily(f Family) uint8 {
|
||||
if f == IPv6 {
|
||||
return unix.AF_INET6
|
||||
}
|
||||
return unix.AF_INET
|
||||
}
|
||||
|
||||
// ipsetEncodeEntry converts a set entry — a bare address or a CIDR — into the
|
||||
// netlink form. Replace is set so add and delete carry the kernel's exist flag,
|
||||
// the same idempotence `ipset -exist` gives.
|
||||
func ipsetEncodeEntry(entry string) (*netlink.IPSetEntry, error) {
|
||||
if strings.Contains(entry, "/") {
|
||||
_, n, err := net.ParseCIDR(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("address set entry %q is not a valid cidr: %w", entry, err)
|
||||
}
|
||||
ones, _ := n.Mask.Size()
|
||||
return &netlink.IPSetEntry{IP: n.IP, CIDR: uint8(ones), Replace: true}, nil
|
||||
}
|
||||
ip := net.ParseIP(entry)
|
||||
if ip == nil {
|
||||
return nil, fmt.Errorf("address set entry %q is not an ip address or cidr", entry)
|
||||
}
|
||||
return &netlink.IPSetEntry{IP: ip, Replace: true}, nil
|
||||
}
|
||||
|
||||
// ipsetDecodeEntry renders a kernel entry the way the save format writes it, so
|
||||
// a set read live and the same set read from a staging file compare equal. A
|
||||
// prefix covering the whole address is dropped, which is how `ipset save` emits
|
||||
// a single host stored in a hash:net set.
|
||||
func ipsetDecodeEntry(e netlink.IPSetEntry) string {
|
||||
if e.IP == nil {
|
||||
return ""
|
||||
}
|
||||
bits := 128
|
||||
if e.IP.To4() != nil {
|
||||
bits = 32
|
||||
}
|
||||
if e.CIDR == 0 || int(e.CIDR) == bits {
|
||||
return e.IP.String()
|
||||
}
|
||||
return e.IP.String() + "/" + strconv.Itoa(int(e.CIDR))
|
||||
}
|
||||
|
||||
// ipsetCreate creates a kernel set, tolerating one that already exists with the
|
||||
// same definition. The type revision is left to the netlink library, which maps
|
||||
// hash:ip to its revision 1 and falls through to the base revision 0 for
|
||||
// hash:net: every kernel carrying hash:net registers 0, and the revisions above
|
||||
// it only add features (ranges, nomatch, counters, comments, forceadd, skbinfo)
|
||||
// this library never asks for.
|
||||
func ipsetCreate(name string, family Family, t SetType) error {
|
||||
return ipsetCall("create "+name, func() error {
|
||||
return netlink.IpsetCreate(name, t.String(), netlink.IpsetCreateOptions{
|
||||
Replace: true,
|
||||
Family: ipsetNLFamily(family),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ipsetDestroy removes a kernel set. A set that is already gone is success.
|
||||
func ipsetDestroy(name string) error {
|
||||
err := ipsetCall("destroy "+name, func() error { return netlink.IpsetDestroy(name) })
|
||||
if err == nil || ipsetNoSuchSet(err) {
|
||||
return nil
|
||||
}
|
||||
if ipsetInUse(err) {
|
||||
return fmt.Errorf("address set %q is still referenced by a loaded rule: %w", name, err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ipsetFlush empties a kernel set. A set that is already gone is success.
|
||||
func ipsetFlush(name string) error {
|
||||
err := ipsetCall("flush "+name, func() error { return netlink.IpsetFlush(name) })
|
||||
if err != nil && !ipsetNoSuchSet(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ipsetAddEntry adds an entry to a kernel set. An entry already present is
|
||||
// success; a set that does not exist is reported, since the caller asked to add
|
||||
// to something that is not there.
|
||||
func ipsetAddEntry(name, entry string) error {
|
||||
e, err := ipsetEncodeEntry(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := ipsetCall("add "+name, func() error { return netlink.IpsetAdd(name, e) }); err != nil {
|
||||
if ipsetNoSuchSet(err) {
|
||||
return fmt.Errorf("address set %q does not exist", name)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ipsetDelEntry removes an entry from a kernel set. A missing entry, or a
|
||||
// missing set, is success.
|
||||
func ipsetDelEntry(name, entry string) error {
|
||||
e, err := ipsetEncodeEntry(entry)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = ipsetCall("del "+name, func() error { return netlink.IpsetDel(name, e) })
|
||||
if err != nil && !ipsetNoSuchSet(err) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ipsetLiveSets reports every set the kernel holds, decoded into address sets.
|
||||
// A host with no ipset support has no sets to report rather than an error, which
|
||||
// keeps a Backup on such a host from failing over a feature it never had.
|
||||
func ipsetLiveSets() ([]*AddressSet, error) {
|
||||
var res []netlink.IPSetResult
|
||||
err := ipsetCall("list", func() error {
|
||||
var e error
|
||||
res, e = netlink.IpsetListAll()
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
if ipsetUnavailable(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
sets := make([]*AddressSet, 0, len(res))
|
||||
for _, r := range res {
|
||||
if r.SetName == "" {
|
||||
continue
|
||||
}
|
||||
set := &AddressSet{Name: r.SetName, Family: IPv4, Type: SetHashIP}
|
||||
if r.Family == unix.NFPROTO_IPV6 {
|
||||
set.Family = IPv6
|
||||
}
|
||||
// An unmodeled type reads as hash:ip, matching how the save-format decode
|
||||
// treats a create line it does not recognize.
|
||||
if r.TypeName == SetHashNet.String() {
|
||||
set.Type = SetHashNet
|
||||
}
|
||||
for _, e := range r.Entries {
|
||||
if s := ipsetDecodeEntry(e); s != "" {
|
||||
set.Entries = append(set.Entries, s)
|
||||
}
|
||||
}
|
||||
sets = append(sets, set)
|
||||
}
|
||||
return sets, nil
|
||||
}
|
||||
|
||||
// netlinkIPSetFamily queries the kernel's ipset subsystem over netlink for a
|
||||
// set's family. A set that does not exist reports found=false; any other
|
||||
// failure (netlink unavailable, insufficient privileges) is an error the caller
|
||||
// can fall back from.
|
||||
func netlinkIPSetFamily(name string) (fam Family, found bool, err error) {
|
||||
var res *netlink.IPSetResult
|
||||
err = ipsetCall("list "+name, func() error {
|
||||
var e error
|
||||
res, e = netlink.IpsetList(name)
|
||||
return e
|
||||
})
|
||||
if err != nil {
|
||||
if ipsetNoSuchSet(err) {
|
||||
return FamilyAny, false, nil
|
||||
}
|
||||
return FamilyAny, false, err
|
||||
}
|
||||
if res.Family == unix.NFPROTO_IPV6 {
|
||||
return IPv6, true, nil
|
||||
}
|
||||
return IPv4, true, nil
|
||||
}
|
||||
3449
iptables_linux.go
Normal file
3449
iptables_linux.go
Normal file
File diff suppressed because it is too large
Load diff
1534
iptables_linux_test.go
Normal file
1534
iptables_linux_test.go
Normal file
File diff suppressed because it is too large
Load diff
192
livecounters_linux.go
Normal file
192
livecounters_linux.go
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Live packet/byte counters for the backends that store their rules in files but
|
||||
// enforce them through iptables — iptables itself, ufw, csf and apf. The files
|
||||
// carry no counts, so the counts have to come from the running ruleset, and the
|
||||
// rows there are whatever the product generates for a rule rather than the rule
|
||||
// itself. Each backend supplies the decode step that turns one of its rows back
|
||||
// into the rule it stands for — for iptables that is the identity, for the others
|
||||
// it undoes the product's own framing; the reading, the run handling and the
|
||||
// matching below are the same for all four.
|
||||
//
|
||||
// Counters are informational (Capabilities().RuleCounters) and never part of
|
||||
// rule identity, so everything here is best-effort: a missing save binary, a row
|
||||
// this model cannot hold, or a rule the running ruleset does not carry simply
|
||||
// leaves that rule's counters zero.
|
||||
|
||||
// liveRow is one counter-annotated `iptables-save -c` rule line, split into the
|
||||
// parts a backend needs to decide what rule it stands for.
|
||||
type liveRow struct {
|
||||
// chain is the chain the line appends to.
|
||||
chain string
|
||||
// fields is the whole line split on whitespace, counter token first. It is
|
||||
// for inspection only: a quoted log prefix does not survive the split, so
|
||||
// rewrites operate on line.
|
||||
fields []string
|
||||
// line is the line verbatim, counter token included.
|
||||
line string
|
||||
// newChain reports whether this row opens a chain, so a decoder holding
|
||||
// state across adjacent rows knows to drop it.
|
||||
newChain bool
|
||||
}
|
||||
|
||||
// liveSaveLines reads the live filter table for a family with counters attached.
|
||||
// A missing or failing save binary yields no lines rather than an error, since a
|
||||
// backend that cannot read counters still reports its rules.
|
||||
func liveSaveLines(ctx context.Context, fam Family) []string {
|
||||
cmd := "iptables-save"
|
||||
if fam == IPv6 {
|
||||
cmd = "ip6tables-save"
|
||||
}
|
||||
out, err := runCommand(ctx, cmd, "-c", "-t", "filter")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// jumpTarget returns the target of a rulespec's `-j`/`--jump` option, or the
|
||||
// empty string when the line carries none.
|
||||
func jumpTarget(fields []string) string {
|
||||
for i, tok := range fields {
|
||||
if tok == "-j" || tok == "--jump" {
|
||||
if i+1 < len(fields) {
|
||||
return fields[i+1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// parseLiveRow reparses a live row as a rule in the direction its chain stands
|
||||
// for. It rewrites the product's chain to the INPUT/OUTPUT/FORWARD equivalent
|
||||
// and reuses the iptables rulespec parser, which also lifts the [pkts:bytes]
|
||||
// prefix onto the rule. A row the model cannot hold is rejected.
|
||||
func parseLiveRow(row liveRow, dir Direction, fam Family) (*Rule, bool) {
|
||||
spec := strings.Replace(row.line, row.fields[1]+" "+row.chain,
|
||||
row.fields[1]+" "+iptChainForDirection(dir), 1)
|
||||
r, err := unmarshalIPTablesRule(spec, fam)
|
||||
if err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
|
||||
// decodeLiveRows decodes counter-annotated `iptables-save -c` output into the
|
||||
// rules a backend's chains hold. decode turns one row into the rule it stands
|
||||
// for and reports false for a row that stands for no rule of its own — a chain
|
||||
// the backend does not surface, or one line of a multi-line expansion.
|
||||
//
|
||||
// A LOG line and the action line beneath it are one logical rule, but only while
|
||||
// they stay physically adjacent, so a row decode rejects ends the current run
|
||||
// and that run is coalesced on its own.
|
||||
func decodeLiveRows(out []string, decode func(row liveRow) (*Rule, bool)) []*Rule {
|
||||
var rules, run []*Rule
|
||||
flush := func() {
|
||||
if len(run) > 0 {
|
||||
rules = append(rules, coalesceLoggedRules(run)...)
|
||||
run = nil
|
||||
}
|
||||
}
|
||||
|
||||
chain := ""
|
||||
for _, line := range out {
|
||||
line = strings.TrimSpace(line)
|
||||
// Only the counter-annotated rule lines carry a rule; the table and chain
|
||||
// headers do not.
|
||||
if !strings.HasPrefix(line, "[") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 4 || (fields[1] != "-A" && fields[1] != "--append") {
|
||||
continue
|
||||
}
|
||||
|
||||
row := liveRow{chain: fields[2], fields: fields, line: line}
|
||||
if row.chain != chain {
|
||||
flush()
|
||||
chain, row.newChain = row.chain, true
|
||||
}
|
||||
r, ok := decode(row)
|
||||
if !ok {
|
||||
flush()
|
||||
continue
|
||||
}
|
||||
run = append(run, r)
|
||||
}
|
||||
flush()
|
||||
return rules
|
||||
}
|
||||
|
||||
// applyLiveCounters attaches each live row's counters to the rule it belongs to
|
||||
// and returns the rows no rule claimed. Every live row is consumed at most once,
|
||||
// so duplicate rules keep distinct counts, and a rule with no live counterpart —
|
||||
// an edit not yet activated by Reload, or one the backend stores but has not
|
||||
// loaded — keeps zero counters. The leftovers are for a backend that has to
|
||||
// claim a row on something other than rule identity (see CSF.claimDenyOutRows).
|
||||
func applyLiveCounters(targets, live []*Rule) []*Rule {
|
||||
if len(targets) == 0 || len(live) == 0 {
|
||||
return live
|
||||
}
|
||||
|
||||
// Match on identity first, so a rule that has a row of its own never absorbs a
|
||||
// wider neighbour's count.
|
||||
used := make([]bool, len(live))
|
||||
matched := make([]bool, len(targets))
|
||||
for ri, r := range targets {
|
||||
for i, l := range live {
|
||||
if used[i] || !l.Equal(r, true) {
|
||||
continue
|
||||
}
|
||||
r.Packets, r.Bytes = l.Packets, l.Bytes
|
||||
used[i], matched[ri] = true, true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Then sum the rows a wider rule spans: a rule that covers more than one
|
||||
// family, transport or direction is written as one row per cell, so its count
|
||||
// is their total.
|
||||
for ri, r := range targets {
|
||||
if matched[ri] {
|
||||
continue
|
||||
}
|
||||
for i, l := range live {
|
||||
if used[i] || !r.Covers(l) {
|
||||
continue
|
||||
}
|
||||
r.Packets += l.Packets
|
||||
r.Bytes += l.Bytes
|
||||
used[i] = true
|
||||
}
|
||||
}
|
||||
|
||||
var leftover []*Rule
|
||||
for i, l := range live {
|
||||
if !used[i] {
|
||||
leftover = append(leftover, l)
|
||||
}
|
||||
}
|
||||
return leftover
|
||||
}
|
||||
|
||||
// countableRules returns the rules a family's live ruleset can account for: the
|
||||
// ones pinned to that family, plus the family-agnostic ones. A backend that
|
||||
// stores a rule dual-stack (apf's port lists, a family-agnostic hook line)
|
||||
// reports it as one FamilyAny rule that both families' rulesets hold a row for,
|
||||
// so it is offered to each and its counters accumulate across the two.
|
||||
func countableRules(rules []*Rule, fam Family) []*Rule {
|
||||
var out []*Rule
|
||||
for _, r := range rules {
|
||||
if r != nil && (r.Family == fam || r.Family == FamilyAny) {
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
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")
|
||||
}
|
||||
183
manager_linux.go
Normal file
183
manager_linux.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// NewManager gets a firewall manager for this server, probing the higher-level
|
||||
// managers first: firewalld, then ufw/csf/apf, then plain iptables, and
|
||||
// nftables last, so a manager that is itself backed by iptables or nftables is
|
||||
// preferred over managing its tables behind its back. 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) {
|
||||
// A probe error usually means "not installed", but it can also be a
|
||||
// transient failure on a host whose firewall IS that manager (a D-Bus
|
||||
// hiccup with firewalld running); every probe's reason is carried in the
|
||||
// final error so a mis-detection is diagnosable.
|
||||
var errs []error
|
||||
probes := []struct {
|
||||
name string
|
||||
try func() (Manager, error)
|
||||
}{
|
||||
{"firewalld", func() (Manager, error) { return NewFirewallD(ctx, rulePrefix) }},
|
||||
{"ufw", func() (Manager, error) { return NewUFW(ctx, rulePrefix) }},
|
||||
{"csf", func() (Manager, error) { return NewCSF(ctx, rulePrefix) }},
|
||||
{"apf", func() (Manager, error) { return NewAPF(ctx, rulePrefix) }},
|
||||
{"iptables", func() (Manager, error) { return NewIPTables(ctx, rulePrefix) }},
|
||||
{"nftables", func() (Manager, error) { return NewNFT(ctx, rulePrefix) }},
|
||||
}
|
||||
for _, p := range probes {
|
||||
mgr, err := p.try()
|
||||
if err == nil {
|
||||
return mgr, nil
|
||||
}
|
||||
errs = append(errs, fmt.Errorf("%s: %w", p.name, err))
|
||||
}
|
||||
return nil, fmt.Errorf("no firewall manager found: %w", errors.Join(errs...))
|
||||
}
|
||||
|
||||
// combineComment joins the configured prefix and an optional user comment into the
|
||||
// single comment string stored on a rule. The prefix is always carried so rules
|
||||
// this library creates stay identifiable: when both are present the prefix is
|
||||
// followed by a space and the user text; when only one is present it is used
|
||||
// alone; when neither is present the result is empty. Shared by the tag-based
|
||||
// Linux backends (iptables, ufw, csf, apf, and the csf/apf hook).
|
||||
func combineComment(prefix, comment string) string {
|
||||
if prefix == "" {
|
||||
return comment
|
||||
}
|
||||
if comment == "" {
|
||||
return prefix
|
||||
}
|
||||
return prefix + " " + comment
|
||||
}
|
||||
|
||||
// commentGroup is one span of a scanned list file: a content line together with
|
||||
// the full-line comment lines attached directly above it, or a single
|
||||
// passthrough line — a blank, a detached comment, or a line the file's
|
||||
// convention does not attach comments to — on its own.
|
||||
type commentGroup struct {
|
||||
// raw preserves the original lines, attached comments first and the content
|
||||
// line last, so a rewrite copies user formatting through verbatim and a
|
||||
// removal drops a rule's comment together with its line.
|
||||
raw []string
|
||||
// line is the trimmed content line, or "" for a passthrough group.
|
||||
line string
|
||||
// comment is the space-joined text of the attached comment lines.
|
||||
comment string
|
||||
}
|
||||
|
||||
// scanCommentGroups streams a list file to fn as comment-attached groups, the
|
||||
// convention shared by the csf.allow/csf.deny lists, the apf trust files, and
|
||||
// the raw-iptables hook: consecutive full-line `#` comments attach to the
|
||||
// content line directly below them as its comment. A blank line detaches a
|
||||
// pending comment block into passthrough groups, and a rulePrefix tag starts a
|
||||
// fresh block, so header comments above a tagged rule survive the rule's
|
||||
// removal. A `#!` line is never a comment: a shebang must not attach to a rule
|
||||
// and be dropped with it. attach reports whether a content line takes the
|
||||
// pending comments (nil attaches to every non-blank, non-comment line); a line
|
||||
// it declines — user shell or ipset lines in the hook — detaches them and
|
||||
// passes through on its own. A nil fd scans as an empty file. An error from fn
|
||||
// stops the scan.
|
||||
func scanCommentGroups(fd *os.File, rulePrefix string, attach func(trimmed string) bool, fn func(g commentGroup) error) error {
|
||||
if fd == nil {
|
||||
return nil
|
||||
}
|
||||
scanner := bufio.NewScanner(fd)
|
||||
// pending holds the raw held-back comment lines (a rewrite needs them
|
||||
// verbatim); pendingText accumulates their space-joined comment text (the
|
||||
// parse needs it).
|
||||
var pending []string
|
||||
var pendingText string
|
||||
flush := func() error {
|
||||
for _, c := range pending {
|
||||
if err := fn(commentGroup{raw: []string{c}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
pending, pendingText = nil, ""
|
||||
return nil
|
||||
}
|
||||
for scanner.Scan() {
|
||||
raw := scanner.Text()
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
|
||||
// A full-line comment is held as a candidate rule comment.
|
||||
if strings.HasPrefix(trimmed, "#") && !strings.HasPrefix(trimmed, "#!") {
|
||||
text := strings.TrimSpace(strings.TrimPrefix(trimmed, "#"))
|
||||
if rulePrefix != "" && (text == rulePrefix || strings.HasPrefix(text, rulePrefix+" ")) {
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if text != "" {
|
||||
if pendingText != "" {
|
||||
pendingText += " " + text
|
||||
} else {
|
||||
pendingText = text
|
||||
}
|
||||
}
|
||||
pending = append(pending, raw)
|
||||
continue
|
||||
}
|
||||
|
||||
// A blank line or a line the file's convention does not attach comments
|
||||
// to detaches the pending block and passes through untouched.
|
||||
if trimmed == "" || (attach != nil && !attach(trimmed)) {
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(commentGroup{raw: []string{raw}}); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
g := commentGroup{raw: append(pending, raw), line: trimmed, comment: pendingText}
|
||||
pending, pendingText = nil, ""
|
||||
if err := fn(g); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
// trimInlineComment strips a trailing inline `#` comment from a list line —
|
||||
// a note on the line itself, not a rule comment — returning the trimmed rule
|
||||
// text that remains.
|
||||
func trimInlineComment(line string) string {
|
||||
if ci := strings.IndexByte(line, '#'); ci >= 0 {
|
||||
line = line[:ci]
|
||||
}
|
||||
return strings.TrimSpace(line)
|
||||
}
|
||||
|
||||
// prefixedComment splits a stored comment into its user-facing text and whether
|
||||
// the comment carried the configured prefix (marking a rule tagged with this
|
||||
// library's namespace). A comment equal to the prefix (a prefix-only tag) has the
|
||||
// prefix with empty text; a comment carrying the prefix followed by a space has
|
||||
// the prefix with the remainder as text; any other comment lacks the prefix and
|
||||
// is returned unchanged. An empty prefix gives the library no namespace of its
|
||||
// own, so the prefix cannot be derived from the comment — hasPrefix is reported
|
||||
// false and the caller decides (backends treat an empty prefix as covering
|
||||
// everything, see GetRules). It is the read-side inverse of combineComment.
|
||||
func prefixedComment(prefix, comment string) (text string, hasPrefix bool) {
|
||||
if prefix == "" {
|
||||
return comment, false
|
||||
}
|
||||
if comment == prefix {
|
||||
return "", true
|
||||
}
|
||||
if rest, ok := strings.CutPrefix(comment, prefix+" "); ok {
|
||||
return rest, true
|
||||
}
|
||||
return comment, false
|
||||
}
|
||||
31
manager_linux_test.go
Normal file
31
manager_linux_test.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// prefixedComment splits a stored comment into user text and a prefix signal: a
|
||||
// prefix-only tag has the prefix with empty text, a "prefix text" comment has the
|
||||
// prefix with the remainder, anything else lacks the prefix and is unchanged, and
|
||||
// an empty prefix yields no prefix signal (callers treat that as covering
|
||||
// everything).
|
||||
func TestPrefixedComment(t *testing.T) {
|
||||
cases := []struct {
|
||||
prefix, stored string
|
||||
wantText string
|
||||
wantHasPrefix bool
|
||||
}{
|
||||
{"myapp", "myapp", "", true},
|
||||
{"myapp", "myapp https", "https", true},
|
||||
{"myapp", "someone else's note", "someone else's note", false},
|
||||
{"myapp", "", "", false},
|
||||
{"", "anything at all", "anything at all", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
text, hasPrefix := prefixedComment(c.prefix, c.stored)
|
||||
require.Equal(t, c.wantText, text, "text for (%q,%q)", c.prefix, c.stored)
|
||||
require.Equal(t, c.wantHasPrefix, hasPrefix, "hasPrefix for (%q,%q)", c.prefix, c.stored)
|
||||
}
|
||||
}
|
||||
16
manager_windows.go
Normal file
16
manager_windows.go
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// NewManager gets a firewall manager for this server; Windows has a single
|
||||
// backend, the Windows Filtering Platform. The context bounds the probe.
|
||||
func NewManager(ctx context.Context, rulePrefix string) (Manager, error) {
|
||||
wf, err := NewWF(ctx, rulePrefix)
|
||||
if err == nil {
|
||||
return wf, nil
|
||||
}
|
||||
return nil, fmt.Errorf("no firewall manager found: %w", err)
|
||||
}
|
||||
3264
nft_linux.go
Normal file
3264
nft_linux.go
Normal file
File diff suppressed because it is too large
Load diff
666
nft_linux_test.go
Normal file
666
nft_linux_test.go
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/google/nftables"
|
||||
"github.com/google/nftables/binaryutil"
|
||||
"github.com/google/nftables/expr"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// nftEncodeRule marshals a rule, failing the test if it cannot be expressed.
|
||||
func nftEncodeRule(t *testing.T, f *NFT, r *Rule) *nftEncoded {
|
||||
t.Helper()
|
||||
enc, err := f.MarshalRule(r)
|
||||
require.NoError(t, err, "failed to marshal %+v", *r)
|
||||
return enc
|
||||
}
|
||||
|
||||
// nftDecodeRule decodes an encoding back into a rule, resolving whatever anonymous
|
||||
// sets the encoding staged rather than reaching for a live ruleset.
|
||||
func nftDecodeRule(t *testing.T, f *NFT, enc *nftEncoded) *Rule {
|
||||
t.Helper()
|
||||
got, err := f.UnmarshalRule(
|
||||
&nftables.Rule{Exprs: enc.exprs, UserData: enc.userData},
|
||||
enc.chain, newStagedSetReader(enc), f.tableRef())
|
||||
require.NoError(t, err, "failed to decode encoding")
|
||||
return got
|
||||
}
|
||||
|
||||
// nftRoundTrip encodes a rule and decodes the result, which is the shape every
|
||||
// read-after-write path depends on: a rule that does not survive this makes Sync
|
||||
// re-add it on every pass.
|
||||
func nftRoundTrip(t *testing.T, f *NFT, r *Rule) *Rule {
|
||||
t.Helper()
|
||||
return nftDecodeRule(t, f, nftEncodeRule(t, f, r))
|
||||
}
|
||||
|
||||
// nftExprKinds names the expressions an encoding produced, in order, so a test can
|
||||
// assert the shape of an encoding without pinning every field.
|
||||
func nftExprKinds(exprs []expr.Any) []string {
|
||||
var out []string
|
||||
for _, e := range exprs {
|
||||
switch e.(type) {
|
||||
case *expr.Meta:
|
||||
out = append(out, "meta")
|
||||
case *expr.Cmp:
|
||||
out = append(out, "cmp")
|
||||
case *expr.Payload:
|
||||
out = append(out, "payload")
|
||||
case *expr.Bitwise:
|
||||
out = append(out, "bitwise")
|
||||
case *expr.Lookup:
|
||||
out = append(out, "lookup")
|
||||
case *expr.Range:
|
||||
out = append(out, "range")
|
||||
case *expr.Ct:
|
||||
out = append(out, "ct")
|
||||
case *expr.Connlimit:
|
||||
out = append(out, "connlimit")
|
||||
case *expr.Dynset:
|
||||
out = append(out, "dynset")
|
||||
case *expr.Limit:
|
||||
out = append(out, "limit")
|
||||
case *expr.Log:
|
||||
out = append(out, "log")
|
||||
case *expr.Counter:
|
||||
out = append(out, "counter")
|
||||
case *expr.Verdict:
|
||||
out = append(out, "verdict")
|
||||
case *expr.Reject:
|
||||
out = append(out, "reject")
|
||||
case *expr.Immediate:
|
||||
out = append(out, "immediate")
|
||||
case *expr.NAT:
|
||||
out = append(out, "nat")
|
||||
case *expr.Masq:
|
||||
out = append(out, "masq")
|
||||
case *expr.Redir:
|
||||
out = append(out, "redir")
|
||||
default:
|
||||
out = append(out, "unknown")
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Every rule shape the backend can express must survive an encode/decode round
|
||||
// trip, across both directions, both families and each match axis.
|
||||
func TestNFTRuleRoundTrip(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
|
||||
rules := []*Rule{
|
||||
// Addresses and families.
|
||||
{Family: IPv4, Source: "192.168.0.0/24", Port: 23, Proto: UDP, Action: Accept},
|
||||
{Family: IPv4, Source: "1.2.3.4", Proto: TCP, Port: 22, Action: Accept},
|
||||
{Family: IPv4, Source: "1.2.3.4/32", Proto: TCP, Port: 22, Action: Accept},
|
||||
{Family: IPv4, Source: "10.0.0.0/12", Action: Drop},
|
||||
{Family: IPv4, Destination: "203.0.113.10", Port: 4791, Proto: TCP, Action: Reject},
|
||||
{Family: IPv6, Source: "2001:db8::1", Action: Drop},
|
||||
{Family: IPv6, Source: "2001:db8::/32", Action: Drop},
|
||||
{Family: IPv6, Destination: "2001:db8::/48", Proto: TCP, Port: 80, Action: Accept},
|
||||
// Negation.
|
||||
{Family: IPv6, Source: "!2001:db8::1", Action: Drop},
|
||||
{Family: IPv4, Destination: "!10.0.0.0/8", Action: Drop},
|
||||
// Named set references.
|
||||
{Family: IPv4, Source: "blocklist", Port: 22, Proto: TCP, Action: Drop},
|
||||
{Direction: DirOutput, Family: IPv6, Destination: "!allowlist", Port: 80, Proto: TCP, Action: Accept},
|
||||
// Family pinned with no address at all.
|
||||
{Family: IPv4, Port: 4789, Proto: UDP, Action: Accept},
|
||||
{Direction: DirOutput, Family: IPv6, Port: 4789, Proto: UDP, Action: Accept},
|
||||
// Ports: single, span, list, mixed list, source ports.
|
||||
{Proto: TCP, Port: 22, Action: Accept},
|
||||
{Proto: UDP, Ports: []PortRange{{Start: 1000, End: 2000}}, Action: Accept},
|
||||
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
|
||||
{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}, {Start: 8000, End: 8100}}, Action: Accept},
|
||||
{Proto: TCP, SourcePort: 1024, Action: Accept},
|
||||
{Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 65535}}, Action: Accept},
|
||||
{Proto: TCP, Port: 22, SourcePort: 1024, Action: Accept},
|
||||
// Protocols with no ports.
|
||||
{Proto: SCTP, Action: Accept},
|
||||
{Proto: GRE, Action: Accept},
|
||||
{Proto: ESP, Action: Accept},
|
||||
{Proto: AH, Action: Accept},
|
||||
// ICMP.
|
||||
{Proto: ICMP, Action: Accept},
|
||||
{Proto: ICMPv6, Action: Accept},
|
||||
{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept},
|
||||
{Family: IPv6, Proto: ICMPv6, ICMPType: Ptr[uint8](128), Action: Accept},
|
||||
// Both transports in a single row.
|
||||
{Proto: TCPUDP, Port: 53, Action: Accept},
|
||||
{Proto: TCPUDP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept},
|
||||
// Connection state.
|
||||
{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept},
|
||||
{Proto: TCP, Port: 22, State: StateNew | StateEstablished, Action: Accept},
|
||||
{State: StateEstablished | StateRelated, Action: Accept},
|
||||
{State: StateInvalid, Action: Drop},
|
||||
// Interfaces, including a wildcard and a forward rule matching both.
|
||||
{InInterface: "eth0", Proto: TCP, Port: 22, Action: Accept},
|
||||
{InInterface: "eth*", Action: Accept},
|
||||
{Direction: DirOutput, OutInterface: "eth1", Proto: UDP, Port: 53, Action: Accept},
|
||||
{Direction: DirForward, InInterface: "eth0", OutInterface: "eth1", Proto: TCP, Port: 22, Action: Accept},
|
||||
// Rate and connection limits.
|
||||
{Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 5, Unit: PerMinute}, Action: Accept},
|
||||
{Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 100, Unit: PerSecond, Burst: 20}, Action: Accept},
|
||||
{Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10}, Action: Drop},
|
||||
{Family: IPv4, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop},
|
||||
{Family: IPv6, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 3, PerSource: true}, Action: Drop},
|
||||
// Logging and comments.
|
||||
{Proto: TCP, Port: 22, Log: true, Action: Accept},
|
||||
{Proto: TCP, Port: 22, Log: true, LogPrefix: "ssh drop ", Action: Drop},
|
||||
{Proto: TCP, Port: 22, Comment: "managed by go-firewall", Action: Accept},
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
got := nftRoundTrip(t, f, r)
|
||||
require.True(t, got.Equal(r, true),
|
||||
"round-trip mismatch: input %+v, output %+v", *r, *got)
|
||||
require.Equal(t, r.Comment, got.Comment, "comment must round-trip for %+v", *r)
|
||||
}
|
||||
}
|
||||
|
||||
// In an inet table a network-header offset names a different field in each
|
||||
// family, so every rule that resolves to a concrete family must carry the
|
||||
// nfproto guard — without it an IPv4 source-address match also matches inside an
|
||||
// IPv6 source address.
|
||||
func TestNFTFamilyGuardAlwaysEmitted(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
|
||||
for _, r := range []*Rule{
|
||||
{Family: IPv4, Source: "1.2.3.4", Action: Drop},
|
||||
{Family: IPv6, Destination: "2001:db8::1", Action: Drop},
|
||||
{Family: IPv4, Proto: TCP, Port: 22, Action: Accept},
|
||||
{Family: IPv4, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 2, PerSource: true}, Action: Drop},
|
||||
} {
|
||||
enc := nftEncodeRule(t, f, r)
|
||||
require.Equal(t, "meta", nftExprKinds(enc.exprs)[0], "expected a leading family guard for %+v", *r)
|
||||
meta := enc.exprs[0].(*expr.Meta)
|
||||
require.Equal(t, expr.MetaKeyNFPROTO, meta.Key, "expected an nfproto guard for %+v", *r)
|
||||
cmp := enc.exprs[1].(*expr.Cmp)
|
||||
require.Equal(t, []byte{f.nfprotoByte(r.impliedFamily())}, cmp.Data)
|
||||
}
|
||||
|
||||
// A rule with no family to pin carries no guard.
|
||||
enc := nftEncodeRule(t, f, &Rule{Proto: TCP, Port: 22, Action: Accept})
|
||||
meta := enc.exprs[0].(*expr.Meta)
|
||||
require.Equal(t, expr.MetaKeyL4PROTO, meta.Key, "an unpinned rule must not claim a family")
|
||||
}
|
||||
|
||||
// A per-source connection limit counts in a named dynamic set keyed on the
|
||||
// source address. The set is family-typed, so its key must name the family's own
|
||||
// address field and the set must be created alongside the rule.
|
||||
func TestNFTPerSourceConnLimit(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
|
||||
r := &Rule{Family: IPv4, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop}
|
||||
enc := nftEncodeRule(t, f, r)
|
||||
|
||||
require.NotNil(t, enc.meterSet, "a per-source limit must create its counting set")
|
||||
require.True(t, enc.meterSet.Dynamic, "the counting set must be dynamic")
|
||||
require.Equal(t, nftables.TypeIPAddr, enc.meterSet.KeyType)
|
||||
|
||||
var ds *expr.Dynset
|
||||
for _, e := range enc.exprs {
|
||||
if v, ok := e.(*expr.Dynset); ok {
|
||||
ds = v
|
||||
}
|
||||
}
|
||||
require.NotNil(t, ds, "expected a dynset statement")
|
||||
require.Equal(t, enc.meterSet.Name, ds.SetName)
|
||||
require.Len(t, ds.Exprs, 1)
|
||||
cl, ok := ds.Exprs[0].(*expr.Connlimit)
|
||||
require.True(t, ok, "the dynset must carry a connlimit")
|
||||
require.Equal(t, uint32(10), cl.Count)
|
||||
require.Equal(t, uint32(expr.NFT_CONNLIMIT_F_INV), cl.Flags, "the count must be an over-limit test")
|
||||
|
||||
// The IPv6 form keys on the v6 source address instead.
|
||||
enc6 := nftEncodeRule(t, f, &Rule{Family: IPv6, Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop})
|
||||
require.Equal(t, nftables.TypeIP6Addr, enc6.meterSet.KeyType)
|
||||
require.NotEqual(t, enc.meterSet.Name, enc6.meterSet.Name, "each family counts in its own set")
|
||||
|
||||
// The set name is derived from rule identity, so re-adding the same rule
|
||||
// reuses its counting state while a different rule gets its own.
|
||||
require.Equal(t, enc.meterSet.Name, nftEncodeRule(t, f, r).meterSet.Name)
|
||||
other := &Rule{Family: IPv4, Proto: TCP, Port: 443, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop}
|
||||
require.NotEqual(t, enc.meterSet.Name, nftEncodeRule(t, f, other).meterSet.Name)
|
||||
|
||||
// A family-agnostic per-source limit has no single row: the caller must fan
|
||||
// it out first, so validateRule rejects it.
|
||||
require.Error(t, f.validateRule(&Rule{Proto: TCP, Port: 22, ConnLimit: &ConnLimit{Count: 10, PerSource: true}, Action: Drop}),
|
||||
"a FamilyAny per-source limit must be expanded first")
|
||||
require.True(t, f.perSourceFamilySplit(&Rule{Proto: TCP, ConnLimit: &ConnLimit{Count: 1, PerSource: true}, Action: Drop}))
|
||||
}
|
||||
|
||||
// A both-transports rule stays a single row: the protocol is an anonymous set of
|
||||
// the two transport numbers, and the ports match through the shared offsets.
|
||||
func TestNFTTCPUDPSingleRow(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
enc := nftEncodeRule(t, f, &Rule{Proto: TCPUDP, Port: 53, Action: Accept})
|
||||
|
||||
require.Len(t, enc.anonSets, 1, "the protocol pair rides one anonymous set")
|
||||
set := enc.anonSets[0]
|
||||
require.Equal(t, nftables.TypeInetProto, set.set.KeyType)
|
||||
require.True(t, set.set.Anonymous && set.set.Constant)
|
||||
require.ElementsMatch(t,
|
||||
[][]byte{{6}, {17}},
|
||||
[][]byte{set.elements[0].Key, set.elements[1].Key},
|
||||
"the set must hold tcp and udp")
|
||||
|
||||
require.Equal(t, []string{"meta", "lookup", "payload", "cmp", "counter", "verdict"}, nftExprKinds(enc.exprs))
|
||||
}
|
||||
|
||||
// The library's connection-state bits and the kernel's do not share an ordering,
|
||||
// so the mapping between them is explicit and must stay symmetric.
|
||||
func TestNFTConnStateMask(t *testing.T) {
|
||||
f := new(NFT)
|
||||
for _, c := range []struct {
|
||||
state ConnState
|
||||
mask uint32
|
||||
}{
|
||||
{StateNew, 0x08},
|
||||
{StateEstablished, 0x02},
|
||||
{StateRelated, 0x04},
|
||||
{StateInvalid, 0x01},
|
||||
{StateEstablished | StateRelated, 0x06},
|
||||
{StateNew | StateEstablished | StateRelated | StateInvalid, 0x0f},
|
||||
} {
|
||||
require.Equal(t, c.mask, f.ctStateMask(c.state), "encoding %v", c.state.Strings())
|
||||
got, ok := f.connStateForMask(c.mask)
|
||||
require.True(t, ok, "decoding mask %#x", c.mask)
|
||||
require.Equal(t, c.state, got, "decoding mask %#x", c.mask)
|
||||
}
|
||||
|
||||
// A mask carrying a state the model cannot hold (untracked) is rejected
|
||||
// rather than narrowed to the states that did map, so the row stays opaque.
|
||||
_, ok := f.connStateForMask(0x40)
|
||||
require.False(t, ok, "an unmodelled ct state must not decode")
|
||||
_, ok = f.connStateForMask(0x02 | 0x40)
|
||||
require.False(t, ok, "a partly unmodelled ct state mask must not decode")
|
||||
}
|
||||
|
||||
// nft shortens a byte-aligned prefix to a narrower payload load rather than
|
||||
// masking, so the decoder must accept that form as well as the masked one this
|
||||
// backend writes.
|
||||
func TestNFTShortenedPrefixDecodes(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
|
||||
for _, c := range []struct {
|
||||
fam Family
|
||||
offset uint32
|
||||
length uint32
|
||||
data []byte
|
||||
want string
|
||||
}{
|
||||
{IPv4, 12, 1, []byte{10}, "10.0.0.0/8"},
|
||||
{IPv4, 12, 2, []byte{192, 168}, "192.168.0.0/16"},
|
||||
{IPv6, 8, 4, []byte{0x20, 0x01, 0x0d, 0xb8}, "2001:db8::/32"},
|
||||
} {
|
||||
nr := &nftables.Rule{Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyNFPROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{f.nfprotoByte(c.fam)}},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: c.offset, Len: c.length},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: c.data},
|
||||
&expr.Verdict{Kind: expr.VerdictDrop},
|
||||
}}
|
||||
got, err := f.UnmarshalRule(nr, "input", newStagedSetReader(), f.tableRef())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, c.want, got.Source)
|
||||
}
|
||||
}
|
||||
|
||||
// A row carrying a construct the Rule model cannot hold must fail to decode, so
|
||||
// the caller keeps it as an opaque slot instead of misrepresenting it.
|
||||
func TestNFTUnmodelledRowsRejected(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
sets := newStagedSetReader()
|
||||
|
||||
cases := map[string][]expr.Any{
|
||||
"unknown l4proto": {
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{2}},
|
||||
&expr.Verdict{Kind: expr.VerdictAccept},
|
||||
},
|
||||
"jump verdict": {
|
||||
&expr.Verdict{Kind: expr.VerdictJump, Chain: "other"},
|
||||
},
|
||||
"unmodelled expression": {
|
||||
&expr.Quota{Bytes: 100},
|
||||
&expr.Verdict{Kind: expr.VerdictAccept},
|
||||
},
|
||||
"address without a family guard": {
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{1, 2, 3, 4}},
|
||||
&expr.Verdict{Kind: expr.VerdictAccept},
|
||||
},
|
||||
"under-limit connection count": {
|
||||
&expr.Connlimit{Count: 5},
|
||||
&expr.Verdict{Kind: expr.VerdictAccept},
|
||||
},
|
||||
"no verdict at all": {
|
||||
&expr.Counter{},
|
||||
},
|
||||
}
|
||||
for name, exprs := range cases {
|
||||
_, err := f.UnmarshalRule(&nftables.Rule{Exprs: exprs}, "input", sets, f.tableRef())
|
||||
require.Error(t, err, "expected %s to stay opaque", name)
|
||||
}
|
||||
}
|
||||
|
||||
// An ip or ip6 table is itself the family qualifier, so an operator's rules in
|
||||
// one carry no nfproto match. Reading those rows against the table's family is
|
||||
// what keeps a foreign address match — a literal or an address set — out of the
|
||||
// opaque bucket, and what reports the one family the row can ever match.
|
||||
func TestNFTForeignTableFamilyFromTable(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
sets := newStagedSetReader()
|
||||
|
||||
for _, c := range []struct {
|
||||
name string
|
||||
tbl *nftables.Table
|
||||
offset uint32
|
||||
length uint32
|
||||
data []byte
|
||||
want *Rule
|
||||
}{
|
||||
{
|
||||
name: "ip table address match", offset: 12, length: 4, data: []byte{192, 0, 2, 10},
|
||||
tbl: &nftables.Table{Family: nftables.TableFamilyIPv4, Name: "filter"},
|
||||
want: &Rule{Family: IPv4, Source: "192.0.2.10", Action: Accept},
|
||||
},
|
||||
{
|
||||
name: "ip6 table address match", offset: 8, length: 16,
|
||||
data: net.ParseIP("2001:db8::1").To16(),
|
||||
tbl: &nftables.Table{Family: nftables.TableFamilyIPv6, Name: "filter"},
|
||||
want: &Rule{Family: IPv6, Source: "2001:db8::1", Action: Accept},
|
||||
},
|
||||
} {
|
||||
nr := &nftables.Rule{Exprs: []expr.Any{
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: c.offset, Len: c.length},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: c.data},
|
||||
&expr.Verdict{Kind: expr.VerdictAccept},
|
||||
}}
|
||||
got, err := f.UnmarshalRule(nr, "input", sets, c.tbl)
|
||||
require.NoError(t, err, c.name)
|
||||
require.Equal(t, c.want.Family, got.Family, c.name)
|
||||
require.Equal(t, c.want.Source, got.Source, c.name)
|
||||
}
|
||||
|
||||
// A set reference is the same case: the set's own family is not needed to read
|
||||
// the row, because the table already pinned it.
|
||||
nr := &nftables.Rule{Exprs: []expr.Any{
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseNetworkHeader, Offset: 12, Len: 4},
|
||||
&expr.Lookup{SourceRegister: 1, SetName: "allowlist"},
|
||||
&expr.Verdict{Kind: expr.VerdictAccept},
|
||||
}}
|
||||
got, err := f.UnmarshalRule(nr, "input", sets, &nftables.Table{Family: nftables.TableFamilyIPv4, Name: "filter"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IPv4, got.Family)
|
||||
require.Equal(t, "allowlist", got.Source)
|
||||
|
||||
// A row with no family evidence at all still takes the table's family: an ip
|
||||
// table can only ever match IPv4.
|
||||
nr = &nftables.Rule{Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{6}},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: binaryutil.BigEndian.PutUint16(8123)},
|
||||
&expr.Verdict{Kind: expr.VerdictAccept},
|
||||
}}
|
||||
got, err = f.UnmarshalRule(nr, "input", sets, &nftables.Table{Family: nftables.TableFamilyIPv4, Name: "filter"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, IPv4, got.Family)
|
||||
|
||||
// The backend's own inet table settles nothing, so an unguarded address match
|
||||
// there stays opaque (see TestNFTUnmodelledRowsRejected).
|
||||
fam, ok := f.familyForTable(f.tableRef())
|
||||
require.False(t, ok)
|
||||
require.Equal(t, FamilyAny, fam)
|
||||
}
|
||||
|
||||
// nftables reports the default burst of 5 on every limit even when none was
|
||||
// asked for, so the default must read back as unset or a rule never matches its
|
||||
// own read-back and Sync re-adds it forever.
|
||||
func TestNFTRateBurstDefaultNormalized(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
|
||||
got := nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 5, Unit: PerMinute}, Action: Accept})
|
||||
require.NotNil(t, got.RateLimit)
|
||||
require.Zero(t, got.RateLimit.Burst, "the netfilter default burst must read as unset")
|
||||
|
||||
// An explicit burst of something other than the default survives intact.
|
||||
got = nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, RateLimit: &RateLimit{Rate: 5, Unit: PerHour, Burst: 20}, Action: Accept})
|
||||
require.Equal(t, uint(20), got.RateLimit.Burst)
|
||||
require.Equal(t, PerHour, got.RateLimit.Unit)
|
||||
}
|
||||
|
||||
// The counters a listed rule carries are reported onto the rule but are not part
|
||||
// of its identity.
|
||||
func TestNFTCounters(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
nr := &nftables.Rule{Exprs: []expr.Any{
|
||||
&expr.Meta{Key: expr.MetaKeyL4PROTO, Register: 1},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: []byte{6}},
|
||||
&expr.Payload{DestRegister: 1, Base: expr.PayloadBaseTransportHeader, Offset: 2, Len: 2},
|
||||
&expr.Cmp{Op: expr.CmpOpEq, Register: 1, Data: binaryutil.BigEndian.PutUint16(22)},
|
||||
&expr.Counter{Packets: 42, Bytes: 336},
|
||||
&expr.Verdict{Kind: expr.VerdictAccept},
|
||||
}}
|
||||
r, err := f.UnmarshalRule(nr, "input", newStagedSetReader(), f.tableRef())
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, uint64(42), r.Packets)
|
||||
require.Equal(t, uint64(336), r.Bytes)
|
||||
require.True(t, r.EqualBase(&Rule{Proto: TCP, Port: 22, Action: Accept}, true),
|
||||
"counters must not be part of rule identity: %+v", r)
|
||||
}
|
||||
|
||||
// A comment and a log prefix ride in user data and a log expression rather than
|
||||
// a quoted string, so characters the textual interface could never carry now
|
||||
// round-trip verbatim.
|
||||
func TestNFTCommentAndPrefixVerbatim(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
|
||||
for _, s := range []string{`has "quotes"`, "has # hash", " leading and trailing ", `back\slash`} {
|
||||
got := nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, Comment: s, Action: Accept})
|
||||
require.Equal(t, s, got.Comment, "comment must survive verbatim")
|
||||
|
||||
got = nftRoundTrip(t, f, &Rule{Proto: TCP, Port: 22, Log: true, LogPrefix: s, Action: Accept})
|
||||
require.Equal(t, s, got.LogPrefix, "log prefix must survive verbatim")
|
||||
}
|
||||
|
||||
// Both are length-capped by nftables, so an over-long value is rejected up
|
||||
// front rather than being silently truncated by the kernel.
|
||||
require.Error(t, f.validateRule(&Rule{Action: Accept, Comment: string(make([]byte, nftCommentMax+1))}))
|
||||
require.Error(t, f.validateRule(&Rule{Action: Accept, Log: true, LogPrefix: string(make([]byte, nftLogPrefixMax+1))}))
|
||||
}
|
||||
|
||||
// Shapes nftables cannot express must be rejected by validateRule, which the
|
||||
// encoding entry points run, rather than producing a rule that would not read
|
||||
// back as written.
|
||||
func TestNFTMarshalRejections(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
|
||||
cases := map[string]*Rule{
|
||||
"port without a protocol": {Port: 80, Proto: ProtocolAny, Action: Accept},
|
||||
"input interface on an output rule": {Direction: DirOutput, InInterface: "eth0", Action: Accept},
|
||||
"output interface on an input rule": {OutInterface: "eth0", Action: Accept},
|
||||
"no action": {Proto: TCP, Port: 22},
|
||||
}
|
||||
for name, r := range cases {
|
||||
require.Error(t, f.validateRule(r), "expected %s to be rejected", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Every NAT kind must survive an encode/decode round trip.
|
||||
func TestNFTNATRoundTrip(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
|
||||
rules := []*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: DNAT, Proto: UDP, Ports: []PortRange{{Start: 5000, End: 5100}}, ToAddress: "192.168.1.2"},
|
||||
{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "2001:db8::1", ToPort: 8080},
|
||||
{Kind: SNAT, Proto: TCP, Interface: "eth0", ToAddress: "1.2.3.4"},
|
||||
{Kind: SNAT, Interface: "eth0", ToAddress: "2001:db8::1"},
|
||||
{Kind: Redirect, Proto: TCP, Port: 80, ToPort: 8080},
|
||||
{Kind: Masquerade, Interface: "eth0"},
|
||||
{Kind: Masquerade},
|
||||
{Kind: DNAT, Family: IPv4, Source: "10.0.0.0/8", Proto: TCP, Port: 80, ToAddress: "192.168.1.2"},
|
||||
{Kind: SNAT, Proto: SCTP, Interface: "eth0", ToAddress: "1.2.3.4"},
|
||||
}
|
||||
for _, r := range rules {
|
||||
enc, err := f.MarshalNATRule(r)
|
||||
require.NoError(t, err, "failed to marshal %+v", *r)
|
||||
got, err := f.UnmarshalNATRule(
|
||||
&nftables.Rule{Exprs: enc.exprs}, newStagedSetReader(enc), f.tableRef())
|
||||
require.NoError(t, err, "failed to decode %+v", *r)
|
||||
require.True(t, got.EqualBase(r), "round-trip mismatch: input %+v, output %+v", *r, *got)
|
||||
}
|
||||
|
||||
// DNAT lands in prerouting, SNAT in postrouting.
|
||||
enc, err := f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "1.2.3.4"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "prerouting", enc.chain)
|
||||
enc, err = f.MarshalNATRule(&NATRule{Kind: SNAT, ToAddress: "1.2.3.4"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "postrouting", enc.chain)
|
||||
}
|
||||
|
||||
// A family-agnostic NAT rule is one unpinned row covering both families, so it
|
||||
// must not acquire a family guard on the way out.
|
||||
func TestNFTFamilyAnyNATIsDualStack(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
enc, err := f.MarshalNATRule(&NATRule{Kind: Masquerade, Interface: "eth0"})
|
||||
require.NoError(t, err)
|
||||
for _, e := range enc.exprs {
|
||||
if m, ok := e.(*expr.Meta); ok {
|
||||
require.NotEqual(t, expr.MetaKeyNFPROTO, m.Key,
|
||||
"a family-agnostic masquerade must stay unpinned")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// nftables' snat expression carries no port, so a source-port translation is a
|
||||
// shape this backend genuinely cannot express and must report as unsupported
|
||||
// rather than silently dropping the port.
|
||||
func TestNFTNATRejections(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
|
||||
err := f.validateNAT(&NATRule{Kind: SNAT, Proto: TCP, ToAddress: "1.2.3.4", ToPort: 8080})
|
||||
require.ErrorIs(t, err, ErrUnsupportedNAT, "snat cannot translate the source port")
|
||||
|
||||
// A CIDR translation target has no single address to rewrite to; the encoder
|
||||
// rejects it while building the nat expression.
|
||||
_, err = f.MarshalNATRule(&NATRule{Kind: DNAT, Proto: TCP, Port: 80, ToAddress: "10.0.0.0/8"})
|
||||
require.Error(t, err, "a translation target must be a single address")
|
||||
}
|
||||
|
||||
// An address set's entries are stored as element keys — discrete addresses in a
|
||||
// plain set, boundary markers in an interval set — and must be rendered back as
|
||||
// the CIDR or range they came from.
|
||||
func TestNFTAddressSetElements(t *testing.T) {
|
||||
f := new(NFT)
|
||||
// A discrete set holds one element per address.
|
||||
elems, err := f.setElements("1.2.3.4", false)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, elems, 1)
|
||||
require.Equal(t, []byte{1, 2, 3, 4}, elems[0].Key)
|
||||
|
||||
// A range cannot be stored in a discrete set.
|
||||
_, err = f.setElements("10.0.0.0/8", false)
|
||||
require.Error(t, err)
|
||||
|
||||
// An interval set stores the inclusive start and an exclusive end marker.
|
||||
elems, err = f.setElements("10.0.0.0/8", true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, elems, 2)
|
||||
require.Equal(t, []byte{10, 0, 0, 0}, elems[0].Key)
|
||||
require.Equal(t, []byte{11, 0, 0, 0}, elems[1].Key)
|
||||
require.True(t, elems[1].IntervalEnd)
|
||||
|
||||
// Round-trip each entry form through the element encoding and back.
|
||||
for _, c := range []struct {
|
||||
entry string
|
||||
interval bool
|
||||
}{
|
||||
{"1.2.3.4", false},
|
||||
{"2001:db8::1", false},
|
||||
{"10.0.0.0/8", true},
|
||||
{"192.168.1.0/24", true},
|
||||
{"2001:db8::/32", true},
|
||||
{"10.0.0.1-10.0.0.9", true},
|
||||
} {
|
||||
els, eerr := f.setElements(c.entry, c.interval)
|
||||
require.NoError(t, eerr, "encoding %q", c.entry)
|
||||
got := f.addressSetEntries(&nftSetContents{
|
||||
set: &nftables.Set{Interval: c.interval},
|
||||
elements: els,
|
||||
})
|
||||
require.Equal(t, []string{c.entry}, got, "round-trip of %q", c.entry)
|
||||
}
|
||||
}
|
||||
|
||||
// A dynamic set is connection-limit counting state and an anonymous set is a
|
||||
// rule's own inline literal; neither is a caller-managed address set.
|
||||
func TestNFTAddressSetKeyTypes(t *testing.T) {
|
||||
f := new(NFT)
|
||||
kt, err := f.setKeyType(IPv4)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, nftables.TypeIPAddr, kt)
|
||||
require.Equal(t, IPv4, f.familyForKeyType(kt))
|
||||
|
||||
kt, err = f.setKeyType(IPv6)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, nftables.TypeIP6Addr, kt)
|
||||
require.Equal(t, IPv6, f.familyForKeyType(kt))
|
||||
|
||||
// An nftables set carries a single address type, so an unspecified family
|
||||
// resolves to IPv4 rather than failing.
|
||||
kt, err = f.setKeyType(FamilyAny)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, nftables.TypeIPAddr, kt)
|
||||
}
|
||||
|
||||
// An interface name is compared against a fixed-width NUL-padded buffer, while a
|
||||
// trailing '*' makes it a prefix match against just the leading characters.
|
||||
func TestNFTInterfaceEncoding(t *testing.T) {
|
||||
f := new(NFT)
|
||||
exact := f.ifnameBytes("eth0")
|
||||
require.Len(t, exact, 16, "an exact interface match is fixed width")
|
||||
require.Equal(t, "eth0", f.ifnameString(exact))
|
||||
|
||||
wild := f.ifnameBytes("eth*")
|
||||
require.Equal(t, []byte("eth"), wild, "a wildcard compares only the prefix")
|
||||
require.Equal(t, "eth*", f.ifnameString(wild))
|
||||
}
|
||||
|
||||
// The table name is derived from the rule prefix and must be a valid nftables
|
||||
// identifier, which cannot begin with a digit.
|
||||
func TestNFTSanitizeName(t *testing.T) {
|
||||
require.Equal(t, "fw_1fw", sanitizeNFTName("1fw"))
|
||||
require.Equal(t, "go_firewall", sanitizeNFTName(""))
|
||||
require.Equal(t, "go_firewall", sanitizeNFTName("!!!"))
|
||||
require.Equal(t, "my_fw", sanitizeNFTName("my-fw"))
|
||||
require.Equal(t, "my_fw", sanitizeNFTName("my.fw"))
|
||||
}
|
||||
|
||||
// A concrete-family removal of a merged row must keep the coverage the caller
|
||||
// never named, across both the family and the transport axis.
|
||||
func TestNFTSplitMergedRowTwoAxes(t *testing.T) {
|
||||
f := &NFT{table: "go_firewall"}
|
||||
|
||||
// A single row covering both families and both transports.
|
||||
merged := &Rule{Proto: TCPUDP, Port: 53, Action: Accept}
|
||||
// Removing only the IPv4 TCP half leaves three cells behind.
|
||||
target := &Rule{Family: IPv4, Proto: TCP, Port: 53, Action: Accept}
|
||||
remainder := splitMergedRow(merged, target)
|
||||
require.NotEmpty(t, remainder, "removing one cell must leave the rest in place")
|
||||
|
||||
// Every remainder must still be expressible, or the removal would fail
|
||||
// halfway through and drop coverage it meant to keep.
|
||||
for _, r := range remainder {
|
||||
_, err := f.MarshalRule(r)
|
||||
require.NoError(t, err, "remainder %+v must be expressible", *r)
|
||||
}
|
||||
}
|
||||
831
pf_test.go
Normal file
831
pf_test.go
Normal file
|
|
@ -0,0 +1,831 @@
|
|||
//go:build darwin || freebsd
|
||||
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 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 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: "203.0.113.10", Port: 4789, Proto: TCP, Action: Accept},
|
||||
{Direction: DirOutput, Family: IPv4, Destination: "203.0.113.10", 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.
|
||||
require.Error(t, fw.validateRule(&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}),
|
||||
"expected a port with no protocol to be rejected")
|
||||
|
||||
// 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.
|
||||
require.NoError(t, fw.validateRule(&Rule{Proto: TCP, SourcePort: 1024, Action: Accept}),
|
||||
"a single source port is valid")
|
||||
require.NoError(t, fw.validateRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 1024, End: 2048}}, Action: Accept}),
|
||||
"a source-port range is valid")
|
||||
require.Error(t, fw.validateRule(&Rule{Proto: TCP, SourcePorts: []PortRange{{Start: 80, End: 80}, {Start: 443, End: 443}}, Action: Accept}),
|
||||
"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)
|
||||
}
|
||||
|
||||
// A discrete destination-port list has no single-row pf form: pfctl expands
|
||||
// `port { 80 443 }` into one rule per port on load, so validateRule rejects it
|
||||
// and AddRule fans the list into one row per port with expandPorts instead.
|
||||
require.ErrorIs(t, fw.validateRule(&Rule{Proto: TCP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}),
|
||||
ErrUnsupported, "a destination-port list must be rejected under pf")
|
||||
|
||||
// pf cannot express a connection-state match in this model.
|
||||
require.Error(t, fw.validateRule(&Rule{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}),
|
||||
"expected a state match to be rejected under pf")
|
||||
|
||||
// Interface/direction mismatches must be rejected.
|
||||
require.Error(t, fw.validateRule(&Rule{Direction: DirOutput, InInterface: "em0", Action: Accept}),
|
||||
"expected an input interface on an output rule to be rejected")
|
||||
require.Error(t, fw.validateRule(&Rule{OutInterface: "em0", Action: Accept}),
|
||||
"expected an output interface on an input rule to be rejected")
|
||||
|
||||
// pf has no distinct forward chain, so a forward rule is rejected with the
|
||||
// ErrUnsupportedForward sentinel.
|
||||
require.ErrorIs(t, fw.validateRule(&Rule{Direction: DirForward, Proto: TCP, Port: 22, Action: Accept}),
|
||||
ErrUnsupportedForward, "a forward rule must be rejected")
|
||||
}
|
||||
|
||||
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.
|
||||
require.Error(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, Log: true, LogPrefix: "x"}),
|
||||
"expected pf to reject a log prefix")
|
||||
require.Error(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Drop, RateLimit: &RateLimit{Rate: 1, Unit: PerSecond}}),
|
||||
"expected pf to reject a limit on a non-accept rule")
|
||||
require.Error(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept, ConnLimit: &ConnLimit{Count: 5, PerSource: false}}),
|
||||
"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.
|
||||
require.Error(t, fw.validateNAT(&NATRule{Kind: Redirect, Family: IPv4, Proto: TCP, Port: 80, ToPort: 8080}),
|
||||
"expected pf to reject a redirect")
|
||||
require.Error(t, fw.validateNAT(&NATRule{Kind: Masquerade, Family: IPv4}),
|
||||
"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"}
|
||||
require.ErrorIs(t, fw.validateRule(&Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept,
|
||||
RateLimit: &RateLimit{Rate: 10, Unit: PerMinute, Burst: 5}}),
|
||||
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 rule written per family lives in two anchor rows. RemoveRule/MoveRule must
|
||||
// locate both from a FamilyAny target with EqualForRemoval, not the family-strict
|
||||
// Equal — which matches neither, so the port stays open. The pf remove must not
|
||||
// no-op on a family-agnostic target.
|
||||
func TestPFFamilyAnyTargetMatchesBothTwins(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)
|
||||
|
||||
// The two rows cover the family-agnostic rule between them.
|
||||
target := &Rule{Family: FamilyAny, Proto: TCP, Port: 22, Action: Accept, Direction: DirInput}
|
||||
require.True(t, target.CoveredBy([]*Rule{v4, v6}))
|
||||
|
||||
// The family-strict matcher finds neither physical row.
|
||||
require.False(t, target.Equal(v4, true))
|
||||
require.False(t, target.Equal(v6, true))
|
||||
|
||||
// EqualForRemoval finds both, so RemoveRule clears both anchor rows and MoveRule
|
||||
// can locate the rule.
|
||||
require.True(t, v4.EqualForRemoval(target, true))
|
||||
require.True(t, v6.EqualForRemoval(target, true))
|
||||
}
|
||||
|
||||
// MoveRule must relocate every physical row a target covers, not just the first.
|
||||
// Moving only the first row of a v4/v6 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 covered rows as a block.
|
||||
func TestPFReorderRowsFamilyPair(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 reports four rules, numbered 1..4.
|
||||
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 both A rows past B. Once A is pulled out, two rows remain, so position 3 is
|
||||
// past the end and appends.
|
||||
out, moved := fw.reorderRows(rules, raw, mk(FamilyAny, 22), 3)
|
||||
require.True(t, moved)
|
||||
require.Equal(t, []string{"B_v4", "B_v6", "A_v4", "A_v6"}, out,
|
||||
"both rows the target covers must move together, landing after B")
|
||||
|
||||
// Move both B rows up to the front.
|
||||
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.
|
||||
// Three rows remain after A_v4 is pulled out, so position 4 appends.
|
||||
out, moved = fw.reorderRows(rules, raw, mk(IPv4, 22), 4)
|
||||
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)
|
||||
}
|
||||
|
||||
// pf's nat translation maps to an address only; it has no source-port form, so a
|
||||
// SNAT rule carrying a ToPort is rejected here rather than dropped silently. The
|
||||
// shared validate accepts the shape, since iptables emits it as
|
||||
// --to-source addr:port, so the rejection lives in pf's own validateNAT.
|
||||
func TestPFMarshalRejectsSNATPort(t *testing.T) {
|
||||
f := &PF{anchor: "go_firewall"}
|
||||
require.ErrorIs(t, f.validateNAT(&NATRule{Kind: SNAT, Proto: TCP, ToAddress: "192.0.2.1", ToPort: 80}),
|
||||
ErrUnsupportedNAT, "a source-port SNAT must be rejected")
|
||||
// A portless SNAT still marshals.
|
||||
require.NoError(t, f.validateNAT(&NATRule{Kind: SNAT, ToAddress: "192.0.2.1"}))
|
||||
_, err := f.MarshalNATRule(&NATRule{Kind: SNAT, ToAddress: "192.0.2.1"})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// expandProtocols fans a TCPUDP rule into a tcp rule and a udp rule, each of which
|
||||
// marshals to a valid concrete-protocol pf line and round-trips. pf has no both-
|
||||
// transports form, so the write path fans out before the row-level marshaller.
|
||||
func TestPFExpandProtocolsMarshal(t *testing.T) {
|
||||
f := &PF{anchor: "go_firewall"}
|
||||
subs := expandProtocols(&Rule{Family: IPv4, Proto: TCPUDP, Port: 22, Action: Accept})
|
||||
require.Len(t, subs, 2, "TCPUDP must fan into two concrete-transport rules")
|
||||
require.Equal(t, TCP, subs[0].Proto)
|
||||
require.Equal(t, UDP, subs[1].Proto)
|
||||
for _, sub := range subs {
|
||||
line, err := f.MarshalRule(sub)
|
||||
require.NoError(t, err, "each fanned transport must marshal")
|
||||
parsed, err := f.UnmarshalRule(line)
|
||||
require.NoError(t, err, "line %q", line)
|
||||
require.True(t, parsed.Equal(sub, true), "round-trip mismatch for %q", line)
|
||||
}
|
||||
}
|
||||
|
||||
// Every modeled anchor row is its own rule, so filterAnchors is the identity over
|
||||
// them. An opaque (nil) row — an anchor line pf keeps but this backend cannot model —
|
||||
// occupies a physical slot without consuming a logical position, so the rules after
|
||||
// it must still map to their own physical rows.
|
||||
func TestPFFilterAnchorsSkipOpaqueRows(t *testing.T) {
|
||||
fw := new(PF)
|
||||
mk := func(proto Protocol, port uint16) *Rule {
|
||||
return &Rule{Family: IPv4, Proto: proto, Port: port, Action: Accept}
|
||||
}
|
||||
rules := []*Rule{mk(TCP, 22), mk(UDP, 22), mk(TCP, 80), mk(UDP, 80)}
|
||||
require.Equal(t, []int{0, 1, 2, 3}, fw.filterAnchors(rules),
|
||||
"every modeled row is its own anchor")
|
||||
require.Equal(t, 1, fw.logicalInsertIndex(fw.filterAnchors(rules), len(rules), 2))
|
||||
require.Equal(t, 4, fw.logicalInsertIndex(fw.filterAnchors(rules), len(rules), 5),
|
||||
"a position past the last logical rule appends")
|
||||
|
||||
// An unmodeled line sits at physical row 1, shifting the rows after it.
|
||||
withOpaque := []*Rule{mk(TCP, 22), nil, mk(UDP, 22), mk(TCP, 80)}
|
||||
anchors := fw.filterAnchors(withOpaque)
|
||||
require.Equal(t, []int{0, 2, 3}, anchors, "the opaque row consumes no logical position")
|
||||
require.Equal(t, 2, fw.logicalInsertIndex(anchors, len(withOpaque), 2),
|
||||
"logical rule 2 lives at physical row 2, past the opaque line")
|
||||
}
|
||||
|
||||
// reorderRows must relocate every physical row a target covers together: pfctl stores
|
||||
// tcp and udp as separate rows, so a caller moving a TCPUDP rule (matched via
|
||||
// EqualForRemoval's protocol coverage) moves both. A concrete-transport target moves
|
||||
// only its own transport row, never the twin's.
|
||||
func TestPFReorderRowsTransportPair(t *testing.T) {
|
||||
fw := new(PF)
|
||||
mk := func(proto Protocol, port uint16) *Rule {
|
||||
return &Rule{Family: IPv4, Proto: proto, Port: port, Action: Accept}
|
||||
}
|
||||
rules := []*Rule{mk(TCP, 22), mk(UDP, 22), mk(TCP, 80), mk(UDP, 80)}
|
||||
raw := []string{"A_tcp", "A_udp", "B_tcp", "B_udp"}
|
||||
|
||||
// Move both A rows past B. Two rows remain once A is pulled out, so position 3
|
||||
// appends.
|
||||
out, moved := fw.reorderRows(rules, raw, mk(TCPUDP, 22), 3)
|
||||
require.True(t, moved)
|
||||
require.Equal(t, []string{"B_tcp", "B_udp", "A_tcp", "A_udp"}, out,
|
||||
"both transport rows the target covers must move together, landing after B")
|
||||
|
||||
// A concrete-transport target relocates only its own transport row. Three rows
|
||||
// remain, so position 4 appends.
|
||||
out, moved = fw.reorderRows(rules, raw, mk(TCP, 22), 4)
|
||||
require.True(t, moved)
|
||||
require.Equal(t, []string{"A_udp", "B_tcp", "B_udp", "A_tcp"}, out)
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
|
||||
// MarshalRule must reject a destination-port list (as it already does for a
|
||||
// source-port list): pfctl expands a discrete port list (`port { 80 443 }`)
|
||||
// into one rule per port on load, so a list has no single-row form. AddRule
|
||||
// fans a list into one row per port with expandPorts, so the row-level
|
||||
// marshaller only ever sees a single spec. A single contiguous range 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.validateRule(&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")
|
||||
}
|
||||
|
||||
// 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])
|
||||
}
|
||||
|
||||
// A verbose listing prints a counters continuation under every rule, including an
|
||||
// unmodeled one held as an opaque nil row; attaching those counters must not
|
||||
// dereference the nil slot.
|
||||
func TestPFVerboseCountersAfterUnmodeledRow(t *testing.T) {
|
||||
fw := &PF{anchor: "go_firewall"}
|
||||
out := []string{
|
||||
"pass in quick inet proto tcp from any to any port > 1023 keep state",
|
||||
" [ Evaluations: 100 Packets: 40 Bytes: 2400 States: 2 ]",
|
||||
"pass in quick inet proto udp from any to any port = 53 keep state",
|
||||
" [ Evaluations: 5 Packets: 5 Bytes: 300 States: 0 ]",
|
||||
}
|
||||
rules, raw := fw.parseAnchorRules(out)
|
||||
require.Len(t, rules, 2)
|
||||
require.Len(t, raw, 2)
|
||||
require.Nil(t, rules[0], "the unmodeled line stays an opaque row")
|
||||
require.NotNil(t, rules[1])
|
||||
require.EqualValues(t, 5, rules[1].Packets, "counters still attach to the modeled rule")
|
||||
}
|
||||
|
||||
// A portable backup may carry DirAny or TCPUDP rules captured from a backend that
|
||||
// stores them as one row; marshalExpanded (behind Restore) must fan them out
|
||||
// exactly as the add paths do rather than fail or drop a half.
|
||||
func TestPFMarshalExpandedMergedRules(t *testing.T) {
|
||||
fw := &PF{anchor: "go_firewall"}
|
||||
merged := &Rule{Direction: DirAny, Family: FamilyAny, Proto: TCPUDP, Port: 53, Action: Accept}
|
||||
lines, err := fw.marshalExpanded([]*Rule{merged})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, lines, 4, "DirAny x TCPUDP must expand to four rows")
|
||||
var in, out, tcp, udp int
|
||||
for _, l := range lines {
|
||||
if strings.Contains(l, "pass in ") {
|
||||
in++
|
||||
}
|
||||
if strings.Contains(l, "pass out ") {
|
||||
out++
|
||||
}
|
||||
if strings.Contains(l, "proto tcp") {
|
||||
tcp++
|
||||
}
|
||||
if strings.Contains(l, "proto udp") {
|
||||
udp++
|
||||
}
|
||||
}
|
||||
require.Equal(t, 2, in)
|
||||
require.Equal(t, 2, out)
|
||||
require.Equal(t, 2, tcp)
|
||||
require.Equal(t, 2, udp)
|
||||
|
||||
// A concrete rule passes through unchanged.
|
||||
lines, err = fw.marshalExpanded([]*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, lines, 1)
|
||||
}
|
||||
|
||||
// A nat source-port match and a dynamic-target port pool have no faithful model;
|
||||
// both must error so the line is preserved as an opaque row rather than mis-read.
|
||||
func TestPFNATParserKeepsUnmodeledShapesOpaque(t *testing.T) {
|
||||
fw := &PF{anchor: "go_firewall"}
|
||||
opaque := []string{
|
||||
"nat on em0 inet from 10.0.0.0/8 port = 500 to any -> 192.0.2.1",
|
||||
"nat on em0 inet from 10.0.0.0/8 to any -> (em0) port 1024:65535",
|
||||
}
|
||||
for _, line := range opaque {
|
||||
_, err := fw.UnmarshalNATRule(line)
|
||||
require.Error(t, err, "line must stay opaque: %s", line)
|
||||
}
|
||||
// The plain forms still parse.
|
||||
r, err := fw.UnmarshalNATRule("nat on em0 inet from 10.0.0.0/8 to any -> (em0)")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, Masquerade, r.Kind)
|
||||
r, err = fw.UnmarshalNATRule("rdr on em0 inet proto tcp from any to any port = 8080 -> 10.0.0.5 port 80")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DNAT, r.Kind)
|
||||
require.EqualValues(t, 8080, r.Port)
|
||||
require.EqualValues(t, 80, r.ToPort)
|
||||
}
|
||||
|
||||
// insertRows must place every cell of a merged rule in one pass over the anchor's
|
||||
// rows, so a rule spanning directions and transports is written by a single anchor
|
||||
// load rather than one read-rewrite cycle per cell. The cells go in at the
|
||||
// requested index in the order ruleCells fans them out, and the surrounding rows
|
||||
// keep their positions.
|
||||
func TestPFInsertRowsPlacesEveryCell(t *testing.T) {
|
||||
fw := &PF{anchor: "go_firewall"}
|
||||
merged := &Rule{Direction: DirAny, Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}
|
||||
cells := fw.ruleCells(merged)
|
||||
require.Len(t, cells, 4, "DirAny x TCPUDP occupies four anchor rows")
|
||||
|
||||
// An empty anchor: with no placement point every cell appends.
|
||||
out, changed, err := fw.insertRows(nil, nil, cells, func(int, *Rule) bool { return false })
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Len(t, out, 4, "all four rows must land in the one pass")
|
||||
|
||||
// A populated anchor with the insert predicate selecting physical row 1: the
|
||||
// block goes in ahead of that row, leaving the existing rows in order.
|
||||
existing := []*Rule{{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}, {Family: IPv4, Proto: TCP, Port: 80, Action: Accept}}
|
||||
raw := []string{"first", "second"}
|
||||
out, changed, err = fw.insertRows(existing, raw, cells, func(i int, _ *Rule) bool { return i == 1 })
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Len(t, out, 6)
|
||||
require.Equal(t, "first", out[0])
|
||||
require.Equal(t, "second", out[5], "the rows either side of the insert keep their order")
|
||||
want := make([]string, len(cells))
|
||||
for i, cell := range cells {
|
||||
line, mErr := fw.MarshalRule(cell)
|
||||
require.NoError(t, mErr)
|
||||
want[i] = line
|
||||
}
|
||||
require.Equal(t, want, out[1:5], "the cells go in together, in ruleCells order")
|
||||
}
|
||||
|
||||
// The add-time dedup must ask whether an existing row *covers* the cell, not
|
||||
// whether it is exactly equal: an anchor row written without an af matches both
|
||||
// families, so re-adding its IPv4 half would leave a redundant row Sync then
|
||||
// reports forever. The coverage is one-way, so a concrete-family row must not
|
||||
// swallow its opposite-family twin — that would leave the twin's family un-firewalled.
|
||||
func TestPFInsertRowsDedupUsesCoverage(t *testing.T) {
|
||||
fw := &PF{anchor: "go_firewall"}
|
||||
never := func(int, *Rule) bool { return false }
|
||||
|
||||
// An af-less row covers the concrete-family cell it spans.
|
||||
dual, err := fw.UnmarshalRule("pass in quick proto tcp from any to any port = 22")
|
||||
require.NoError(t, err)
|
||||
v4Cell := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 22, Action: Accept}
|
||||
_, changed, err := fw.insertRows([]*Rule{dual}, []string{"dual"}, []*Rule{v4Cell}, never)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed, "a row covering the cell must not be duplicated")
|
||||
|
||||
// A concrete IPv4 row does not cover the IPv6 cell, which must still be written.
|
||||
v4Row, err := fw.UnmarshalRule("pass in quick inet proto tcp from any to any port = 22")
|
||||
require.NoError(t, err)
|
||||
v6Cell := &Rule{Family: IPv6, Direction: DirInput, Proto: TCP, Port: 22, Action: Accept}
|
||||
out, changed, err := fw.insertRows([]*Rule{v4Row}, []string{"v4"}, []*Rule{v6Cell}, never)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed, "an opposite-family twin is not a duplicate")
|
||||
require.Len(t, out, 2)
|
||||
require.Contains(t, out[1], "inet6")
|
||||
|
||||
// A partially-present set fills in only the missing cell rather than re-adding
|
||||
// the rule whole, so an edit interrupted part-way converges on a retry.
|
||||
missing := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 80, Action: Accept}
|
||||
out, changed, err = fw.insertRows([]*Rule{v4Row}, []string{"v4"}, []*Rule{v4Cell, missing}, never)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Len(t, out, 2, "only the absent cell is added")
|
||||
require.Contains(t, out[1], "port 80")
|
||||
}
|
||||
|
||||
// A concrete-family removal that matches a genuine dual-family row (an anchor rule
|
||||
// with no af, covering both) must not drop both families: the untargeted family is
|
||||
// re-marshalled into the removed row's own slot, so it keeps its coverage and its
|
||||
// place in the anchor. Opaque rows are never removal targets and stay put.
|
||||
func TestPFRemoveRowsSplitsDualRowInPlace(t *testing.T) {
|
||||
fw := &PF{anchor: "go_firewall"}
|
||||
dual, err := fw.UnmarshalRule("pass in quick proto tcp from any to any port = 22")
|
||||
require.NoError(t, err)
|
||||
tail := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 80, Action: Accept}
|
||||
|
||||
rules := []*Rule{nil, dual, tail}
|
||||
raw := []string{"opaque", "dual", "tail"}
|
||||
cell := &Rule{Family: IPv4, Direction: DirInput, Proto: TCP, Port: 22, Action: Accept}
|
||||
|
||||
out, changed, err := fw.removeRows(rules, raw, []*Rule{cell})
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Len(t, out, 3, "the dual row is replaced, not dropped")
|
||||
require.Equal(t, "opaque", out[0], "an opaque row is never a removal target")
|
||||
require.Contains(t, out[1], "inet6", "the untargeted family survives in the dual row's slot")
|
||||
require.Equal(t, "tail", out[2])
|
||||
|
||||
// A target matching nothing leaves the rows untouched, so no anchor load runs.
|
||||
_, changed, err = fw.removeRows(rules, raw, []*Rule{{Family: IPv4, Direction: DirInput, Proto: UDP, Port: 9999, Action: Accept}})
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
}
|
||||
441
scripts/refactor_backend.py
Normal file
441
scripts/refactor_backend.py
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
#!/usr/bin/env python3
|
||||
"""refactor_backend.py — normalize function placement/naming in a go-firewall backend.
|
||||
|
||||
The library's convention, modeled on the APF refactor:
|
||||
|
||||
* 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.
|
||||
# Group 4: type-parameter list of a generic function, which Go forbids on methods.
|
||||
FUNC_RE = re.compile(
|
||||
r"^func\s+(?:\((?:(\w+)\s+)?\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*(\[[^\]]*\])?\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 results(header):
|
||||
"""Return the result section of a declaration header, empty when it has none.
|
||||
|
||||
The parameter list is skipped by balancing parentheses, since a parameter may
|
||||
itself be a func type whose own parens would fool a plain regex.
|
||||
"""
|
||||
start = header.find("(")
|
||||
if start < 0:
|
||||
return ""
|
||||
depth = 0
|
||||
for i in range(start, len(header)):
|
||||
if header[i] == "(":
|
||||
depth += 1
|
||||
elif header[i] == ")":
|
||||
depth -= 1
|
||||
if depth == 0:
|
||||
return header[i + 1:].rstrip().rstrip("{").strip()
|
||||
return ""
|
||||
|
||||
|
||||
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("--via", default="",
|
||||
help="comma-separated Type=expr paths from a non-backend receiver to the backend (e.g. nftDecoder=d.f)")
|
||||
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), "rtype": m.group(2),
|
||||
"generic": bool(m.group(4)), "hidx": hidx})
|
||||
names = {d["name"] for d in decls}
|
||||
|
||||
# A constructor for any type declared in this file stays global, matching
|
||||
# newHookScript/newAtomicFile: the type it builds, not the backend, owns it.
|
||||
# A constructor is a new-prefixed global returning a locally declared type,
|
||||
# which also catches a name that drops the backend token (newSetReader for
|
||||
# *nftSetReader).
|
||||
local_types = set(re.findall(r"(?m)^type\s+(\w+)\b", text))
|
||||
ctors = set()
|
||||
for hidx, blk in blocks:
|
||||
m = FUNC_RE.match(blk[0])
|
||||
if not m or m.group(2) or not m.group(3).startswith(("new", "New")):
|
||||
continue
|
||||
if any(re.search(r"\b" + re.escape(t) + r"\b", results(blk[0])) for t in local_types):
|
||||
ctors.add(m.group(3))
|
||||
|
||||
# Methods on a type other than the backend reach a converted global only
|
||||
# through an explicit path (--via nftDecoder=d.f); without one they cannot
|
||||
# call it at all, so anything they use is held back as a global.
|
||||
via = dict(p.split("=", 1) for p in args.via.split(",") if "=" in p)
|
||||
stranded = {d["name"] for d in decls
|
||||
if d["method"] and d["rtype"] != typ and d["rtype"] not in via}
|
||||
|
||||
# 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 / generic / (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
|
||||
# Go has no type-parameterized methods, so a generic helper stays global.
|
||||
if d["name"] == "New" + typ or d["name"] in shared or d["generic"] or d["name"] in ctors:
|
||||
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, a method with an anonymous receiver, or
|
||||
# a method on another type with no --via path. 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} | ctors | anon_methods | stranded
|
||||
| {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:
|
||||
if old == "New" + typ or old in ctors:
|
||||
why = "constructor"
|
||||
elif old in shared:
|
||||
why = "shared"
|
||||
elif d["generic"]:
|
||||
why = "generic"
|
||||
else:
|
||||
why = "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}")
|
||||
edits = [p for p in plan if p[2] in ("global->method", "method-rename")]
|
||||
print(f"# {len(edits)} change(s), {len(rename_map)} of them renames; "
|
||||
f"{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, via)
|
||||
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, via=None):
|
||||
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
|
||||
# A method on another type reaches the backend only through its --via
|
||||
# path, so converted calls are qualified with that instead.
|
||||
block_recv = m.group(1) if m.group(2) == typ else (via or {}).get(m.group(2))
|
||||
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()
|
||||
304
scripts/refactor_order.py
Normal file
304
scripts/refactor_order.py
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
#!/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
|
||||
from collections import Counter
|
||||
|
||||
# Regex used to parse a column-0 `func` declaration line. Groups:
|
||||
# 1: receiver variable (None for a plain function)
|
||||
# 2: receiver type (None for a plain function)
|
||||
# 3: function name
|
||||
# A generic function's type-parameter list sits between the name and the
|
||||
# signature, so it is skipped without capturing.
|
||||
DECL_RE = re.compile(r"^func\s+(?:\((?:(\w+)\s+)?\*?(\w+)\)\s+)?([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\s*\(")
|
||||
|
||||
|
||||
def split_blocks(text):
|
||||
"""Split source into (preamble, [block_dict, ...]).
|
||||
|
||||
A block starts at the function's doc comment and runs to the start of the
|
||||
next function's doc comment, so every line of the file lands in exactly one
|
||||
block and nothing can be dropped by the rewrite.
|
||||
|
||||
A doc comment is the run of column-0 `//` lines immediately above the `func`
|
||||
line, which is what Go itself treats as the declaration's doc. Matching the
|
||||
comment's leading word against function names is deliberately avoided: a
|
||||
sentence that merely *starts* with another function's name (a wrapped line
|
||||
such as "// applyRuleFiles fanned out the axes before calling.") would
|
||||
otherwise be misread as the start of that function's doc comment.
|
||||
"""
|
||||
lines = text.split("\n")
|
||||
func_lines = [i for i, line in enumerate(lines) if line.startswith("func ")]
|
||||
if not func_lines:
|
||||
return text, []
|
||||
|
||||
# Parse every declaration and claim the doc comment directly above it.
|
||||
funcs = []
|
||||
for fl in func_lines:
|
||||
m = DECL_RE.match(lines[fl])
|
||||
if not m:
|
||||
die(f"could not parse declaration: {lines[fl]}")
|
||||
assert m
|
||||
doc_line = fl
|
||||
while doc_line > 0 and lines[doc_line - 1].startswith("//"):
|
||||
doc_line -= 1
|
||||
funcs.append(
|
||||
{
|
||||
"recv_var": m.group(1),
|
||||
"recv_type": m.group(2),
|
||||
"name": m.group(3),
|
||||
"doc_line": doc_line,
|
||||
"func_line": fl,
|
||||
}
|
||||
)
|
||||
|
||||
# Everything above the first function's doc comment is the preamble.
|
||||
preamble_lines = lines[: funcs[0]["doc_line"]]
|
||||
preamble = "\n".join(preamble_lines)
|
||||
if preamble_lines:
|
||||
preamble += "\n"
|
||||
|
||||
# Each block owns its doc comment, its body, and the blank separator that
|
||||
# follows, so blocks concatenate back into a well-formed file in any order.
|
||||
# Trailing blanks are normalized to a single separator line; the last block
|
||||
# keeps whatever follows it (there is no next doc comment to stop at).
|
||||
blocks = []
|
||||
for idx, f in enumerate(funcs):
|
||||
end = funcs[idx + 1]["doc_line"] if idx + 1 < len(funcs) else len(lines)
|
||||
block_lines = lines[f["doc_line"] : end]
|
||||
while block_lines and block_lines[-1] == "":
|
||||
block_lines.pop()
|
||||
blocks.append(
|
||||
{
|
||||
"recv_var": f["recv_var"],
|
||||
"recv_type": f["recv_type"],
|
||||
"name": f["name"],
|
||||
"text": "\n".join(block_lines) + "\n\n",
|
||||
}
|
||||
)
|
||||
|
||||
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, backend_type):
|
||||
"""Return a map from function name to the set of in-file functions it references.
|
||||
|
||||
Both call shapes and bare method values count as references, since a
|
||||
dependency is anything that must already be declared for the reader to
|
||||
follow the code. The receiver-qualified regex therefore does not require a
|
||||
trailing "(": `f.removeRuleGroups` handed to a higher-order helper depends on
|
||||
it just as much as `f.removeRuleGroups(...)` would. Bare names still require
|
||||
the paren, because an unqualified name with no call syntax is far more often
|
||||
a prose mention inside a doc comment than a function value.
|
||||
"""
|
||||
names = {block["name"] for block in blocks}
|
||||
graph = {name: set() for name in names}
|
||||
|
||||
# Any variable holding the backend can reach its methods, not just the
|
||||
# dominant receiver: a constructor names its local something else (`ipt`),
|
||||
# and its calls would otherwise look like calls on an unrelated value.
|
||||
bt = re.escape(backend_type)
|
||||
alias_re = re.compile(rf"\b(\w+)\s*:?=\s*(?:&?{bt}\{{|new\(\s*{bt}\s*\))")
|
||||
|
||||
for block in blocks:
|
||||
name = block["name"]
|
||||
text = block["text"]
|
||||
deps = graph[name]
|
||||
|
||||
recvs = {receiver} if receiver else set()
|
||||
recvs.update(mm.group(1) for mm in alias_re.finditer(text))
|
||||
recvs.discard("")
|
||||
if recvs:
|
||||
method_re = re.compile(
|
||||
r"\b(?:" + "|".join(re.escape(r) for r in sorted(recvs)) + r")\.(\w+)\b"
|
||||
)
|
||||
for mm in method_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, backend_type)
|
||||
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()
|
||||
active = set()
|
||||
|
||||
def place(name):
|
||||
if name in placed or name in active:
|
||||
return
|
||||
if name not in name_to_block:
|
||||
return
|
||||
# active breaks a dependency cycle (mutual recursion, or a false edge
|
||||
# from a field that shares a function's name). One member of the cycle
|
||||
# necessarily ends up after a caller; Go does not mind, and the
|
||||
# alternative is unbounded recursion.
|
||||
active.add(name)
|
||||
for dep in sorted(graph.get(name, set())):
|
||||
place(dep)
|
||||
active.discard(name)
|
||||
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 check_lossless(old_text, new_text):
|
||||
"""Abort unless the rewrite is a pure permutation of the source.
|
||||
|
||||
A reorder may only move lines, never add or drop them, so the multiset of
|
||||
non-blank lines must be identical on both sides. This is the backstop that
|
||||
catches a mis-attributed comment or a mis-sliced block before it reaches the
|
||||
file, since either shows up as a missing line rather than a Go syntax error.
|
||||
"""
|
||||
before = Counter(l for l in old_text.split("\n") if l.strip())
|
||||
after = Counter(l for l in new_text.split("\n") if l.strip())
|
||||
lost = before - after
|
||||
gained = after - before
|
||||
if not lost and not gained:
|
||||
return
|
||||
for line in list(lost)[:20]:
|
||||
print(f"error: line dropped by reorder: {line}", file=sys.stderr)
|
||||
for line in list(gained)[:20]:
|
||||
print(f"error: line invented by reorder: {line}", file=sys.stderr)
|
||||
die("reorder is not lossless; the file was left unchanged")
|
||||
|
||||
|
||||
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)
|
||||
new_text = new_text.rstrip("\n") + "\n"
|
||||
check_lossless(text, new_text)
|
||||
|
||||
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()
|
||||
288
services.go
Normal file
288
services.go
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
//go:build linux
|
||||
|
||||
package firewall
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
dbus "github.com/coreos/go-systemd/v22/dbus"
|
||||
)
|
||||
|
||||
// systemdActive reports whether systemd is the running init (/run/systemd/system
|
||||
// exists), the same marker systemctl uses to decide it can reach the manager.
|
||||
func systemdActive() bool {
|
||||
_, err := os.Stat("/run/systemd/system")
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// commandExists reports whether name resolves on PATH or in the standard install
|
||||
// directories, matching how runCommand resolves the tool it runs.
|
||||
func commandExists(name string) bool {
|
||||
_, ok := resolveBinary(name)
|
||||
return ok
|
||||
}
|
||||
|
||||
// systemdUnitEnabled reports whether systemd reports name's unit as set to
|
||||
// start at boot. Only "enabled" and "enabled-runtime" count: a "generated"
|
||||
// unit wraps a SysV init.d script and exists for every script regardless of
|
||||
// its rc registration, so boot enablement for those is resolved through the
|
||||
// SysV checks instead. Returns false when systemd is not running or the unit
|
||||
// is absent.
|
||||
func systemdUnitEnabled(ctx context.Context, name string) bool {
|
||||
conn, err := dbus.NewWithContext(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer conn.Close()
|
||||
prop, err := conn.GetUnitPropertyContext(ctx, name+".service", "UnitFileState")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch prop.Value.Value() {
|
||||
case "enabled", "enabled-runtime":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// systemdUnitState returns name's unit-file state (e.g. "enabled", "disabled",
|
||||
// "static") and whether its unit file is installed. It lists all unit files and
|
||||
// filters by name rather than calling ListUnitFilesByPatterns, which older
|
||||
// systemd (CentOS 7's v219) does not export over D-Bus. Used to tell
|
||||
// installed-but-disabled units (which can be enabled) from absent ones (which
|
||||
// cannot be). Returns "", false when systemd is not running.
|
||||
func systemdUnitState(ctx context.Context, name string) (state string, present bool) {
|
||||
conn, err := dbus.NewWithContext(ctx)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
defer conn.Close()
|
||||
files, err := conn.ListUnitFilesContext(ctx)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
target := name + ".service"
|
||||
for _, uf := range files {
|
||||
if filepath.Base(uf.Path) == target {
|
||||
return uf.Type, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// chkconfigOn reports whether `chkconfig --list name` shows any runlevel on,
|
||||
// the RHEL-family enablement signal. Returns false when chkconfig is absent.
|
||||
func chkconfigOn(ctx context.Context, name string) bool {
|
||||
results, err := runCommand(ctx, "chkconfig", "--list", name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, line := range results {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) == 0 || fields[0] != name {
|
||||
continue
|
||||
}
|
||||
for _, f := range fields[1:] {
|
||||
if _, status, found := strings.Cut(f, ":"); found && status == "on" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rcSymlinksOn reports whether an S*name start symlink exists in any rcN.d
|
||||
// tree, the enablement signal for both update-rc.d (Debian/Ubuntu, /etc/rcN.d)
|
||||
// and Slackware (/etc/rc.d/rcN.d).
|
||||
func rcSymlinksOn(name string) bool {
|
||||
for _, rl := range []string{"2", "3", "4", "5"} {
|
||||
for _, dir := range []string{"/etc/rc" + rl + ".d", "/etc/rc.d/rc" + rl + ".d"} {
|
||||
if matches, _ := filepath.Glob(filepath.Join(dir, "S*"+name)); len(matches) > 0 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// openrcOn reports whether name is linked into an OpenRC runlevel directory
|
||||
// (default or boot), the enablement signal created by `rc-update add` on Gentoo.
|
||||
func openrcOn(name string) bool {
|
||||
for _, rl := range []string{"default", "boot"} {
|
||||
if _, err := os.Lstat(filepath.Join("/etc/runlevels", rl, name)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// rcLocalOn reports whether name is invoked from an rc.local file, apf's
|
||||
// last-resort enablement when neither systemd nor an init.d registration
|
||||
// applies. Commented lines are ignored, and name must appear as its own token
|
||||
// (bare or path-qualified) so an unrelated mention such as a log-file path
|
||||
// does not count.
|
||||
func rcLocalOn(name string) bool {
|
||||
for _, p := range []string{"/etc/rc.local", "/etc/rc.d/rc.local"} {
|
||||
data, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
for _, tok := range strings.Fields(line) {
|
||||
if tok == name || strings.HasSuffix(tok, "/"+name) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sysvServiceEnabled reports whether name is enabled under any SysV-family init
|
||||
// system, the fallback when systemd is absent or does not report the service
|
||||
// enabled. It checks chkconfig, Debian/Slackware rcN.d start symlinks, OpenRC
|
||||
// runlevels, and rc.local in turn; the first to report it on wins.
|
||||
func sysvServiceEnabled(ctx context.Context, name string) bool {
|
||||
if chkconfigOn(ctx, name) {
|
||||
return true
|
||||
}
|
||||
if rcSymlinksOn(name) {
|
||||
return true
|
||||
}
|
||||
if openrcOn(name) {
|
||||
return true
|
||||
}
|
||||
return rcLocalOn(name)
|
||||
}
|
||||
|
||||
// serviceInstalled reports whether name's unit or init.d script is installed,
|
||||
// regardless of whether it is enabled. Used to tell a merely-disabled service
|
||||
// (which can be enabled) from an absent one (which cannot be).
|
||||
func serviceInstalled(ctx context.Context, name string) bool {
|
||||
if systemdActive() {
|
||||
_, present := systemdUnitState(ctx, name)
|
||||
return present
|
||||
}
|
||||
for _, dir := range []string{"/etc/init.d", "/etc/rc.d/init.d"} {
|
||||
if _, err := os.Stat(filepath.Join(dir, name)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// serviceEnabled reports whether name is enabled to start, checking systemd
|
||||
// first and then every SysV-family init mechanism the supported firewalls
|
||||
// install under: chkconfig (RHEL), update-rc.d (Debian/Ubuntu, via /etc/rcN.d
|
||||
// symlinks), rc-update (Gentoo/OpenRC, via /etc/runlevels), Slackware rc.d
|
||||
// symlinks, and rc.local (apf's last resort). name is the service base name
|
||||
// without a ".service" suffix (e.g. "csf", "apf", "netfilter-persistent"); any
|
||||
// one mechanism reporting it on counts as enabled.
|
||||
func serviceEnabled(ctx context.Context, name string) bool {
|
||||
if systemdUnitEnabled(ctx, name) {
|
||||
return true
|
||||
}
|
||||
return sysvServiceEnabled(ctx, name)
|
||||
}
|
||||
|
||||
// enableService enables name to start at boot under whatever init system is
|
||||
// active, mirroring how the firewalls' installers register themselves. It is a
|
||||
// no-op (returns nil) when the service is not installed or already enabled. On
|
||||
// systemd it runs `systemctl enable` (preceded by daemon-reload so a freshly
|
||||
// installed unit is picked up); otherwise it uses chkconfig, update-rc.d, or
|
||||
// rc-update, whichever is present.
|
||||
func enableService(ctx context.Context, name string) error {
|
||||
if systemdActive() {
|
||||
state, present := systemdUnitState(ctx, name)
|
||||
if !present {
|
||||
return nil
|
||||
}
|
||||
switch state {
|
||||
case "enabled", "enabled-runtime", "static":
|
||||
// Static units have no install info and are pulled in by other
|
||||
// units, so there is nothing to enable.
|
||||
return nil
|
||||
case "generated":
|
||||
// A generated unit wraps a SysV init.d script whose enablement
|
||||
// lives in its rc registration, not the unit file. `systemctl
|
||||
// enable` reaches that registration through systemd-sysv-install.
|
||||
if sysvServiceEnabled(ctx, name) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if _, err := runCommand(ctx, "systemctl", "daemon-reload"); err != nil {
|
||||
return fmt.Errorf("failed to reload systemd for %s: %s", name, err)
|
||||
}
|
||||
if _, err := runCommand(ctx, "systemctl", "enable", name+".service"); err != nil {
|
||||
return fmt.Errorf("failed to enable %s: %s", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !serviceInstalled(ctx, name) {
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case commandExists("chkconfig"):
|
||||
if _, err := runCommand(ctx, "chkconfig", "--add", name); err != nil {
|
||||
return fmt.Errorf("failed to enable %s: %s", name, err)
|
||||
}
|
||||
if _, err := runCommand(ctx, "chkconfig", name, "on"); err != nil {
|
||||
return fmt.Errorf("failed to enable %s: %s", name, err)
|
||||
}
|
||||
case commandExists("update-rc.d"):
|
||||
if _, err := runCommand(ctx, "update-rc.d", name, "defaults"); err != nil {
|
||||
return fmt.Errorf("failed to enable %s: %s", name, err)
|
||||
}
|
||||
case commandExists("rc-update"):
|
||||
if _, err := runCommand(ctx, "rc-update", "add", name, "default"); err != nil {
|
||||
return fmt.Errorf("failed to enable %s: %s", name, err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("no supported init system found to enable %s", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// restartService restarts name under whatever init system is active. On systemd
|
||||
// it runs `systemctl restart`; otherwise it prefers the `service` wrapper, then
|
||||
// OpenRC's `rc-service`, then the init.d script directly.
|
||||
func restartService(ctx context.Context, name string) error {
|
||||
if systemdActive() {
|
||||
if _, err := runCommand(ctx, "systemctl", "restart", name+".service"); err != nil {
|
||||
// A burst of restarts (a caller reconciling several times in quick
|
||||
// succession) trips systemd's start rate limit and the unit lands in
|
||||
// a failed start-limit-hit state; clear it and retry once rather
|
||||
// than fail a reload the unit itself is healthy for.
|
||||
if _, rerr := runCommand(ctx, "systemctl", "reset-failed", name+".service"); rerr == nil {
|
||||
if _, err2 := runCommand(ctx, "systemctl", "restart", name+".service"); err2 == nil {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("failed to restart %s: %s", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
switch {
|
||||
case commandExists("service"):
|
||||
if _, err := runCommand(ctx, "service", name, "restart"); err != nil {
|
||||
return fmt.Errorf("failed to restart %s: %s", name, err)
|
||||
}
|
||||
case commandExists("rc-service"):
|
||||
if _, err := runCommand(ctx, "rc-service", name, "restart"); err != nil {
|
||||
return fmt.Errorf("failed to restart %s: %s", name, err)
|
||||
}
|
||||
default:
|
||||
if _, err := runCommand(ctx, filepath.Join("/etc/init.d", name), "restart"); err != nil {
|
||||
return fmt.Errorf("failed to restart %s: %s", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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
|
||||
453
test/integration/guest-linux-run.sh
Executable file
453
test/integration/guest-linux-run.sh
Executable file
|
|
@ -0,0 +1,453 @@
|
|||
#!/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
|
||||
# ipset-persistent ships the netfilter-persistent plugin the backend probes
|
||||
# for to decide it has somewhere to stage address sets. Without it the
|
||||
# backend falls back to creating sets live and the staged path — the one
|
||||
# Reload loads through `ipset restore` — never runs. ufw cannot have it (it
|
||||
# pulls netfilter-persistent, which conflicts with ufw as noted above), so
|
||||
# ufw is what covers the live-only fallback.
|
||||
apt-get install -y --no-install-recommends iptables iptables-persistent netfilter-persistent ipset ipset-persistent
|
||||
;;
|
||||
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)
|
||||
# Two stock settings keep apf from enforcing what the tests write, both set
|
||||
# here rather than in the config snapshot (which is seeded once and reused
|
||||
# across runs). EGF gates egress filtering as a whole: with it off apf stores
|
||||
# an EG_*_CPORTS entry but never puts it in the kernel, so an outbound rule
|
||||
# is never actually applied. BLK_RESNET drops traffic to the reserved
|
||||
# networks ahead of apf's own port rules, and the rule-counter subtest drives
|
||||
# packets at a TEST-NET address to make a rule's counter move.
|
||||
sed -i 's/^EGF=.*/EGF="1"/' /etc/apf/conf.apf
|
||||
sed -i 's/^BLK_RESNET=.*/BLK_RESNET="0"/' /etc/apf/conf.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"
|
||||
733
types.go
Normal file
733
types.go
Normal file
|
|
@ -0,0 +1,733 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Action is the firewall action taken on a rule's matching packets.
|
||||
type Action uint8
|
||||
|
||||
const (
|
||||
// ActionInvalid is the zero value of Action, meaning no action; it is
|
||||
// rejected when authoring a rule or policy.
|
||||
ActionInvalid Action = iota
|
||||
// Accept permits matching packets through.
|
||||
Accept
|
||||
// Reject refuses matching packets with an error response to the sender.
|
||||
Reject
|
||||
// Drop silently discards matching packets.
|
||||
Drop
|
||||
)
|
||||
|
||||
// String returns the canonical lower-case name of the action.
|
||||
func (t Action) String() string {
|
||||
switch t {
|
||||
case Accept:
|
||||
return "accept"
|
||||
case Reject:
|
||||
return "reject"
|
||||
case Drop:
|
||||
return "drop"
|
||||
}
|
||||
return "invalid"
|
||||
}
|
||||
|
||||
// ParseAction parses a caller-supplied action token (case-insensitive),
|
||||
// accepting only the concrete actions Accept, Reject and Drop. The sentinel
|
||||
// "invalid" (ActionInvalid) is rejected here so callers cannot author a rule or
|
||||
// policy with no real action; backup decoding round-trips it separately in
|
||||
// Action.UnmarshalJSON.
|
||||
func ParseAction(s string) (Action, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "accept":
|
||||
return Accept, nil
|
||||
case "reject":
|
||||
return Reject, nil
|
||||
case "drop":
|
||||
return Drop, nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown action %q", s)
|
||||
}
|
||||
|
||||
// Family is the IP family a rule targets.
|
||||
type Family uint8
|
||||
|
||||
const (
|
||||
// FamilyAny targets both IPv4 and IPv6.
|
||||
FamilyAny Family = iota
|
||||
// IPv4 targets IPv4 traffic only.
|
||||
IPv4
|
||||
// IPv6 targets IPv6 traffic only.
|
||||
IPv6
|
||||
)
|
||||
|
||||
// String returns the canonical lower-case name of the family.
|
||||
func (t Family) String() string {
|
||||
switch t {
|
||||
case IPv4:
|
||||
return "ipv4"
|
||||
case IPv6:
|
||||
return "ipv6"
|
||||
}
|
||||
return "any"
|
||||
}
|
||||
|
||||
// ParseFamily parses a family token (case-insensitive), accepting the canonical
|
||||
// name emitted by Family.String plus the common aliases (v4/inet4, v6/inet6).
|
||||
// An unknown value is an error.
|
||||
func ParseFamily(s string) (Family, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "any":
|
||||
return FamilyAny, nil
|
||||
case "ipv4", "v4", "inet4":
|
||||
return IPv4, nil
|
||||
case "ipv6", "v6", "inet6":
|
||||
return IPv6, nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown family %q", s)
|
||||
}
|
||||
|
||||
// Protocol is the network protocol a rule matches.
|
||||
type Protocol uint8
|
||||
|
||||
const (
|
||||
// ProtocolAny matches every IP protocol and is the zero value.
|
||||
ProtocolAny Protocol = iota
|
||||
// UDP is the connectionless transport protocol.
|
||||
UDP
|
||||
// TCP is the connection-oriented transport protocol.
|
||||
TCP
|
||||
// ICMP and ICMPv6 are the control-message protocols. ICMP implies IPv4 and
|
||||
// ICMPv6 implies IPv6.
|
||||
ICMP
|
||||
ICMPv6
|
||||
// SCTP is a transport protocol that, like TCP and UDP, carries ports.
|
||||
SCTP
|
||||
// GRE, ESP and AH are portless IP protocols (tunneling and IPsec). A rule
|
||||
// carrying one of these cannot also match a port.
|
||||
GRE
|
||||
ESP
|
||||
AH
|
||||
// TCPUDP matches TCP and UDP together.
|
||||
TCPUDP
|
||||
)
|
||||
|
||||
// String returns the canonical lower-case name of the protocol.
|
||||
func (t Protocol) String() string {
|
||||
switch t {
|
||||
case UDP:
|
||||
return "udp"
|
||||
case TCP:
|
||||
return "tcp"
|
||||
case TCPUDP:
|
||||
return "tcpudp"
|
||||
case ICMP:
|
||||
return "icmp"
|
||||
case ICMPv6:
|
||||
return "icmpv6"
|
||||
case SCTP:
|
||||
return "sctp"
|
||||
case GRE:
|
||||
return "gre"
|
||||
case ESP:
|
||||
return "esp"
|
||||
case AH:
|
||||
return "ah"
|
||||
}
|
||||
return "any"
|
||||
}
|
||||
|
||||
// IsICMP reports whether the protocol is ICMP or ICMPv6.
|
||||
func (t Protocol) IsICMP() bool {
|
||||
return t == ICMP || t == ICMPv6
|
||||
}
|
||||
|
||||
// HasPorts reports whether the protocol carries layer-4 ports (TCP, UDP, SCTP or
|
||||
// the merged TCPUDP). A port match is only meaningful and only valid for these
|
||||
// protocols.
|
||||
func (t Protocol) HasPorts() bool {
|
||||
return t == TCP || t == UDP || t == SCTP || t == TCPUDP
|
||||
}
|
||||
|
||||
// oppositeProtocol returns the other transport of the TCP/UDP pair a TCPUDP rule
|
||||
// fans out to: UDP for TCP and vice versa. Every other protocol has no twin and
|
||||
// returns ProtocolAny (the sentinel meaning "no pair"). It is the protocol analog of
|
||||
// oppositeFamily, and supports the dual-row split on removal.
|
||||
func oppositeProtocol(p Protocol) Protocol {
|
||||
switch p {
|
||||
case TCP:
|
||||
return UDP
|
||||
case UDP:
|
||||
return TCP
|
||||
default:
|
||||
return ProtocolAny
|
||||
}
|
||||
}
|
||||
|
||||
// Ptr returns a pointer to v. It is a convenience for setting optional rule
|
||||
// fields such as ICMPType, e.g. firewall.Ptr[uint8](8).
|
||||
func Ptr[T any](v T) *T {
|
||||
return &v
|
||||
}
|
||||
|
||||
// icmpNameToNum maps the ICMP type names various tools accept (and their common
|
||||
// aliases) to their numeric type. It is used when reading a rule whose ICMP type
|
||||
// is written by name; rules this library writes always emit the number, which
|
||||
// every backend accepts.
|
||||
var icmpNameToNum = map[string]uint8{
|
||||
"echo-reply": 0,
|
||||
"pong": 0,
|
||||
"destination-unreachable": 3,
|
||||
"source-quench": 4,
|
||||
"redirect": 5,
|
||||
"echo-request": 8,
|
||||
"ping": 8,
|
||||
"router-advertisement": 9,
|
||||
"router-solicitation": 10,
|
||||
"time-exceeded": 11,
|
||||
"ttl-exceeded": 11,
|
||||
"parameter-problem": 12,
|
||||
"timestamp-request": 13,
|
||||
"timestamp-reply": 14,
|
||||
"info-request": 15,
|
||||
"info-reply": 16,
|
||||
"address-mask-request": 17,
|
||||
"address-mask-reply": 18,
|
||||
"traceroute": 30,
|
||||
}
|
||||
|
||||
// icmpv6NameToNum maps the ICMPv6 type names nftables, ip6tables and ufw print
|
||||
// to their numeric type (both the nftables nd-* spellings and the ip6tables
|
||||
// long forms such as router-solicitation). ICMPv6 reuses several names from
|
||||
// ICMPv4 (echo-request, destination-unreachable, ...) for *different* numbers,
|
||||
// so a name read from an ICMPv6 rule must be resolved through this table rather
|
||||
// than icmpNameToNum.
|
||||
var icmpv6NameToNum = map[string]uint8{
|
||||
"destination-unreachable": 1,
|
||||
"packet-too-big": 2,
|
||||
"time-exceeded": 3,
|
||||
"ttl-exceeded": 3,
|
||||
"parameter-problem": 4,
|
||||
"echo-request": 128,
|
||||
"ping": 128,
|
||||
"echo-reply": 129,
|
||||
"pong": 129,
|
||||
"mld-listener-query": 130,
|
||||
"mld-listener-report": 131,
|
||||
"mld-listener-done": 132,
|
||||
"mld-listener-reduction": 132,
|
||||
"nd-router-solicit": 133,
|
||||
"router-solicitation": 133,
|
||||
"nd-router-advert": 134,
|
||||
"router-advertisement": 134,
|
||||
"nd-neighbor-solicit": 135,
|
||||
"neighbor-solicitation": 135,
|
||||
"neighbour-solicitation": 135,
|
||||
"nd-neighbor-advert": 136,
|
||||
"neighbor-advertisement": 136,
|
||||
"neighbour-advertisement": 136,
|
||||
"nd-redirect": 137,
|
||||
"redirect": 137,
|
||||
"router-renumbering": 138,
|
||||
"ind-neighbor-solicit": 141,
|
||||
"ind-neighbor-advert": 142,
|
||||
"mld2-listener-report": 143,
|
||||
}
|
||||
|
||||
// parseICMPType parses an ICMP type token as either a number (0-255) or one of
|
||||
// the well-known IPv4 names in icmpNameToNum.
|
||||
func parseICMPType(tok string) (uint8, bool) {
|
||||
return parseICMPTypeFamily(tok, false)
|
||||
}
|
||||
|
||||
// parseICMPTypeFamily parses an ICMP type token like parseICMPType, but resolves
|
||||
// names through the ICMPv6 table when v6 is true. Numbers parse identically in
|
||||
// either family (and rules this library writes always emit the number), so only
|
||||
// the name path is family-dependent.
|
||||
func parseICMPTypeFamily(tok string, v6 bool) (uint8, bool) {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if n, err := strconv.ParseUint(tok, 10, 8); err == nil {
|
||||
return uint8(n), true
|
||||
}
|
||||
if v6 {
|
||||
if n, ok := icmpv6NameToNum[strings.ToLower(tok)]; ok {
|
||||
return n, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
if n, ok := icmpNameToNum[strings.ToLower(tok)]; ok {
|
||||
return n, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// ParseICMPType parses an ICMP type token as either a number (0-255) or a
|
||||
// well-known type name, resolving names through the ICMPv6 table when v6 is true
|
||||
// (the same name maps to a different number under ICMPv6 — e.g. echo-request is 8
|
||||
// for ICMPv4 but 128 for ICMPv6). It is the exported form of the resolution the
|
||||
// backends use internally, so a caller or CLI authoring a rule by name accepts
|
||||
// exactly the spellings the library itself emits and reads back.
|
||||
func ParseICMPType(tok string, v6 bool) (uint8, bool) {
|
||||
return parseICMPTypeFamily(tok, v6)
|
||||
}
|
||||
|
||||
// GetProtocol converts a string to the network protocol. The common spellings
|
||||
// each backend emits for ICMPv6 (icmpv6, ipv6-icmp, icmp6) are all recognized.
|
||||
// An unknown token resolves to ProtocolAny (the widest match), so a caller that
|
||||
// must distinguish an unknown protocol from a genuine "any" checks the token
|
||||
// itself, as the save-file parsers do.
|
||||
func GetProtocol(proto string) Protocol {
|
||||
switch {
|
||||
case strings.EqualFold("udp", proto):
|
||||
return UDP
|
||||
case strings.EqualFold("tcp", proto):
|
||||
return TCP
|
||||
case strings.EqualFold("tcpudp", proto):
|
||||
return TCPUDP
|
||||
case strings.EqualFold("icmp", proto):
|
||||
return ICMP
|
||||
case strings.EqualFold("icmpv6", proto),
|
||||
strings.EqualFold("ipv6-icmp", proto),
|
||||
strings.EqualFold("icmp6", proto):
|
||||
return ICMPv6
|
||||
case strings.EqualFold("sctp", proto):
|
||||
return SCTP
|
||||
case strings.EqualFold("gre", proto):
|
||||
return GRE
|
||||
case strings.EqualFold("esp", proto),
|
||||
strings.EqualFold("ipsec-esp", proto):
|
||||
return ESP
|
||||
case strings.EqualFold("ah", proto),
|
||||
strings.EqualFold("ipsec-ah", proto):
|
||||
return AH
|
||||
}
|
||||
return ProtocolAny
|
||||
}
|
||||
|
||||
// PortRange is an inclusive range of ports. A single port is represented with
|
||||
// End equal to Start (or End left zero, which normalizes to Start).
|
||||
type PortRange struct {
|
||||
// Start is the first port in the inclusive range.
|
||||
Start uint16
|
||||
// End is the last port in the inclusive range.
|
||||
End uint16
|
||||
}
|
||||
|
||||
// normalized returns the range with a zero or inverted End collapsed to a single
|
||||
// port at Start.
|
||||
func (pr PortRange) normalized() PortRange {
|
||||
if pr.End == 0 || pr.End < pr.Start {
|
||||
pr.End = pr.Start
|
||||
}
|
||||
return pr
|
||||
}
|
||||
|
||||
// String renders the range as "80" for a single port or "80-90" for a span.
|
||||
func (pr PortRange) String() string {
|
||||
pr = pr.normalized()
|
||||
if pr.Start == pr.End {
|
||||
return strconv.FormatUint(uint64(pr.Start), 10)
|
||||
}
|
||||
return fmt.Sprintf("%d-%d", pr.Start, pr.End)
|
||||
}
|
||||
|
||||
// ParsePortRange parses a single "80" or "80-90"/"80:90" token into a PortRange.
|
||||
func ParsePortRange(s string) (PortRange, error) {
|
||||
s = strings.TrimSpace(s)
|
||||
sep := "-"
|
||||
if strings.Contains(s, ":") {
|
||||
sep = ":"
|
||||
}
|
||||
lo, hi, isRange := strings.Cut(s, sep)
|
||||
start, err := strconv.ParseUint(strings.TrimSpace(lo), 10, 16)
|
||||
if err != nil {
|
||||
return PortRange{}, fmt.Errorf("invalid port %q", lo)
|
||||
}
|
||||
pr := PortRange{Start: uint16(start), End: uint16(start)}
|
||||
if isRange {
|
||||
end, err := strconv.ParseUint(strings.TrimSpace(hi), 10, 16)
|
||||
if err != nil {
|
||||
return PortRange{}, fmt.Errorf("invalid port %q", hi)
|
||||
}
|
||||
pr.End = uint16(end)
|
||||
if pr.End < pr.Start {
|
||||
return PortRange{}, fmt.Errorf("port range end %d is below start %d", pr.End, pr.Start)
|
||||
}
|
||||
}
|
||||
return pr, nil
|
||||
}
|
||||
|
||||
// ParsePortRanges parses a separated list such as "80,443,1000-2000" into a slice
|
||||
// of PortRange values. sep is the separator between entries (typically ",").
|
||||
func ParsePortRanges(s, sep string) ([]PortRange, error) {
|
||||
var out []PortRange
|
||||
for _, tok := range strings.Split(s, sep) {
|
||||
tok = strings.TrimSpace(tok)
|
||||
if tok == "" {
|
||||
continue
|
||||
}
|
||||
pr, err := ParsePortRange(tok)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, pr)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// FormatPortRanges renders a slice of ranges as a separated list.
|
||||
func FormatPortRanges(prs []PortRange, sep string) string {
|
||||
parts := make([]string, len(prs))
|
||||
for i, pr := range prs {
|
||||
parts[i] = pr.String()
|
||||
}
|
||||
return strings.Join(parts, sep)
|
||||
}
|
||||
|
||||
// ConnState is a set of connection-tracking states to match, combined as a
|
||||
// bitmask (e.g. StateEstablished|StateRelated). The zero value matches no
|
||||
// particular state (i.e. the rule is stateless).
|
||||
type ConnState uint8
|
||||
|
||||
const (
|
||||
// StateNew matches packets starting a new connection.
|
||||
StateNew ConnState = 1 << iota
|
||||
// StateEstablished matches packets belonging to an existing connection.
|
||||
StateEstablished
|
||||
// StateRelated matches packets starting a connection related to an existing
|
||||
// one.
|
||||
StateRelated
|
||||
// StateInvalid matches packets the tracker cannot associate with a connection.
|
||||
StateInvalid
|
||||
)
|
||||
|
||||
// connStateNames lists the states in canonical rendering order.
|
||||
var connStateNames = []struct {
|
||||
bit ConnState
|
||||
name string
|
||||
}{
|
||||
{StateNew, "new"},
|
||||
{StateEstablished, "established"},
|
||||
{StateRelated, "related"},
|
||||
{StateInvalid, "invalid"},
|
||||
}
|
||||
|
||||
// Strings returns the set states as lower-case names in canonical order.
|
||||
func (s ConnState) Strings() []string {
|
||||
var out []string
|
||||
for _, cs := range connStateNames {
|
||||
if s&cs.bit != 0 {
|
||||
out = append(out, cs.name)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// String renders the state set as a comma-separated list (e.g.
|
||||
// "established,related"), or the empty string when no state is set.
|
||||
func (s ConnState) String() string {
|
||||
return strings.Join(s.Strings(), ",")
|
||||
}
|
||||
|
||||
// ParseConnState parses state names (case-insensitive) into a ConnState bitmask.
|
||||
// Each token may itself be a comma-separated list. An unknown name is an error.
|
||||
func ParseConnState(tokens ...string) (ConnState, error) {
|
||||
var state ConnState
|
||||
for _, tok := range tokens {
|
||||
for _, name := range strings.Split(tok, ",") {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
matched := false
|
||||
for _, cs := range connStateNames {
|
||||
if strings.EqualFold(name, cs.name) {
|
||||
state |= cs.bit
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return 0, fmt.Errorf("unknown connection state %q", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
// RateUnit is the time unit a RateLimit is expressed over.
|
||||
type RateUnit uint8
|
||||
|
||||
const (
|
||||
// PerSecond expresses a rate per second.
|
||||
PerSecond RateUnit = iota
|
||||
// PerMinute expresses a rate per minute.
|
||||
PerMinute
|
||||
// PerHour expresses a rate per hour.
|
||||
PerHour
|
||||
// PerDay expresses a rate per day.
|
||||
PerDay
|
||||
)
|
||||
|
||||
// String returns the canonical (nftables-style) unit name.
|
||||
func (u RateUnit) String() string {
|
||||
switch u {
|
||||
case PerMinute:
|
||||
return "minute"
|
||||
case PerHour:
|
||||
return "hour"
|
||||
case PerDay:
|
||||
return "day"
|
||||
}
|
||||
return "second"
|
||||
}
|
||||
|
||||
// ParseRateUnit parses a rate-unit token, accepting the long, short and
|
||||
// single-letter spellings the various backends emit (e.g. second/sec/s).
|
||||
func ParseRateUnit(s string) (RateUnit, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "s", "sec", "second", "seconds":
|
||||
return PerSecond, nil
|
||||
case "m", "min", "minute", "minutes":
|
||||
return PerMinute, nil
|
||||
case "h", "hour", "hours":
|
||||
return PerHour, nil
|
||||
case "d", "day", "days":
|
||||
return PerDay, nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown rate unit %q", s)
|
||||
}
|
||||
|
||||
// RateLimit caps the rate at which a rule matches packets: up to Rate packets
|
||||
// per Unit, with an optional Burst allowance. A nil *RateLimit on a Rule means
|
||||
// no rate limiting. Backends that cannot express a rate limit reject a rule
|
||||
// carrying one rather than applying it unlimited.
|
||||
type RateLimit struct {
|
||||
// Rate is the maximum number of matching packets allowed per Unit.
|
||||
Rate uint
|
||||
// Unit is the time window Rate is counted over.
|
||||
Unit RateUnit
|
||||
// Burst is an optional allowance for bursts above Rate. 0 leaves the burst
|
||||
// at the backend default.
|
||||
Burst uint
|
||||
}
|
||||
|
||||
// String renders the limit as "<rate>/<unit>" (e.g. "10/minute").
|
||||
func (rl RateLimit) String() string {
|
||||
return fmt.Sprintf("%d/%s", rl.Rate, rl.Unit)
|
||||
}
|
||||
|
||||
// parseRateToken parses a "<rate>/<unit>" token (e.g. "10/minute") into its
|
||||
// numeric rate and unit. Backends use it when decoding a rule.
|
||||
func parseRateToken(tok string) (uint, RateUnit, error) {
|
||||
num, unitStr, ok := strings.Cut(strings.TrimSpace(tok), "/")
|
||||
if !ok {
|
||||
return 0, 0, fmt.Errorf("invalid rate %q", tok)
|
||||
}
|
||||
n, err := strconv.ParseUint(strings.TrimSpace(num), 10, 32)
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("invalid rate %q", tok)
|
||||
}
|
||||
unit, err := ParseRateUnit(unitStr)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return uint(n), unit, nil
|
||||
}
|
||||
|
||||
// ConnLimit caps the number of concurrent connections a rule matches. When
|
||||
// PerSource is set the cap is applied per source address; otherwise it is a
|
||||
// single global cap. A nil *ConnLimit means no connection limiting.
|
||||
type ConnLimit struct {
|
||||
// Count is the maximum number of concurrent connections the rule matches.
|
||||
Count uint
|
||||
// PerSource, when set, applies Count per source address rather than as a
|
||||
// single global cap.
|
||||
PerSource bool
|
||||
}
|
||||
|
||||
// netfilterDefaultBurst is the burst the kernel's xt_limit applies when a rule
|
||||
// names none (5). nft and iptables always print it back, and their read paths
|
||||
// collapse it to 0 (unset), so a caller that sets Burst=5 is asking for exactly
|
||||
// that default; normBurst folds the two spellings together.
|
||||
const netfilterDefaultBurst = 5
|
||||
|
||||
// normBurst folds an explicit burst of the netfilter default (5) to 0 (unset)
|
||||
// so a rule that names Burst=5 matches its own read-back, which reports the
|
||||
// default as 0.
|
||||
func normBurst(b uint) uint {
|
||||
if b == netfilterDefaultBurst {
|
||||
return 0
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// eqRateLimit reports whether two optional rate limits are equal, treating nil
|
||||
// as a distinct "unset" value. The burst is compared through normBurst so an
|
||||
// explicit default burst (5) and an unset burst (0) count as the same limit.
|
||||
func eqRateLimit(a, b *RateLimit) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return a.Rate == b.Rate && a.Unit == b.Unit && normBurst(a.Burst) == normBurst(b.Burst)
|
||||
}
|
||||
|
||||
// eqConnLimit reports whether two optional connection limits are equal, treating
|
||||
// nil as a distinct "unset" value.
|
||||
func eqConnLimit(a, b *ConnLimit) bool {
|
||||
if a == nil || b == nil {
|
||||
return a == b
|
||||
}
|
||||
return *a == *b
|
||||
}
|
||||
|
||||
// Direction names the traffic direction a default policy or rule applies to.
|
||||
type Direction uint8
|
||||
|
||||
const (
|
||||
// DirInput is the inbound (input) direction. It must remain the zero value so
|
||||
// a rule with no explicit direction is an input rule.
|
||||
DirInput Direction = iota
|
||||
// DirOutput is the outbound (output) direction.
|
||||
DirOutput
|
||||
// DirForward is the routing (forward) direction, where a backend models it.
|
||||
DirForward
|
||||
// DirAny applies to both the input and output directions. It is the direction
|
||||
// analog of FamilyAny: a backend that can store a bidirectional rule as one
|
||||
// object reads it back as DirAny, while one that cannot fans it into a concrete
|
||||
// input row plus a role-swapped output row on write (expandDirections). It never
|
||||
// covers DirForward (a routed rule has no input/output twin) and must be declared
|
||||
// last so DirInput stays the zero value.
|
||||
DirAny
|
||||
)
|
||||
|
||||
// String returns the canonical lower-case name of the direction.
|
||||
func (d Direction) String() string {
|
||||
switch d {
|
||||
case DirOutput:
|
||||
return "output"
|
||||
case DirForward:
|
||||
return "forward"
|
||||
case DirAny:
|
||||
return "any"
|
||||
}
|
||||
return "input"
|
||||
}
|
||||
|
||||
// ParseDirection parses a direction token (case-insensitive), accepting the
|
||||
// canonical name emitted by Direction.String. An unknown value is an error.
|
||||
func ParseDirection(s string) (Direction, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "input", "in":
|
||||
return DirInput, nil
|
||||
case "output", "out":
|
||||
return DirOutput, nil
|
||||
case "forward", "fwd":
|
||||
return DirForward, nil
|
||||
case "any", "both":
|
||||
return DirAny, nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown direction %q", s)
|
||||
}
|
||||
|
||||
// DefaultPolicy describes the default action a firewall applies to packets that
|
||||
// match no rule, per direction. A field left as ActionInvalid has backend-
|
||||
// defined meaning: on Get it means the backend does not expose that direction,
|
||||
// and on Set it means the direction should be left unchanged.
|
||||
type DefaultPolicy struct {
|
||||
// Input is the default action for inbound packets.
|
||||
Input Action
|
||||
// Output is the default action for outbound packets.
|
||||
Output Action
|
||||
// Forward is the default action for routed packets.
|
||||
Forward Action
|
||||
}
|
||||
|
||||
// get returns the action for a direction on a DefaultPolicy.
|
||||
func (p *DefaultPolicy) get(d Direction) Action {
|
||||
switch d {
|
||||
case DirOutput:
|
||||
return p.Output
|
||||
case DirForward:
|
||||
return p.Forward
|
||||
}
|
||||
return p.Input
|
||||
}
|
||||
|
||||
// set assigns the action for a direction on a DefaultPolicy.
|
||||
func (p *DefaultPolicy) set(d Direction, a Action) {
|
||||
switch d {
|
||||
case DirOutput:
|
||||
p.Output = a
|
||||
case DirForward:
|
||||
p.Forward = a
|
||||
default:
|
||||
p.Input = a
|
||||
}
|
||||
}
|
||||
|
||||
// SetType names the kind of entries an AddressSet holds.
|
||||
type SetType uint8
|
||||
|
||||
const (
|
||||
// SetHashIP is a set of individual IP addresses.
|
||||
SetHashIP SetType = iota
|
||||
// SetHashNet is a set of CIDR network ranges.
|
||||
SetHashNet
|
||||
)
|
||||
|
||||
// String returns the ipset-style name of the set type.
|
||||
func (t SetType) String() string {
|
||||
switch t {
|
||||
case SetHashNet:
|
||||
return "hash:net"
|
||||
}
|
||||
return "hash:ip"
|
||||
}
|
||||
|
||||
// ParseSetType parses a set-type token (case-insensitive), accepting the
|
||||
// canonical name emitted by SetType.String ("hash:ip"/"hash:net") plus the short
|
||||
// aliases "ip"/"net". An unknown value is an error.
|
||||
func ParseSetType(s string) (SetType, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(s)) {
|
||||
case "hash:ip", "ip":
|
||||
return SetHashIP, nil
|
||||
case "hash:net", "net":
|
||||
return SetHashNet, nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown set type %q", s)
|
||||
}
|
||||
|
||||
// AddressSet is a named collection of addresses (an ipset, an nftables set or a
|
||||
// pf table) that rules can match against. It is managed separately from filter
|
||||
// and NAT rules through the Manager's address-set methods.
|
||||
type AddressSet struct {
|
||||
// Name of the set. Backends that namespace sets (nftables table, pf anchor)
|
||||
// keep it within their own container.
|
||||
Name string
|
||||
// Family restricts the set to an IP family. Some backends require a concrete
|
||||
// family (nftables inet sets carry a single address type); FamilyAny is
|
||||
// resolved to IPv4 by those backends.
|
||||
Family Family
|
||||
// Type is the entry kind, defaulting to SetHashIP when zero.
|
||||
Type SetType
|
||||
// Entries are the addresses or CIDRs in the set.
|
||||
Entries []string
|
||||
}
|
||||
|
||||
// ruleLine is one line a rule materializes into in a csf.allow/csf.deny or apf
|
||||
// allow_hosts/deny_hosts file, paired with the rule that line reads back as. A rule
|
||||
// spanning a family or transport axis the native line cannot carry has no single
|
||||
// form, so it fans out into one line per cell. EditIPList marks the lines the file
|
||||
// already holds as it scans and writes only the rest, so a partially present fan-out
|
||||
// — one family written by an earlier single-family add, or a line lost to a manual
|
||||
// edit — is completed rather than left half open or duplicated wholesale.
|
||||
type ruleLine struct {
|
||||
// line is the exact text written to the list file.
|
||||
line string
|
||||
// read is the rule that line parses back to, which is what an existing line in
|
||||
// the file is compared against to decide whether the line is already present.
|
||||
read *Rule
|
||||
}
|
||||
110
types_test.go
Normal file
110
types_test.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ParseICMPType resolves a numeric or named ICMP type, selecting the ICMPv6 name
|
||||
// table when v6 is set so a name shared with ICMPv4 maps to its v6 number. It is
|
||||
// the exported resolver the CLI relies on, so its name coverage must match the
|
||||
// tables the backends emit.
|
||||
func TestParseICMPType(t *testing.T) {
|
||||
cases := []struct {
|
||||
tok string
|
||||
v6 bool
|
||||
want uint8
|
||||
ok bool
|
||||
}{
|
||||
{"8", false, 8, true}, // numeric parses in either family
|
||||
{"255", true, 255, true}, // numbers are family-independent
|
||||
{"echo-request", false, 8, true},
|
||||
{"ECHO-REQUEST", false, 8, true}, // case-insensitive
|
||||
{"echo-request", true, 128, true}, // same name, different v6 number
|
||||
{"destination-unreachable", false, 3, true},
|
||||
{"destination-unreachable", true, 1, true},
|
||||
{"nd-neighbor-solicit", true, 135, true},
|
||||
// info-request/info-reply live only in the v4 table; route them through
|
||||
// here to prove they resolve.
|
||||
{"info-request", false, 15, true},
|
||||
{"info-reply", false, 16, true},
|
||||
// A v4-only name is unknown under v6, and vice versa.
|
||||
{"source-quench", true, 0, false},
|
||||
{"packet-too-big", false, 0, false},
|
||||
{"not-a-type", false, 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
got, ok := ParseICMPType(c.tok, c.v6)
|
||||
require.Equalf(t, c.ok, ok, "ParseICMPType(%q, v6=%v) ok", c.tok, c.v6)
|
||||
if c.ok {
|
||||
require.Equalf(t, c.want, got, "ParseICMPType(%q, v6=%v)", c.tok, c.v6)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Enums render as their stable string name (not a bare number) and round-trip
|
||||
// through encoding/json. A backup must stay readable and meaningful even if an
|
||||
// iota constant is later reordered.
|
||||
func TestEnumJSON(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in any
|
||||
want string // the quoted JSON string expected
|
||||
}{
|
||||
{"action-accept", Accept, `"accept"`},
|
||||
{"action-drop", Drop, `"drop"`},
|
||||
{"action-invalid", ActionInvalid, `"invalid"`},
|
||||
{"family-v4", IPv4, `"ipv4"`},
|
||||
{"family-v6", IPv6, `"ipv6"`},
|
||||
{"family-any", FamilyAny, `"any"`},
|
||||
{"proto-tcp", TCP, `"tcp"`},
|
||||
{"proto-sctp", SCTP, `"sctp"`},
|
||||
{"proto-any", ProtocolAny, `"any"`},
|
||||
{"natkind", DNAT, `"dnat"`},
|
||||
{"rateunit", PerMinute, `"minute"`},
|
||||
{"direction", DirForward, `"forward"`},
|
||||
{"settype", SetHashNet, `"hash:net"`},
|
||||
{"connstate", ConnState(StateEstablished | StateRelated), `"established,related"`},
|
||||
{"connstate-zero", ConnState(0), `""`},
|
||||
}
|
||||
for _, c := range cases {
|
||||
out, err := json.Marshal(c.in)
|
||||
require.NoError(t, err, c.name)
|
||||
require.Equal(t, c.want, string(out), "%s: marshal", c.name)
|
||||
}
|
||||
|
||||
// Round-trip each value through marshal -> unmarshal.
|
||||
roundTrips := []struct {
|
||||
name string
|
||||
mk func() any // fresh addressable value to unmarshal into
|
||||
eq func(any) bool // reports whether it equals the marshal source
|
||||
}{
|
||||
{"action", func() any { var v Action; return &v }, func(g any) bool { return *g.(*Action) == Accept }},
|
||||
{"family", func() any { var v Family; return &v }, func(g any) bool { return *g.(*Family) == IPv4 }},
|
||||
{"proto", func() any { var v Protocol; return &v }, func(g any) bool { return *g.(*Protocol) == TCP }},
|
||||
{"connstate", func() any { var v ConnState; return &v }, func(g any) bool { return *g.(*ConnState) == (StateNew | StateEstablished) }},
|
||||
{"rateunit", func() any { var v RateUnit; return &v }, func(g any) bool { return *g.(*RateUnit) == PerHour }},
|
||||
{"natkind", func() any { var v NATKind; return &v }, func(g any) bool { return *g.(*NATKind) == Masquerade }},
|
||||
{"direction", func() any { var v Direction; return &v }, func(g any) bool { return *g.(*Direction) == DirOutput }},
|
||||
{"settype", func() any { var v SetType; return &v }, func(g any) bool { return *g.(*SetType) == SetHashIP }},
|
||||
}
|
||||
marshalVals := map[string]any{
|
||||
"action": Accept,
|
||||
"family": IPv4,
|
||||
"proto": TCP,
|
||||
"connstate": ConnState(StateNew | StateEstablished),
|
||||
"rateunit": PerHour,
|
||||
"natkind": Masquerade,
|
||||
"direction": DirOutput,
|
||||
"settype": SetHashIP,
|
||||
}
|
||||
for _, rt := range roundTrips {
|
||||
data, err := json.Marshal(marshalVals[rt.name])
|
||||
require.NoError(t, err, rt.name)
|
||||
dst := rt.mk()
|
||||
require.NoError(t, json.Unmarshal(data, dst), "%s: unmarshal %s", rt.name, data)
|
||||
require.True(t, rt.eq(dst), "%s: round-trip mismatch (got %+v)", rt.name, dst)
|
||||
}
|
||||
}
|
||||
1897
ufw_linux.go
Normal file
1897
ufw_linux.go
Normal file
File diff suppressed because it is too large
Load diff
805
ufw_linux_test.go
Normal file
805
ufw_linux_test.go
Normal file
|
|
@ -0,0 +1,805 @@
|
|||
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 physical 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 := fw.MarshalRule(rule)
|
||||
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", ""},
|
||||
// ufw stores a dual-interface route rule as one "!"-joined trailing token.
|
||||
{`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 := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCP, Source: "192.168.0.0/24", SourcePort: 1234, Port: 22, Action: Accept})
|
||||
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 needs a port-carrying protocol. TCPUDP (ufw's ported `any`,
|
||||
// tcp+udp) marshals to the native form with no proto clause; ProtocolAny (every
|
||||
// protocol) is rejected by Rule.validate, since ufw cannot match a port across every
|
||||
// protocol. A source-port range still needs a concrete tcp/udp, so a TCPUDP range
|
||||
// fans out into concrete tcp+udp rules instead of taking the tuple path.
|
||||
anySrc := fw.MarshalRule(&Rule{Family: IPv4, Proto: TCPUDP, SourcePort: 1234, Action: Accept})
|
||||
require.Contains(t, anySrc, "port 1234")
|
||||
require.NotContains(t, anySrc, "proto", "a tcpudp rule omits the proto clause")
|
||||
require.Error(t, (&Rule{Family: IPv4, Proto: ProtocolAny, SourcePort: 1234, Action: Accept}).validate(),
|
||||
"a source port across every protocol has no ufw form")
|
||||
require.True(t, fw.tcpudpNeedsExpand(&Rule{Family: IPv4, Proto: TCPUDP, SourcePorts: []PortRange{{Start: 1000, End: 2000}}, Action: Accept}),
|
||||
"a source-port range without a concrete tcp/udp must fan out")
|
||||
|
||||
// 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 203.0.113.10 in`,
|
||||
`allow tcp 4791 203.0.113.10 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
|
||||
fw.MarshalRule(orig)
|
||||
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 := fw.MarshalRule(&Rule{InInterface: "eth0", Family: IPv4, Proto: TCP, Port: 22, Action: Accept})
|
||||
require.Equal(t, "allow in on eth0 proto tcp to 0.0.0.0/0 port 22", spec, "unexpected interface marshal")
|
||||
|
||||
// Shapes the tuple form cannot hold never reach MarshalRule: ICMP and a state
|
||||
// match are routed to the before.rules files, and a TCPUDP port list — a bare
|
||||
// multiport, which ufw itself rejects — fans out into concrete tcp+udp rules.
|
||||
// A multiport ProtocolAny match ("every protocol on this port") has no
|
||||
// port-carrying transport at all, so Rule.validate rejects it outright.
|
||||
require.True(t, fw.needsIPTablesRules(&Rule{Proto: ICMP, Action: Accept}))
|
||||
require.True(t, fw.needsIPTablesRules(&Rule{Proto: TCP, Port: 22, State: StateEstablished, Action: Accept}))
|
||||
require.True(t, fw.tcpudpNeedsExpand(&Rule{Proto: TCPUDP, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}))
|
||||
require.Error(t, (&Rule{Proto: ProtocolAny, Ports: []PortRange{{Start: 80}, {Start: 443}}, Action: Accept}).validate())
|
||||
|
||||
// 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 {
|
||||
require.Equal(t, c.want, fw.MarshalRule(c.rule), "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")
|
||||
require.Equal(t, "allow in log proto tcp to 0.0.0.0/0 port 22", fw.MarshalRule(logged))
|
||||
|
||||
// The keyword follows the interface clause.
|
||||
ifLogged := &Rule{Family: IPv4, Proto: TCP, Port: 22, InInterface: "eth0", Log: true, Action: Accept}
|
||||
require.Equal(t, "allow in on eth0 log proto tcp to 0.0.0.0/0 port 22", fw.MarshalRule(ifLogged))
|
||||
|
||||
// A custom log prefix cannot be set in a tuple, so such a rule is routed raw.
|
||||
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")
|
||||
}
|
||||
|
||||
// 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 := fw.MarshalRule(r)
|
||||
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 := fw.MarshalRule(&Rule{Proto: TCP, Port: 22, Action: Accept})
|
||||
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")
|
||||
}
|
||||
|
||||
// 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 := fw.MarshalRule(&Rule{Proto: TCP, SourcePort: 1024, Action: Accept})
|
||||
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")
|
||||
}
|
||||
|
||||
// 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 carrying both the limit verb and the log keyword.
|
||||
out := f.MarshalRule(r)
|
||||
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")
|
||||
}
|
||||
|
||||
// The before.rules scan is table-scoped, as the iptables backend's is: a line on
|
||||
// a ufw chain sitting in the nat table is not a filter rule, so it is neither
|
||||
// reported by GetRules nor a removal target — a removal that dropped it would
|
||||
// silently rewrite the host's NAT block. Every untouched line, that one
|
||||
// included, streams back through the rewrite verbatim.
|
||||
func TestUFWBeforeRulesTableScoping(t *testing.T) {
|
||||
fw := new(UFW)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "before.rules")
|
||||
content := "*nat\n:POSTROUTING ACCEPT [0:0]\n" +
|
||||
"-A ufw-before-input -p icmp --icmp-type 8 -j DROP\n" +
|
||||
"COMMIT\n" +
|
||||
"# the filter section\n" +
|
||||
"*filter\n:ufw-before-input - [0:0]\n" +
|
||||
"-A ufw-before-input -p icmp --icmp-type 8 -j DROP\n" +
|
||||
"COMMIT\n"
|
||||
require.NoError(t, os.WriteFile(path, []byte(content), 0o600))
|
||||
|
||||
// Only the filter table's copy is a rule.
|
||||
rules, err := fw.ParseIPTablesRules(path, IPv4)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rules, 1, "only the *filter copy is a filter rule, got %+v", rules)
|
||||
|
||||
icmp := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Drop}
|
||||
changed, err := fw.editIPTablesRulesFile(path, icmp, IPv4, true)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
|
||||
out, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
body := string(out)
|
||||
filterAt := strings.Index(body, "*filter")
|
||||
require.Contains(t, body[:filterAt], "--icmp-type 8", "the nat table's line must survive the removal")
|
||||
require.NotContains(t, body[filterAt:], "--icmp-type 8", "the filter rule must be removed")
|
||||
require.Contains(t, body, "# the filter section", "untouched lines stream through verbatim")
|
||||
|
||||
// The staged rewrite keeps the file's own mode.
|
||||
fi, err := os.Stat(path)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, os.FileMode(0o600), fi.Mode().Perm())
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
||||
// ufw's `any` protocol means two different things depending on whether the rule
|
||||
// carries a port: on a ported rule it is tcp+udp together (TCPUDP, ufw's native
|
||||
// both-transports form), and on a portless rule it is every IP protocol
|
||||
// (ProtocolAny). TestUFWTCPUDPMarshal exercises the write side of that split.
|
||||
func TestUFWTCPUDPMarshal(t *testing.T) {
|
||||
fw := new(UFW)
|
||||
|
||||
// A single-port TCPUDP rule emits ufw's bare-port short form (proto rides along as
|
||||
// ufw's `any`, so no `/proto` suffix and no proto clause).
|
||||
spec := fw.MarshalRule(&Rule{Port: 80, Proto: TCPUDP, Action: Accept})
|
||||
require.Equal(t, "allow in 80", spec, "a single-port TCPUDP rule uses ufw's bare-port short form")
|
||||
|
||||
// The same port with ProtocolAny is rejected at the entry points: a port across
|
||||
// every protocol has no ufw form.
|
||||
require.Error(t, (&Rule{Port: 80, Proto: ProtocolAny, Action: Accept}).validate(),
|
||||
"a ProtocolAny rule carrying a port has no ufw form")
|
||||
|
||||
// A portless ProtocolAny rule is ufw's real `any` and still marshals fine.
|
||||
spec = fw.MarshalRule(&Rule{Proto: ProtocolAny, Source: "1.2.3.4", Action: Accept})
|
||||
require.Contains(t, spec, "from 1.2.3.4")
|
||||
require.NotContains(t, spec, "proto", "ufw's `any` protocol carries no proto clause")
|
||||
|
||||
// A family-specific single-port TCPUDP rule uses the full grammar with no proto
|
||||
// clause (ufw's `any` on a ported rule), covering both tcp and udp.
|
||||
spec = fw.MarshalRule(&Rule{Family: IPv4, Port: 80, Proto: TCPUDP, Action: Accept})
|
||||
require.Equal(t, "allow in to 0.0.0.0/0 port 80", spec)
|
||||
}
|
||||
|
||||
// TestUFWTCPUDPTupleRoundTrip verifies the read side of ufw's `any`-protocol split:
|
||||
// an `any` tuple with a port decodes to TCPUDP, one without a port to ProtocolAny.
|
||||
func TestUFWTCPUDPTupleRoundTrip(t *testing.T) {
|
||||
fw := new(UFW)
|
||||
|
||||
// A ported `any` tuple is tcp+udp together.
|
||||
ported, err := fw.UnmarshalRule("allow any 80 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, TCPUDP, ported.Proto, "a ported `any` tuple decodes to TCPUDP")
|
||||
require.EqualValues(t, 80, ported.Port)
|
||||
|
||||
// A source-port-only `any` tuple is likewise tcp+udp.
|
||||
sported, err := fw.UnmarshalRule("allow any any 0.0.0.0/0 1024 0.0.0.0/0 in", IPv4)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, TCPUDP, sported.Proto, "an `any` tuple with a source port decodes to TCPUDP")
|
||||
require.True(t, sported.HasSourcePorts(), "the source port must be parsed")
|
||||
require.EqualValues(t, 1024, sported.SourcePort)
|
||||
|
||||
// A portless `any` tuple is every IP protocol.
|
||||
portless, err := fw.UnmarshalRule("allow any any 0.0.0.0/0 any 1.2.3.4 in", IPv4)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ProtocolAny, portless.Proto, "a portless `any` tuple decodes to ProtocolAny")
|
||||
require.Equal(t, "1.2.3.4", portless.Source)
|
||||
|
||||
// A native single-port TCPUDP rule round-trips through marshal + unmarshal.
|
||||
require.Equal(t, "allow in to 0.0.0.0/0 port 443",
|
||||
fw.MarshalRule(&Rule{Family: IPv4, Port: 443, Proto: TCPUDP, Action: Accept}))
|
||||
}
|
||||
|
||||
// Every modeled tuple is its own rule, so a logical position maps straight to ufw's
|
||||
// native slot. An unmodeled tuple (a route rule, kept as a nil row) still occupies a
|
||||
// physical slot and shifts the ones after it.
|
||||
func TestUFWNativeInsertPositionSkipsUnmodeledRows(t *testing.T) {
|
||||
fw := new(UFW)
|
||||
tcp80 := &Rule{Family: IPv4, Proto: TCP, Port: 80, Action: Accept}
|
||||
udp80 := &Rule{Family: IPv4, Proto: UDP, Port: 80, Action: Accept}
|
||||
tcp22 := &Rule{Family: IPv4, Proto: TCP, Port: 22, Action: Accept}
|
||||
|
||||
// With every tuple modeled, position N is native slot N.
|
||||
rows := []*Rule{tcp80, udp80, tcp22}
|
||||
require.Equal(t, 1, fw.nativeInsertPositionFromRows(rows, 1))
|
||||
require.Equal(t, 2, fw.nativeInsertPositionFromRows(rows, 2))
|
||||
require.Equal(t, 3, fw.nativeInsertPositionFromRows(rows, 3))
|
||||
require.Equal(t, 4, fw.nativeInsertPositionFromRows(rows, 4),
|
||||
"past the end appends past the last physical tuple")
|
||||
|
||||
// A route rule ufw counts but this backend cannot model sits at physical slot 2,
|
||||
// so the second reported rule lives at slot 3.
|
||||
withRoute := []*Rule{tcp80, nil, udp80, tcp22}
|
||||
require.Equal(t, 1, fw.nativeInsertPositionFromRows(withRoute, 1))
|
||||
require.Equal(t, 3, fw.nativeInsertPositionFromRows(withRoute, 2),
|
||||
"the unmodeled route tuple shifts the native slot")
|
||||
require.Equal(t, 4, fw.nativeInsertPositionFromRows(withRoute, 3))
|
||||
require.Equal(t, 5, fw.nativeInsertPositionFromRows(withRoute, 4))
|
||||
}
|
||||
|
||||
// TestUFWEditIPTablesRulesAnchorsFilterCommit pins that the raw-path add splices
|
||||
// its rule into the *filter section's COMMIT, not the file's first COMMIT: the
|
||||
// canonical ufw NAT setup puts a *nat block above *filter, and a filter rule in
|
||||
// that block references an undeclared chain, failing the reload.
|
||||
func TestUFWEditIPTablesRulesAnchorsFilterCommit(t *testing.T) {
|
||||
fw := new(UFW)
|
||||
natFirst := "*nat\n:POSTROUTING ACCEPT [0:0]\n-A POSTROUTING -s 10.0.0.0/8 -o eth0 -j MASQUERADE\nCOMMIT\n" +
|
||||
"*filter\n:ufw-before-input - [0:0]\nCOMMIT\n"
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "before.rules")
|
||||
require.NoError(t, os.WriteFile(path, []byte(natFirst), 0644))
|
||||
|
||||
icmp := &Rule{Proto: ICMP, ICMPType: Ptr[uint8](8), Action: Accept}
|
||||
changed, err := fw.editIPTablesRulesFile(path, icmp, IPv4, false)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
body := string(data)
|
||||
natEnd := strings.Index(body, "*filter")
|
||||
require.NotContains(t, body[:natEnd], "--icmp-type",
|
||||
"the rule must not land inside the *nat block")
|
||||
require.Contains(t, body[natEnd:], "--icmp-type",
|
||||
"the rule must land inside the *filter block")
|
||||
// The rule sits before *filter's COMMIT.
|
||||
filterPart := body[natEnd:]
|
||||
require.Less(t, strings.Index(filterPart, "--icmp-type"), strings.Index(filterPart, "COMMIT"))
|
||||
}
|
||||
|
||||
// TestUFWOldFormatTupleStaysOpaque pins that ufw's old 8-field storage format
|
||||
// (six core fields plus dapp/sapp, no direction field) is rejected by the tuple
|
||||
// parser, so parseTupleRows holds it as a nil row rather than mis-reading it.
|
||||
func TestUFWOldFormatTupleStaysOpaque(t *testing.T) {
|
||||
fw := new(UFW)
|
||||
_, err := fw.UnmarshalRule("allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 Apache -", IPv4)
|
||||
require.Error(t, err, "the old 8-field format must stay opaque")
|
||||
|
||||
// A non-route tuple must not carry a "!"-joined interface pair.
|
||||
_, err = fw.UnmarshalRule("allow tcp 80 0.0.0.0/0 any 0.0.0.0/0 in_eth0!out_eth1", IPv4)
|
||||
require.Error(t, err, "a dual-interface token is only valid on a route rule")
|
||||
}
|
||||
|
||||
// ufwLiveSave is a trimmed `iptables-save -c -t filter` capture from a host
|
||||
// running ufw, covering each shape ufw expands a rule into: a plain action line,
|
||||
// the tcp+udp pair a ported `any` tuple becomes, the logging-chain jump that
|
||||
// precedes a logged rule's action line, the three-line `limit` sequence, and a
|
||||
// raw before.rules rule written as a LOG line plus its action line.
|
||||
var ufwLiveSave = []string{
|
||||
"# Generated by iptables-save",
|
||||
"*filter",
|
||||
":INPUT DROP [0:0]",
|
||||
":ufw-user-input - [0:0]",
|
||||
"[9:540] -A INPUT -j ufw-before-input",
|
||||
"[3:180] -A ufw-before-output -p tcp -m tcp --dport 39323 -m comment --comment gofw -j LOG --log-prefix \"probe: \"",
|
||||
"[3:180] -A ufw-before-output -p tcp -m tcp --dport 39323 -m comment --comment gofw -j ACCEPT",
|
||||
"[7:420] -A ufw-user-input -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --set --name DEFAULT --mask 255.255.255.255 --rsource",
|
||||
"[1:60] -A ufw-user-input -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --update --seconds 30 --hitcount 6 --name DEFAULT --mask 255.255.255.255 --rsource -j ufw-user-limit",
|
||||
"[6:360] -A ufw-user-input -p tcp -m tcp --dport 22 -j ufw-user-limit-accept",
|
||||
"[2:120] -A ufw-user-input -p tcp -m tcp --dport 53 -j ACCEPT",
|
||||
"[5:405] -A ufw-user-input -p udp -m udp --dport 53 -j ACCEPT",
|
||||
"[4:240] -A ufw-user-input -p tcp -m tcp --dport 8080 -j ufw-user-logging-input",
|
||||
"[4:240] -A ufw-user-input -p tcp -m tcp --dport 8080 -j ACCEPT",
|
||||
"[4:240] -A ufw-user-logging-input -p tcp -m tcp --dport 8080 -m conntrack --ctstate NEW -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix \"[UFW ALLOW] \"",
|
||||
"[4:240] -A ufw-user-logging-input -p tcp -m tcp --dport 8080 -j RETURN",
|
||||
"[6:360] -A ufw-user-limit-accept -j ACCEPT",
|
||||
"COMMIT",
|
||||
}
|
||||
|
||||
// TestUFWParseLiveRules verifies the live ruleset is read back as the rules that
|
||||
// produced it: ufw's logging-chain jump and its three-line `limit` sequence are
|
||||
// folded back into the single rule each stands for, its internal chains and
|
||||
// accounting rows contribute nothing, and every row keeps the kernel's counters.
|
||||
func TestUFWParseLiveRules(t *testing.T) {
|
||||
fw := new(UFW)
|
||||
rules := fw.parseLiveRules(ufwLiveSave, IPv4)
|
||||
require.Len(t, rules, 5, "the internal-chain rows and the limit accounting rows model no rule of their own")
|
||||
|
||||
// The raw before.rules rule: its LOG line and action line are one logged rule
|
||||
// carrying the action line's counters.
|
||||
require.True(t, rules[0].Log)
|
||||
require.Equal(t, "probe: ", rules[0].LogPrefix)
|
||||
require.EqualValues(t, 39323, rules[0].Port)
|
||||
require.EqualValues(t, 3, rules[0].Packets)
|
||||
require.EqualValues(t, 180, rules[0].Bytes)
|
||||
|
||||
// The `limit` tuple: read back as an accept carrying ufw's built-in rate, with
|
||||
// the counters of the row that accepts.
|
||||
require.EqualValues(t, 22, rules[1].Port)
|
||||
require.Equal(t, Accept, rules[1].Action)
|
||||
require.NotNil(t, rules[1].RateLimit)
|
||||
require.Equal(t, fw.nativeLimit(), *rules[1].RateLimit)
|
||||
require.EqualValues(t, 6, rules[1].Packets)
|
||||
|
||||
// The ported `any` tuple stays two rows here; only the merge sums them.
|
||||
require.Equal(t, TCP, rules[2].Proto)
|
||||
require.EqualValues(t, 2, rules[2].Packets)
|
||||
require.Equal(t, UDP, rules[3].Proto)
|
||||
require.EqualValues(t, 5, rules[3].Packets)
|
||||
|
||||
// The logged tuple: the jump into ufw's logging chain sets Log on the action
|
||||
// row beneath it, and ufw's own log prefix is not a rule field.
|
||||
require.EqualValues(t, 8080, rules[4].Port)
|
||||
require.True(t, rules[4].Log)
|
||||
require.Empty(t, rules[4].LogPrefix)
|
||||
require.EqualValues(t, 4, rules[4].Packets)
|
||||
}
|
||||
|
||||
// TestUFWApplyCounters verifies counters land on the rule that owns them: an
|
||||
// exact match wins over a wider rule, a ported `any` tuple sums the tcp and udp
|
||||
// rows ufw writes for it, and a rule with no live row keeps zero.
|
||||
func TestUFWApplyCounters(t *testing.T) {
|
||||
fw := new(UFW)
|
||||
live := fw.parseLiveRules(ufwLiveSave, IPv4)
|
||||
|
||||
limit := fw.nativeLimit()
|
||||
targets := []*Rule{
|
||||
{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 22, Action: Accept, RateLimit: &limit},
|
||||
{Direction: DirInput, Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept},
|
||||
{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 8080, Action: Accept, Log: true},
|
||||
{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 9999, Action: Accept},
|
||||
}
|
||||
applyLiveCounters(targets, live)
|
||||
|
||||
require.EqualValues(t, 6, targets[0].Packets, "a limit rule counts the row that accepts")
|
||||
require.EqualValues(t, 7, targets[1].Packets, "a ported any tuple sums its tcp and udp rows")
|
||||
require.EqualValues(t, 525, targets[1].Bytes)
|
||||
require.EqualValues(t, 4, targets[2].Packets, "a logged rule counts its action row")
|
||||
require.Zero(t, targets[3].Packets, "a rule the live ruleset does not hold keeps zero counters")
|
||||
}
|
||||
|
||||
// TestUFWApplyCountersExactMatchWins verifies a rule that has a row of its own is
|
||||
// matched to it before a wider rule can absorb that row, so the narrow rule is
|
||||
// not left at zero.
|
||||
func TestUFWApplyCountersExactMatchWins(t *testing.T) {
|
||||
fw := new(UFW)
|
||||
live := fw.parseLiveRules(ufwLiveSave, IPv4)
|
||||
|
||||
wide := &Rule{Direction: DirInput, Family: IPv4, Proto: TCPUDP, Port: 53, Action: Accept}
|
||||
narrow := &Rule{Direction: DirInput, Family: IPv4, Proto: TCP, Port: 53, Action: Accept}
|
||||
applyLiveCounters([]*Rule{wide, narrow}, live)
|
||||
|
||||
require.EqualValues(t, 2, narrow.Packets, "the tcp rule keeps the tcp row it matches exactly")
|
||||
require.EqualValues(t, 5, wide.Packets, "the wider rule sums only the rows left over")
|
||||
}
|
||||
|
||||
// TestUFWParseLiveRulesLimitLog verifies a `limit_log` tuple, whose live rows put
|
||||
// ufw's logging jump above the three-line rate-limit sequence, is read back as
|
||||
// one logged and rate-limited rule: the accounting row between them carries no
|
||||
// jump and must not consume the pending logging jump.
|
||||
func TestUFWParseLiveRulesLimitLog(t *testing.T) {
|
||||
fw := new(UFW)
|
||||
out := []string{
|
||||
"[4:240] -A ufw-user-input -p tcp -m tcp --dport 22 -j ufw-user-logging-input",
|
||||
"[4:240] -A ufw-user-input -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --set --name DEFAULT --mask 255.255.255.255 --rsource",
|
||||
"[1:60] -A ufw-user-input -p tcp -m tcp --dport 22 -m conntrack --ctstate NEW -m recent --update --seconds 30 --hitcount 6 --name DEFAULT --mask 255.255.255.255 --rsource -j ufw-user-limit",
|
||||
"[3:180] -A ufw-user-input -p tcp -m tcp --dport 22 -j ufw-user-limit-accept",
|
||||
}
|
||||
rules := fw.parseLiveRules(out, IPv4)
|
||||
require.Len(t, rules, 1)
|
||||
require.True(t, rules[0].Log, "the logging jump above the rate-limit sequence still marks the rule as logged")
|
||||
require.NotNil(t, rules[0].RateLimit)
|
||||
require.Equal(t, fw.nativeLimit(), *rules[0].RateLimit)
|
||||
require.EqualValues(t, 3, rules[0].Packets, "a limit rule counts the row that accepts")
|
||||
|
||||
// The tuple ufw stores for that rule must match the row it was read back as.
|
||||
tuple, err := fw.UnmarshalRule("limit_log tcp 22 0.0.0.0/0 any 0.0.0.0/0 in", IPv4)
|
||||
require.NoError(t, err)
|
||||
applyLiveCounters([]*Rule{tuple}, rules)
|
||||
require.EqualValues(t, 3, tuple.Packets)
|
||||
require.EqualValues(t, 180, tuple.Bytes)
|
||||
}
|
||||
238
utils.go
Normal file
238
utils.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
func trimQuotes(s string) string {
|
||||
return strings.Trim(s, "\"'")
|
||||
}
|
||||
|
||||
// stripUnquotedComment removes a trailing '#' comment from a config line,
|
||||
// ignoring a '#' inside a quoted value so `KEY = "pre#fix"` is not truncated.
|
||||
func stripUnquotedComment(line string) string {
|
||||
var inQuote byte
|
||||
for i := 0; i < len(line); i++ {
|
||||
switch c := line[i]; {
|
||||
case inQuote != 0:
|
||||
if c == inQuote {
|
||||
inQuote = 0
|
||||
}
|
||||
case c == '"' || c == '\'':
|
||||
inQuote = c
|
||||
case c == '#':
|
||||
return line[:i]
|
||||
}
|
||||
}
|
||||
return line
|
||||
}
|
||||
|
||||
// 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. The last
|
||||
// assignment wins, matching how the shell sources these files. 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() }()
|
||||
value := ""
|
||||
scanner := bufio.NewScanner(fd)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(stripUnquotedComment(scanner.Text()))
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
k, v, found := strings.Cut(line, "=")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(k) == key {
|
||||
value = trimQuotes(strings.TrimSpace(v))
|
||||
}
|
||||
}
|
||||
return value, scanner.Err()
|
||||
}
|
||||
|
||||
// binSearchDirs are the directories searched for a firewall tool when PATH does
|
||||
// not resolve it, covering the standard locations iptables, ip6tables, ipset, nft
|
||||
// and the backend front-ends install into. The tools live in the sbin
|
||||
// directories, and a process started from a service unit, a cron job or a login
|
||||
// shell for an unprivileged user routinely gets a PATH without them.
|
||||
var binSearchDirs = []string{"/usr/sbin", "/sbin", "/usr/local/sbin", "/usr/bin", "/bin", "/usr/local/bin"}
|
||||
|
||||
// resolvedBins memoizes successful lookups: every command run resolves its tool
|
||||
// and an installed tool does not move while the process runs. A failed lookup is
|
||||
// deliberately not cached, so a tool installed under a long-lived process is
|
||||
// picked up on its next use.
|
||||
var resolvedBins sync.Map
|
||||
|
||||
// resolveBinary returns the absolute path of a firewall tool and reports whether
|
||||
// it was found. PATH wins; failing that the standard install directories are
|
||||
// searched (binSearchDirs). A name that already carries a path separator is taken
|
||||
// as given. An unresolved name is returned unchanged, leaving the caller to hand
|
||||
// it to exec (for its own error) or to a shell.
|
||||
func resolveBinary(name string) (string, bool) {
|
||||
if name == "" {
|
||||
return name, false
|
||||
}
|
||||
if strings.ContainsRune(name, os.PathSeparator) {
|
||||
return name, true
|
||||
}
|
||||
if cached, ok := resolvedBins.Load(name); ok {
|
||||
return cached.(string), true
|
||||
}
|
||||
if p, err := exec.LookPath(name); err == nil {
|
||||
if abs, err := filepath.Abs(p); err == nil {
|
||||
p = abs
|
||||
}
|
||||
resolvedBins.Store(name, p)
|
||||
return p, true
|
||||
}
|
||||
// PATH missed it — the tool may still be installed where the tools live.
|
||||
for _, dir := range binSearchDirs {
|
||||
cand := filepath.Join(dir, name)
|
||||
if fi, err := os.Stat(cand); err == nil && fi.Mode().IsRegular() && fi.Mode()&0111 != 0 {
|
||||
resolvedBins.Store(name, cand)
|
||||
return cand, true
|
||||
}
|
||||
}
|
||||
return name, false
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Resolve the tool up front so a PATH without the sbin directories does not
|
||||
// turn every backend call into an executable-not-found error.
|
||||
bin, _ := resolveBinary(command)
|
||||
cmd := exec.CommandContext(ctx, bin, 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 {
|
||||
// Keep the underlying error wrapped so its exit code stays reachable
|
||||
// through errors.As, and only mention stdout when there is any.
|
||||
stderrText := strings.TrimSpace(stderrData.String())
|
||||
switch {
|
||||
case stderrText != "" && len(out) > 0:
|
||||
err = fmt.Errorf("%s (stdout %s): %w", stderrText, strings.Join(out, "\n"), err)
|
||||
case stderrText != "":
|
||||
err = fmt.Errorf("%s: %w", stderrText, err)
|
||||
}
|
||||
// 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
|
||||
}
|
||||
1004
wf_windows.go
Normal file
1004
wf_windows.go
Normal file
File diff suppressed because it is too large
Load diff
76
wf_windows_test.go
Normal file
76
wf_windows_test.go
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
package firewall
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue