From 6c8e0764b82d2b03909e6dab0ac6edad9034d87f Mon Sep 17 00:00:00 2001 From: Viktor Liu Date: Wed, 9 Sep 2026 11:14:35 +0200 Subject: [PATCH] Keep the DNS interception hooks installed when the firewall is disabled --- client/firewall/create_linux.go | 6 +-- client/firewall/hooks.go | 21 +++++++++ client/firewall/uspfilter/hooks_filter.go | 7 +-- client/internal/engine.go | 8 ++++ client/internal/engine_firewall_test.go | 53 +++++++++++++++++++++++ 5 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 client/firewall/hooks.go create mode 100644 client/internal/engine_firewall_test.go diff --git a/client/firewall/create_linux.go b/client/firewall/create_linux.go index d585e85d7..e8ee75cbb 100644 --- a/client/firewall/create_linux.go +++ b/client/firewall/create_linux.go @@ -68,11 +68,11 @@ func NewFirewall(iface IFaceMapper, stateManager *statemanager.Manager, flowLogg case err == nil && !iface.IsUserspaceBind(): // Nothing to do, fall through case err == nil && iface.IsUserspaceBind(): - // Native firewall handles packet filtering, but the userspace WireGuard bind + // Native firewall handles packet filtering, but the userspace bind // needs a device filter for DNS interception hooks. Install a minimal // hooks-only filter that passes all traffic through to the kernel firewall. - if err := iface.SetFilter(&uspfilter.HooksFilter{}); err != nil { - log.Warnf("failed to set hooks filter, DNS via memory hooks will not work: %v", err) + if err := InstallDNSHooksFilter(iface); err != nil { + log.Errorf("failed to set hooks filter, DNS via memory hooks will not work: %v", err) } case err != nil && !iface.IsUserspaceBind(): // Kernel cannot fall back to anything else, need to return error diff --git a/client/firewall/hooks.go b/client/firewall/hooks.go new file mode 100644 index 000000000..e38268e72 --- /dev/null +++ b/client/firewall/hooks.go @@ -0,0 +1,21 @@ +package firewall + +import ( + "github.com/netbirdio/netbird/client/firewall/uspfilter" +) + +// InstallDNSHooksFilter installs a device filter that carries nothing but the +// DNS interception hooks, for setups that run no firewall manager. The +// in-process resolver receives queries through hooks on the interface's device +// filter, so without a filter it never sees a query; the filter passes all +// other traffic through untouched. +// +// It is a no-op when the interface has no device filter to install on, which +// is the case for a kernel bind. +func InstallDNSHooksFilter(iface IFaceMapper) error { + if !iface.IsUserspaceBind() { + return nil + } + + return iface.SetFilter(&uspfilter.HooksFilter{}) +} diff --git a/client/firewall/uspfilter/hooks_filter.go b/client/firewall/uspfilter/hooks_filter.go index f3adf5f8b..7d341d20a 100644 --- a/client/firewall/uspfilter/hooks_filter.go +++ b/client/firewall/uspfilter/hooks_filter.go @@ -21,9 +21,10 @@ const ( ) // HooksFilter is a minimal packet filter that only handles outbound DNS hooks. -// It is installed on the WireGuard interface when the userspace bind is active -// but a full firewall filter (Manager) is not needed because a native kernel -// firewall (nftables/iptables) handles packet filtering. +// It is installed on the interface when the userspace bind is active but a full +// filter (Manager) is not: either because a native kernel firewall +// (nftables/iptables) handles packet filtering, or because no firewall manager +// runs at all. type HooksFilter struct { udpHook atomic.Pointer[common.PacketHook] tcpHook atomic.Pointer[common.PacketHook] diff --git a/client/internal/engine.go b/client/internal/engine.go index f8b65f7d8..96e353b62 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -732,6 +732,14 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL) func (e *Engine) createFirewall() error { if e.config.DisableFirewall { log.Infof("firewall is disabled") + + // The DNS hooks are not firewall rules. Without the filter that carries + // them the resolver never receives a query, while the system is still + // pointed at it. + if err := firewall.InstallDNSHooksFilter(e.wgInterface); err != nil { + log.Errorf("failed to install DNS hooks filter, DNS will not work: %v", err) + } + return nil } diff --git a/client/internal/engine_firewall_test.go b/client/internal/engine_firewall_test.go new file mode 100644 index 000000000..78e44ce54 --- /dev/null +++ b/client/internal/engine_firewall_test.go @@ -0,0 +1,53 @@ +package internal + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/firewall/uspfilter" + "github.com/netbirdio/netbird/client/iface/device" +) + +// A disabled firewall must still leave the DNS interception hooks in place on a +// userspace bind: the in-process resolver receives queries through them, and the +// system is pointed at that resolver either way. +func TestCreateFirewallDisabledInstallsDNSHooksOnUserspaceBind(t *testing.T) { + var installed device.PacketFilter + iface := &MockWGIface{ + IsUserspaceBindFunc: func() bool { return true }, + SetFilterFunc: func(filter device.PacketFilter) error { + installed = filter + return nil + }, + } + + engine := &Engine{ + config: &EngineConfig{DisableFirewall: true}, + wgInterface: iface, + } + + require.NoError(t, engine.createFirewall()) + assert.Nil(t, engine.firewall, "no firewall manager should be created") + assert.IsType(t, &uspfilter.HooksFilter{}, installed) +} + +// A kernel bind has no device filter, so nothing should be installed on it. +func TestCreateFirewallDisabledSkipsDNSHooksOnKernelBind(t *testing.T) { + iface := &MockWGIface{ + IsUserspaceBindFunc: func() bool { return false }, + SetFilterFunc: func(device.PacketFilter) error { + t.Error("SetFilter called for a kernel bind") + return nil + }, + } + + engine := &Engine{ + config: &EngineConfig{DisableFirewall: true}, + wgInterface: iface, + } + + require.NoError(t, engine.createFirewall()) + assert.Nil(t, engine.firewall, "no firewall manager should be created") +}