519 lines
16 KiB
Go
519 lines
16 KiB
Go
//go:build firewalld_integration
|
|
|
|
// Integration tests exercised against a live firewalld over the system bus. They
|
|
// are gated behind the firewalld_integration build tag so the default `go test`
|
|
// stays bus-free. Run them on each target server (they need firewalld and root):
|
|
//
|
|
// go test -c -tags firewalld_integration -o firewalld.itest .
|
|
// scp firewalld.itest root@<host>:/tmp/ && ssh root@<host> /tmp/firewalld.itest -test.v
|
|
//
|
|
// Safety: every test operates on a throwaway zone/ipset/policy it creates and
|
|
// removes. Nothing here touches the default zone, real interfaces, panic mode, or
|
|
// lockdown, so a failure cannot cut the SSH session.
|
|
package firewalld
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"os"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// testCtx returns a context bounded to keep a hung call from stalling the suite.
|
|
func testCtx(t *testing.T) context.Context {
|
|
t.Helper()
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
t.Cleanup(cancel)
|
|
return ctx
|
|
}
|
|
|
|
// dial connects to the live firewalld or skips the test if unavailable.
|
|
func dial(t *testing.T) *Conn {
|
|
t.Helper()
|
|
c, err := Connect(testCtx(t))
|
|
if err != nil {
|
|
t.Skipf("no firewalld on the system bus: %v", err)
|
|
}
|
|
t.Cleanup(func() { c.Close() })
|
|
return c
|
|
}
|
|
|
|
// uniqueName derives a collision-resistant throwaway name.
|
|
func uniqueName(prefix string) string {
|
|
return fmt.Sprintf("%s%d", prefix, os.Getpid())
|
|
}
|
|
|
|
func TestIntegrationConnect(t *testing.T) {
|
|
c := dial(t)
|
|
v := c.Version()
|
|
t.Logf("firewalld version %s (major=%d minor=%d)", v.Raw, v.Major, v.Minor)
|
|
assert.NotEmpty(t, v.Raw)
|
|
t.Logf("caps: dict=%v policies=%v svc2=%v reset=%v",
|
|
c.Supports(DictZoneSettings), c.Supports(Policies),
|
|
c.Supports(ServiceSettings2), c.Supports(ResetToDefaults))
|
|
}
|
|
|
|
func TestIntegrationDefaultZoneAndList(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
|
|
dz, err := c.DefaultZone(ctx)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, dz)
|
|
t.Logf("default zone: %s", dz)
|
|
|
|
names, err := c.Permanent().ZoneNames(ctx)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, names, "public")
|
|
}
|
|
|
|
// TestIntegrationZoneSettingsTransport reads the public zone through whichever
|
|
// transport the server supports (tuple on EL7, dict on EL9+) and sanity-checks it.
|
|
func TestIntegrationZoneSettingsTransport(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
|
|
s, err := c.Permanent().Zone("public").Settings(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Public", s.Short)
|
|
assert.Equal(t, TargetDefault, s.Target)
|
|
t.Logf("public services=%v ports=%v forward=%v", s.Services, s.Ports, s.Forward)
|
|
|
|
// The runtime read path too.
|
|
rs, err := c.Runtime().Settings(ctx, "public")
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "Public", rs.Short)
|
|
}
|
|
|
|
// TestIntegrationPermanentZoneLifecycle is the core round-trip: create a zone,
|
|
// mutate every element kind, read it back through Settings, then remove it.
|
|
func TestIntegrationPermanentZoneLifecycle(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
perm := c.Permanent()
|
|
name := uniqueName("gofwz")
|
|
|
|
// Clean any leftover from a previous crashed run, then create fresh.
|
|
_ = perm.Zone(name).Remove(ctx)
|
|
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name, Target: TargetDefault})
|
|
require.NoError(t, err, "addZone")
|
|
t.Cleanup(func() {
|
|
_ = perm.Zone(name).Remove(context.Background())
|
|
_ = c.Reload(context.Background())
|
|
})
|
|
|
|
z := perm.Zone(name)
|
|
require.NoError(t, z.SetTarget(ctx, TargetDROP))
|
|
require.NoError(t, z.AddPort(ctx, Port{Port: "4242", Protocol: "udp"}))
|
|
require.NoError(t, z.AddPort(ctx, Port{Port: "8080", Protocol: "tcp"}))
|
|
require.NoError(t, z.AddSourcePort(ctx, Port{Port: "1024", Protocol: "tcp"}))
|
|
require.NoError(t, z.AddSource(ctx, "10.9.8.0/24"))
|
|
require.NoError(t, z.AddService(ctx, "ssh"))
|
|
require.NoError(t, z.AddProtocol(ctx, "gre"))
|
|
require.NoError(t, z.AddMasquerade(ctx))
|
|
require.NoError(t, z.AddForwardPort(ctx, ForwardPort{Port: "80", Protocol: "tcp", ToPort: "8080", ToAddr: "10.9.8.5"}))
|
|
require.NoError(t, z.AddRichRule(ctx, `rule family="ipv4" source address="10.9.8.0/24" reject`))
|
|
|
|
// Read everything back through the settings decoder.
|
|
s, err := z.Settings(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, TargetDROP, s.Target)
|
|
assert.Contains(t, s.Ports, Port{Port: "4242", Protocol: "udp"})
|
|
assert.Contains(t, s.Ports, Port{Port: "8080", Protocol: "tcp"})
|
|
assert.Contains(t, s.SourcePorts, Port{Port: "1024", Protocol: "tcp"})
|
|
assert.Contains(t, s.Sources, "10.9.8.0/24")
|
|
assert.Contains(t, s.Services, "ssh")
|
|
assert.Contains(t, s.Protocols, "gre")
|
|
assert.True(t, s.Masquerade)
|
|
assert.Contains(t, s.ForwardPorts, ForwardPort{Port: "80", Protocol: "tcp", ToPort: "8080", ToAddr: "10.9.8.5"})
|
|
assert.Len(t, s.RichRules, 1)
|
|
|
|
// Query helpers agree with the settings snapshot.
|
|
ok, err := z.QueryPort(ctx, Port{Port: "4242", Protocol: "udp"})
|
|
require.NoError(t, err)
|
|
assert.True(t, ok)
|
|
|
|
// Remove a port and confirm it is gone.
|
|
require.NoError(t, z.RemovePort(ctx, Port{Port: "8080", Protocol: "tcp"}))
|
|
ok, err = z.QueryPort(ctx, Port{Port: "8080", Protocol: "tcp"})
|
|
require.NoError(t, err)
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
// TestIntegrationUpdateWholesale verifies the Update path (tuple or dict) writes a
|
|
// full settings struct that reads back intact.
|
|
func TestIntegrationUpdateWholesale(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
perm := c.Permanent()
|
|
name := uniqueName("gofwu")
|
|
|
|
_ = perm.Zone(name).Remove(ctx)
|
|
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
_ = perm.Zone(name).Remove(context.Background())
|
|
_ = c.Reload(context.Background())
|
|
})
|
|
|
|
want := ZoneSettings{
|
|
Short: name,
|
|
Description: "wholesale update",
|
|
Target: TargetACCEPT,
|
|
Services: []string{"ssh", "http"},
|
|
Ports: []Port{{Port: "5353", Protocol: "udp"}},
|
|
Masquerade: true,
|
|
}
|
|
require.NoError(t, perm.Zone(name).Update(ctx, want))
|
|
|
|
got, err := perm.Zone(name).Settings(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, TargetACCEPT, got.Target)
|
|
assert.Equal(t, "wholesale update", got.Description)
|
|
assert.ElementsMatch(t, want.Services, got.Services)
|
|
assert.Contains(t, got.Ports, Port{Port: "5353", Protocol: "udp"})
|
|
assert.True(t, got.Masquerade)
|
|
}
|
|
|
|
// TestIntegrationRuntimeZone exercises transient edits with a timeout against a
|
|
// throwaway zone that has been reloaded into the runtime.
|
|
func TestIntegrationRuntimeZone(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
perm := c.Permanent()
|
|
name := uniqueName("gofwr")
|
|
|
|
_ = perm.Zone(name).Remove(ctx)
|
|
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name})
|
|
require.NoError(t, err)
|
|
require.NoError(t, c.Reload(ctx)) // materialise the zone at runtime
|
|
t.Cleanup(func() {
|
|
_ = perm.Zone(name).Remove(context.Background())
|
|
_ = c.Reload(context.Background())
|
|
})
|
|
|
|
rz := c.Runtime().Zone(name)
|
|
require.NoError(t, rz.AddPort(ctx, Port{Port: "7000", Protocol: "tcp"}, 0))
|
|
ports, err := rz.Ports(ctx)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, ports, Port{Port: "7000", Protocol: "tcp"})
|
|
|
|
ok, err := rz.QueryPort(ctx, Port{Port: "7000", Protocol: "tcp"})
|
|
require.NoError(t, err)
|
|
assert.True(t, ok)
|
|
|
|
require.NoError(t, rz.RemovePort(ctx, Port{Port: "7000", Protocol: "tcp"}))
|
|
}
|
|
|
|
// TestIntegrationIdempotency confirms firewalld's idempotency signals map to the
|
|
// sentinels callers rely on.
|
|
func TestIntegrationIdempotency(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
perm := c.Permanent()
|
|
name := uniqueName("gofwi")
|
|
|
|
_ = perm.Zone(name).Remove(ctx)
|
|
_, err := perm.AddZone(ctx, name, ZoneSettings{Short: name})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
_ = perm.Zone(name).Remove(context.Background())
|
|
_ = c.Reload(context.Background())
|
|
})
|
|
|
|
z := perm.Zone(name)
|
|
require.NoError(t, z.AddPort(ctx, Port{Port: "9999", Protocol: "tcp"}))
|
|
|
|
// Adding the same port again is ALREADY_ENABLED.
|
|
err = z.AddPort(ctx, Port{Port: "9999", Protocol: "tcp"})
|
|
assert.ErrorIs(t, err, ErrAlreadyEnabled)
|
|
|
|
// Removing a port that is not present is NOT_ENABLED.
|
|
err = z.RemovePort(ctx, Port{Port: "1234", Protocol: "tcp"})
|
|
assert.ErrorIs(t, err, ErrNotEnabled)
|
|
}
|
|
|
|
// TestIntegrationErrorMapping checks a precise INVALID_* mapping.
|
|
func TestIntegrationErrorMapping(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
|
|
_, err := c.Permanent().Zone("definitely-not-a-zone").Settings(ctx)
|
|
assert.Error(t, err)
|
|
assert.ErrorIs(t, err, ErrInvalidZone)
|
|
}
|
|
|
|
func TestIntegrationIPSetLifecycle(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
perm := c.Permanent()
|
|
name := uniqueName("gofwset")
|
|
|
|
_ = perm.IPSet(name).Remove(ctx)
|
|
_, err := perm.AddIPSet(ctx, name, IPSetSettings{
|
|
Type: "hash:ip",
|
|
Options: map[string]string{"family": "inet"},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
_ = perm.IPSet(name).Remove(context.Background())
|
|
_ = c.Reload(context.Background())
|
|
})
|
|
|
|
set := perm.IPSet(name)
|
|
require.NoError(t, set.AddEntry(ctx, "10.7.7.1"))
|
|
require.NoError(t, set.AddEntry(ctx, "10.7.7.2"))
|
|
|
|
entries, err := set.Entries(ctx)
|
|
require.NoError(t, err)
|
|
assert.ElementsMatch(t, []string{"10.7.7.1", "10.7.7.2"}, entries)
|
|
|
|
s, err := set.Settings(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "hash:ip", s.Type)
|
|
assert.Equal(t, "inet", s.Options["family"])
|
|
|
|
require.NoError(t, set.RemoveEntry(ctx, "10.7.7.1"))
|
|
ok, err := set.QueryEntry(ctx, "10.7.7.1")
|
|
require.NoError(t, err)
|
|
assert.False(t, ok)
|
|
}
|
|
|
|
func TestIntegrationServiceRead(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
|
|
s, err := c.ServiceSettings(ctx, "ssh")
|
|
require.NoError(t, err)
|
|
assert.Contains(t, s.Ports, Port{Port: "22", Protocol: "tcp"})
|
|
}
|
|
|
|
func TestIntegrationICMPTypeRead(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
|
|
names, err := c.ICMPTypes(ctx)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, names)
|
|
|
|
s, err := c.ICMPTypeSettings(ctx, "echo-request")
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, s.Short)
|
|
}
|
|
|
|
// TestIntegrationPolicyLifecycle is skipped on servers without policy support.
|
|
func TestIntegrationPolicyLifecycle(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
if !c.Supports(Policies) {
|
|
t.Skip("server predates zone-to-zone policies")
|
|
}
|
|
perm := c.Permanent()
|
|
name := uniqueName("gofwp")
|
|
|
|
_ = perm.Policy(name).Remove(ctx)
|
|
_, err := perm.AddPolicy(ctx, name, PolicySettings{
|
|
Target: "CONTINUE",
|
|
Priority: -1,
|
|
IngressZones: []string{"public"},
|
|
EgressZones: []string{"HOST"},
|
|
Ports: []Port{{Port: "6000", Protocol: "tcp"}},
|
|
})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
_ = perm.Policy(name).Remove(context.Background())
|
|
_ = c.Reload(context.Background())
|
|
})
|
|
|
|
s, err := perm.Policy(name).Settings(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "CONTINUE", s.Target)
|
|
assert.Equal(t, int32(-1), s.Priority)
|
|
assert.Contains(t, s.IngressZones, "public")
|
|
assert.Contains(t, s.Ports, Port{Port: "6000", Protocol: "tcp"})
|
|
|
|
// Reload so the permanent policy appears in the runtime, then exercise the
|
|
// runtime .policy interface. These calls target the .policy interface (not
|
|
// .zone); a regression to the wrong interface would fault here with
|
|
// UnknownMethod rather than returning the policy.
|
|
require.NoError(t, c.Reload(ctx))
|
|
names, err := c.Runtime().Policies(ctx)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, names, name)
|
|
|
|
rs, err := c.Runtime().PolicySettings(ctx, name)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "CONTINUE", rs.Target)
|
|
assert.Contains(t, rs.IngressZones, "public")
|
|
|
|
_, err = c.Runtime().ActivePolicies(ctx)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
// TestIntegrationDirect exercises the direct interface with a throwaway custom
|
|
// chain and rule, cleaning up after itself.
|
|
func TestIntegrationDirect(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
d := c.Direct()
|
|
chain := DirectChain{IPV: "ipv4", Table: "filter", Chain: uniqueName("GOFW")}
|
|
|
|
_ = d.RemoveChain(ctx, chain)
|
|
require.NoError(t, d.AddChain(ctx, chain))
|
|
t.Cleanup(func() {
|
|
_ = d.RemoveRules(context.Background(), chain.IPV, chain.Table, chain.Chain)
|
|
_ = d.RemoveChain(context.Background(), chain)
|
|
})
|
|
|
|
ok, err := d.QueryChain(ctx, chain)
|
|
require.NoError(t, err)
|
|
assert.True(t, ok)
|
|
|
|
chains, err := d.AllChains(ctx)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, chains, chain)
|
|
|
|
rule := DirectRule{IPV: "ipv4", Table: "filter", Chain: chain.Chain, Priority: 0, Args: []string{"-j", "ACCEPT"}}
|
|
require.NoError(t, d.AddRule(ctx, rule))
|
|
|
|
ok, err = d.QueryRule(ctx, rule)
|
|
require.NoError(t, err)
|
|
assert.True(t, ok)
|
|
|
|
rules, err := d.Rules(ctx, chain.IPV, chain.Table, chain.Chain)
|
|
require.NoError(t, err)
|
|
require.Len(t, rules, 1)
|
|
assert.Equal(t, []string{"-j", "ACCEPT"}, rules[0].Args)
|
|
|
|
require.NoError(t, d.RemoveRule(ctx, rule))
|
|
}
|
|
|
|
// TestIntegrationSignals subscribes to firewalld signals and confirms a Reloaded
|
|
// event arrives after an explicit reload.
|
|
func TestIntegrationSignals(t *testing.T) {
|
|
c := dial(t)
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
|
|
reloaded, err := c.WatchReloaded(ctx)
|
|
require.NoError(t, err)
|
|
|
|
// Give the match rule a moment to register before triggering.
|
|
time.Sleep(200 * time.Millisecond)
|
|
require.NoError(t, c.Reload(ctx))
|
|
|
|
select {
|
|
case <-reloaded:
|
|
// success
|
|
case <-time.After(10 * time.Second):
|
|
t.Fatal("did not receive Reloaded signal within timeout")
|
|
}
|
|
}
|
|
|
|
// TestIntegrationProperties reads the runtime state and daemon config properties.
|
|
func TestIntegrationProperties(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
|
|
state, err := c.State(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "RUNNING", state)
|
|
|
|
info, err := c.RuntimeInfo(ctx)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "RUNNING", info.State)
|
|
t.Logf("runtime: ipv4=%v ipv6=%v ipset=%v bridge=%v ipsetTypes=%d",
|
|
info.IPv4, info.IPv6, info.IPSet, info.Bridge, len(info.IPSetTypes))
|
|
|
|
dc, err := c.Permanent().DaemonConfig(ctx)
|
|
require.NoError(t, err)
|
|
assert.NotEmpty(t, dc.DefaultZone)
|
|
t.Logf("daemon: backend=%q defaultZone=%q ipv6rpfilter=%q cleanup=%q",
|
|
dc.FirewallBackend, dc.DefaultZone, dc.IPv6RPFilter, dc.CleanupOnExit)
|
|
t.Logf("daemon 2.x knobs: nftCounters=%q nftFlowtable=%q strictForwardPorts=%q",
|
|
dc.NftablesCounters, dc.NftablesFlowtable, dc.StrictForwardPorts)
|
|
}
|
|
|
|
// TestIntegrationServiceIncludes exercises the service includes editor on a
|
|
// throwaway service (firewalld >= 1.0).
|
|
func TestIntegrationServiceIncludes(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
if !c.Supports(ServiceSettings2) {
|
|
t.Skip("server predates service includes")
|
|
}
|
|
perm := c.Permanent()
|
|
name := uniqueName("gofwsvc")
|
|
|
|
_ = perm.Service(name).Remove(ctx)
|
|
_, err := perm.AddService(ctx, name, ServiceSettings{Short: name})
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
_ = perm.Service(name).Remove(context.Background())
|
|
_ = c.Reload(context.Background())
|
|
})
|
|
|
|
svc := perm.Service(name)
|
|
require.NoError(t, svc.AddPort(ctx, Port{Port: "1717", Protocol: "tcp"}))
|
|
require.NoError(t, svc.AddInclude(ctx, "ssh"))
|
|
|
|
ok, err := svc.QueryInclude(ctx, "ssh")
|
|
require.NoError(t, err)
|
|
assert.True(t, ok)
|
|
|
|
incs, err := svc.Includes(ctx)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, incs, "ssh")
|
|
|
|
got, err := svc.Settings(ctx)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, got.Ports, Port{Port: "1717", Protocol: "tcp"})
|
|
}
|
|
|
|
// TestIntegrationChangeZoneOfSource confirms a source can be moved between zones
|
|
// at runtime without an ALREADY-bound error.
|
|
func TestIntegrationChangeZoneOfSource(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
perm := c.Permanent()
|
|
za, zb := uniqueName("gofwca"), uniqueName("gofwcb")
|
|
source := "10.44.44.0/24"
|
|
|
|
for _, n := range []string{za, zb} {
|
|
_ = perm.Zone(n).Remove(ctx)
|
|
_, err := perm.AddZone(ctx, n, ZoneSettings{Short: n})
|
|
require.NoError(t, err)
|
|
}
|
|
require.NoError(t, c.Reload(ctx))
|
|
t.Cleanup(func() {
|
|
for _, n := range []string{za, zb} {
|
|
_ = perm.Zone(n).Remove(context.Background())
|
|
}
|
|
_ = c.Reload(context.Background())
|
|
})
|
|
|
|
require.NoError(t, c.Runtime().Zone(za).AddSource(ctx, source))
|
|
// Move it to zb; ChangeSource must succeed even though it is bound to za.
|
|
require.NoError(t, c.Runtime().Zone(zb).ChangeSource(ctx, source))
|
|
|
|
zone, err := c.Runtime().ZoneOfSource(ctx, source)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, zb, zone)
|
|
}
|
|
|
|
// TestIntegrationUnsupportedGuards ensures the library refuses policy calls on old
|
|
// servers rather than emitting an UnknownMethod fault.
|
|
func TestIntegrationUnsupportedGuards(t *testing.T) {
|
|
c := dial(t)
|
|
ctx := testCtx(t)
|
|
if c.Supports(Policies) {
|
|
t.Skip("server supports policies; nothing to guard")
|
|
}
|
|
_, err := c.Runtime().Policies(ctx)
|
|
assert.True(t, errors.Is(err, ErrUnsupported))
|
|
}
|