- 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.
302 lines
11 KiB
Go
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
|
|
}
|