go-firewall/integration_linux_test.go
James Coleman 5095d90fa4 Advertise capability flags and harden backend semantics
- New Capabilities: PortPair, Negation, RejectAction,
  FamilyWithoutAddress, DenyActionFromConfig, advertised per backend.
- coversDirection isolates DirForward even when output is unowned;
  add splitNATDualRow so a concrete-family removal re-adds the opposite
  family's NAT translation.
- Resolve ip6tables/ufw ICMPv6 type aliases; ParseNATKind rejects the
  "invalid" sentinel as input while JSON round-trips it.
- Sync counts additions on mid-batch failure and uses RuleBatcher.
- NewManager runs a probe loop joining each backend's reason for
  diagnosability; services.go drops "generated" from enabled, handles it
  on enable, clears start-limit-hit on restart, and matches rc.local by
  token.
- nftables: per-source connection limits (meter set), quoted-token
  parsing preserving log-prefix spacing, digit-led prefix sanitizing.
- apf/csf: deny-action-from-config with cached STOP settings, port lists
  and inexpressible shapes routed through the pre-hook, confKeyApplies
  guard against a missing config line.
- atomic config writes fsync before rename and resolve symlinks;
  readConfValue is last-assignment-wins; runCommand preserves the exit
  code through the wrapped error.
- Move coreos/go-systemd to the maintained v22 module directly.
2026-07-13 17:50:43 -05:00

302 lines
11 KiB
Go

//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())
}
// hookPlanter returns a function that writes a rule directly into the backend's
// raw-iptables pre-hook, standing in for a copy a customer added by hand, or nil when
// the backend has no pre-hook. Only csf and apf carry one. 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 hookPlanter(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 }
}
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"
cmds := [][]string{
{"add", "table", "ip", table},
{"add", "chain", "ip", table, "input", "{", "type", "filter", "hook", "input", "priority", "0", ";", "policy", "accept", ";", "}"},
{"add", "rule", "ip", table, "input", "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, 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
}