From fd96b8c12fd9601b597372660baacaa63ba1f13e Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Sun, 28 Jun 2026 12:44:40 +0200
Subject: [PATCH 01/20] [client] Improve network addresses filter (#6515)
* [client] Filter link-local and multicast from network addresses
Skip IPv6 link-local and multicast addresses when building the peer
network_addresses list on non-iOS platforms, matching the existing iOS
behavior. A flapping NIC's link-local address otherwise churns the peer
meta on every interface up/down.
* [client] Skip engine restart when default route is unchanged
After the network monitor's debounce window, re-check the default next
hop before triggering a client restart. A flapping NIC that returns to
the same default route no longer forces a restart, avoiding redundant
sync stream reconnects and peer meta churn.
* [client] Exclude own overlay address from reported network addresses
The peer's own WireGuard overlay address (v4 and v6) was reported in
network_addresses. As the interface comes and goes during reconnects it
churned the peer meta on the management server. Drop it in
GetInfoWithChecks, matching the IP regardless of prefix length since the
engine knows the overlay address with the network mask while the
interface reports it as a host address.
* [client] Treat missing default route per protocol in next-hop check
A failed GetNextHop lookup is now treated as an absent route (zero
Nexthop) and compared per protocol, instead of forcing a restart. In a
single-stack network the missing IPv6 default route no longer counts as
a change on every debounce, which previously defeated the unchanged-route
check.
* [client] Make next-hop check injectable for network monitor tests
Move the next-hop comparison behind a NetworkMonitor field set by New(),
so tests can supply a stub instead of hitting the host's real default
route. Fixes the Event/MultiEvent tests hanging after the unchanged-route
check was added.
* Revert "[client] Make next-hop check injectable for network monitor tests"
This reverts commit 88a9d96e8f26c987c93ca62811f2f5b106c31147.
* Revert "[client] Treat missing default route per protocol in next-hop check"
This reverts commit 0fb531e4bc8227eaac7ca6d9c9dca34a89b8f531.
* Revert "[client] Skip engine restart when default route is unchanged"
This reverts commit a071b55f35a7d5eb6f83d0a9b3bb17600c5217f8.
---
client/internal/engine.go | 18 ++++++++++--
client/system/info.go | 23 ++++++++++++++-
client/system/info_test.go | 40 ++++++++++++++++++++++++++
client/system/network_addr.go | 4 ++-
client/system/network_addr_test.go | 45 ++++++++++++++++++++++++++++++
5 files changed, 126 insertions(+), 4 deletions(-)
create mode 100644 client/system/network_addr_test.go
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 452075da8..e7f1c0501 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -1066,7 +1066,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
}
e.checks = checks
- info, err := system.GetInfoWithChecks(e.ctx, checks)
+ info, err := system.GetInfoWithChecks(e.ctx, checks, e.overlayAddresses()...)
if err != nil {
log.Warnf("failed to get system info with checks: %v", err)
info = system.GetInfo(e.ctx)
@@ -1097,6 +1097,20 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
return nil
}
+// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
+// can be excluded from the reported network addresses; the interface coming and
+// going otherwise churns the peer meta on the management server.
+func (e *Engine) overlayAddresses() []netip.Addr {
+ var ips []netip.Addr
+ if e.config.WgAddr.IP.IsValid() {
+ ips = append(ips, e.config.WgAddr.IP)
+ }
+ if e.config.WgAddr.HasIPv6() {
+ ips = append(ips, e.config.WgAddr.IPv6)
+ }
+ return ips
+}
+
func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
if e.wgInterface == nil {
return errors.New("wireguard interface is not initialized")
@@ -1240,7 +1254,7 @@ func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
- info, err := system.GetInfoWithChecks(e.ctx, e.checks)
+ info, err := system.GetInfoWithChecks(e.ctx, e.checks, e.overlayAddresses()...)
if err != nil {
log.Warnf("failed to get system info with checks: %v", err)
info = system.GetInfo(e.ctx)
diff --git a/client/system/info.go b/client/system/info.go
index 477d5162b..27588859e 100644
--- a/client/system/info.go
+++ b/client/system/info.go
@@ -3,6 +3,7 @@ package system
import (
"context"
"net/netip"
+ "slices"
"strings"
log "github.com/sirupsen/logrus"
@@ -121,6 +122,23 @@ func (i *Info) SetFlags(
}
}
+// removeAddresses drops network addresses whose IP matches any of the given
+// addresses, regardless of prefix length. Used to exclude the NetBird overlay
+// address, which otherwise churns the meta as the interface comes and goes.
+func (i *Info) removeAddresses(ips ...netip.Addr) {
+ if len(ips) == 0 {
+ return
+ }
+ filtered := i.NetworkAddresses[:0]
+ for _, addr := range i.NetworkAddresses {
+ if slices.Contains(ips, addr.NetIP.Addr()) {
+ continue
+ }
+ filtered = append(filtered, addr)
+ }
+ i.NetworkAddresses = filtered
+}
+
// extractUserAgent extracts Netbird's agent (client) name and version from the outgoing context
func extractUserAgent(ctx context.Context) string {
md, hasMeta := metadata.FromOutgoingContext(ctx)
@@ -147,7 +165,9 @@ func extractDeviceName(ctx context.Context, defaultName string) string {
}
// GetInfoWithChecks retrieves and parses the system information with applied checks.
-func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, error) {
+// excludeIPs are dropped from the reported network addresses (e.g. our own
+// WireGuard overlay address, which otherwise churns the peer meta).
+func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, error) {
log.Debugf("gathering system information with checks: %d", len(checks))
processCheckPaths := make([]string, 0)
for _, check := range checks {
@@ -162,6 +182,7 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks) (*Info, erro
info := GetInfo(ctx)
info.Files = files
+ info.removeAddresses(excludeIPs...)
log.Debugf("all system information gathered successfully")
return info, nil
diff --git a/client/system/info_test.go b/client/system/info_test.go
index 27821f3c5..dcda18e61 100644
--- a/client/system/info_test.go
+++ b/client/system/info_test.go
@@ -2,6 +2,7 @@ package system
import (
"context"
+ "net/netip"
"testing"
"github.com/stretchr/testify/assert"
@@ -43,3 +44,42 @@ func Test_NetAddresses(t *testing.T) {
t.Errorf("no network addresses found")
}
}
+
+func TestInfo_RemoveAddresses(t *testing.T) {
+ addr := func(cidr string) NetworkAddress {
+ return NetworkAddress{NetIP: netip.MustParsePrefix(cidr)}
+ }
+
+ info := &Info{
+ NetworkAddresses: []NetworkAddress{
+ addr("192.168.1.7/24"),
+ addr("100.76.70.97/32"), // overlay v4 (host mask /32)
+ addr("2001:818:c51b:4800:845:a65d:ae6f:623f/64"), // real global v6
+ addr("fd00:1234::1/64"), // overlay v6
+ },
+ }
+
+ // Overlay addresses as the engine knows them, with a different mask (/16, /64).
+ info.removeAddresses(
+ netip.MustParseAddr("100.76.70.97"),
+ netip.MustParseAddr("fd00:1234::1"),
+ )
+
+ want := []string{"192.168.1.7/24", "2001:818:c51b:4800:845:a65d:ae6f:623f/64"}
+ if len(info.NetworkAddresses) != len(want) {
+ t.Fatalf("got %d addresses, want %d: %v", len(info.NetworkAddresses), len(want), info.NetworkAddresses)
+ }
+ for i, w := range want {
+ if got := info.NetworkAddresses[i].NetIP.String(); got != w {
+ t.Errorf("address[%d] = %s, want %s", i, got, w)
+ }
+ }
+}
+
+func TestInfo_RemoveAddresses_NoOp(t *testing.T) {
+ info := &Info{NetworkAddresses: []NetworkAddress{{NetIP: netip.MustParsePrefix("10.0.0.1/24")}}}
+ info.removeAddresses()
+ if len(info.NetworkAddresses) != 1 {
+ t.Errorf("expected no change with empty input, got %v", info.NetworkAddresses)
+ }
+}
diff --git a/client/system/network_addr.go b/client/system/network_addr.go
index 5423cf8ad..44260a938 100644
--- a/client/system/network_addr.go
+++ b/client/system/network_addr.go
@@ -46,7 +46,9 @@ func toNetworkAddress(address net.Addr, mac string) (NetworkAddress, bool) {
if !ok {
return NetworkAddress{}, false
}
- if ipNet.IP.IsLoopback() {
+ // Skip link-local and multicast: they carry no routable peer info and the
+ // IPv6 link-local of a flapping NIC churns the meta on every up/down.
+ if ipNet.IP.IsLoopback() || ipNet.IP.IsLinkLocalUnicast() || ipNet.IP.IsMulticast() {
return NetworkAddress{}, false
}
prefix, err := netip.ParsePrefix(ipNet.String())
diff --git a/client/system/network_addr_test.go b/client/system/network_addr_test.go
new file mode 100644
index 000000000..a5f9c4279
--- /dev/null
+++ b/client/system/network_addr_test.go
@@ -0,0 +1,45 @@
+//go:build !ios
+
+package system
+
+import (
+ "net"
+ "testing"
+)
+
+func mustIPNet(t *testing.T, cidr string) *net.IPNet {
+ t.Helper()
+ ip, ipNet, err := net.ParseCIDR(cidr)
+ if err != nil {
+ t.Fatalf("parse %q: %v", cidr, err)
+ }
+ ipNet.IP = ip
+ return ipNet
+}
+
+func TestToNetworkAddress_Filtering(t *testing.T) {
+ const mac = "c8:4b:d6:b6:04:ac"
+
+ tests := []struct {
+ name string
+ cidr string
+ want bool
+ }{
+ {"ipv4 global", "10.65.16.181/23", true},
+ {"ipv6 global", "2620:52:0:4110:102d:6a98:ee75:8b92/64", true},
+ {"ipv4 loopback", "127.0.0.1/8", false},
+ {"ipv6 loopback", "::1/128", false},
+ {"ipv6 link-local", "fe80::871:4c25:23d7:2529/64", false},
+ {"ipv4 link-local", "169.254.1.2/16", false},
+ {"ipv6 multicast", "ff02::1/128", false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ _, got := toNetworkAddress(mustIPNet(t, tt.cidr), mac)
+ if got != tt.want {
+ t.Errorf("toNetworkAddress(%s) ok = %v, want %v", tt.cidr, got, tt.want)
+ }
+ })
+ }
+}
From 1b29995ecec132bec6d1b61420375fba6ebb1082 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Sun, 28 Jun 2026 12:45:33 +0200
Subject: [PATCH 02/20] [client] Fix blocked status lock via relay manager path
(#6547)
* peer/status: move relay-state reads off the main mux
GetRelayStates held d.mux (RLock) while calling into the relay
Manager (RelayStates/RelayConnectError/ServerURLs). Those calls can be
slow or block on the relay manager's own locks while it is reconnecting,
which kept the central Status mutex held and stalled every peer state
writer (UpdatePeerState, ReplaceOfflinePeers, etc.) contending for it.
Guard relayMgr/relayStates with a dedicated muxRelays mutex and release
it before invoking the relay Manager, so the relay read path no longer
contends with the hot peer-state writers on d.mux.
* peer/status: clone relay states in nil-manager path
Return a cloned snapshot of d.relayStates when relayMgr is nil so callers
cannot mutate the shared cached state, matching the non-nil path.
---
client/internal/peer/status.go | 23 +++++++++++++----------
1 file changed, 13 insertions(+), 10 deletions(-)
diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go
index 3e5c56dd2..e48ac333c 100644
--- a/client/internal/peer/status.go
+++ b/client/internal/peer/status.go
@@ -192,6 +192,7 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
// Pure read methods take RLock; anything that mutates state takes Lock.
type Status struct {
mux sync.RWMutex
+ muxRelays sync.RWMutex
peers map[string]State
ipToKey map[string]string
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
@@ -244,8 +245,8 @@ func NewRecorder(mgmAddress string) *Status {
}
func (d *Status) SetRelayMgr(manager *relayClient.Manager) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.muxRelays.Lock()
+ defer d.muxRelays.Unlock()
d.relayMgr = manager
}
@@ -906,8 +907,8 @@ func (d *Status) MarkSignalConnected() {
}
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
- d.mux.Lock()
- defer d.mux.Unlock()
+ d.muxRelays.Lock()
+ defer d.muxRelays.Unlock()
d.relayStates = relayResults
}
@@ -1018,24 +1019,26 @@ func (d *Status) GetSignalState() SignalState {
// GetRelayStates returns the stun/turn/permanent relay states
func (d *Status) GetRelayStates() []relay.ProbeResult {
- d.mux.RLock()
- defer d.mux.RUnlock()
+ d.muxRelays.RLock()
if d.relayMgr == nil {
- return d.relayStates
+ defer d.muxRelays.RUnlock()
+ return slices.Clone(d.relayStates)
}
+ relayMgr := d.relayMgr
// extend the list of stun, turn servers with the relay server connections
relayStates := slices.Clone(d.relayStates)
+ d.muxRelays.RUnlock()
- states := d.relayMgr.RelayStates()
+ states := relayMgr.RelayStates()
if len(states) == 0 {
// no relay connection tracked yet; surface configured servers as
// unavailable with the real reconnect error when known
err := relayClient.ErrRelayClientNotConnected
- if connErr := d.relayMgr.RelayConnectError(); connErr != nil {
+ if connErr := relayMgr.RelayConnectError(); connErr != nil {
err = connErr
}
- for _, r := range d.relayMgr.ServerURLs() {
+ for _, r := range relayMgr.ServerURLs() {
relayStates = append(relayStates, relay.ProbeResult{
URI: r,
Err: err,
From 62f5467cd85d395449f09ad9506cef9d04da651b Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Sun, 28 Jun 2026 14:22:19 +0200
Subject: [PATCH 03/20] [client] Eliminate packet loss during lazy connections.
(#6355)
* [client] Remove peer deletion on lazy activity detection
Updated WireGuard dependency with a patch and removed the RemovePeer
call on lazy activity detection to force a new handshake initiation
to the updated endpoint. This also flushed the staged queue, dropping
the first packet.
Since UpdatePeer (called after ICE/relay negotiation) triggers
SendStagedPackets via IpcSet/handlePostConfig, the peer removal is
no longer necessary. The staged packet survives and the handshake
is initiated on the real endpoint automatically.
This also eliminates the transient state where the peer's endpoint
and routes were absent between the lazy idle and connected states.
* Update WireGuard dependency
* Update WireGuard dependencies
* Update WireGuard dependency
---
client/internal/lazyconn/activity/listener_bind.go | 4 ----
go.mod | 2 +-
go.sum | 4 ++--
3 files changed, 3 insertions(+), 7 deletions(-)
diff --git a/client/internal/lazyconn/activity/listener_bind.go b/client/internal/lazyconn/activity/listener_bind.go
index 60b8baadb..666c3bc28 100644
--- a/client/internal/lazyconn/activity/listener_bind.go
+++ b/client/internal/lazyconn/activity/listener_bind.go
@@ -119,10 +119,6 @@ func (d *BindListener) ReadPackets() {
}
d.peerCfg.Log.Debugf("removing lazy endpoint for peer %s", d.peerCfg.PublicKey)
- if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil {
- d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err)
- }
-
_ = d.lazyConn.Close()
d.bind.RemoveEndpoint(d.fakeIP)
d.done.Done()
diff --git a/go.mod b/go.mod
index 2858d2044..fa5b431bf 100644
--- a/go.mod
+++ b/go.mod
@@ -341,7 +341,7 @@ replace github.com/kardianos/service => github.com/netbirdio/service v0.0.0-2024
replace github.com/getlantern/systray => github.com/netbirdio/systray v0.0.0-20231030152038-ef1ed2a27949
-replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f
+replace golang.zx2c4.com/wireguard => github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a
replace github.com/cloudflare/circl => codeberg.org/cunicu/circl v0.0.0-20230801113412-fec58fc7b5f6
diff --git a/go.sum b/go.sum
index 1768ee069..3e1d7c97a 100644
--- a/go.sum
+++ b/go.sum
@@ -510,8 +510,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
-github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f h1:ff2D57RBjWtyQ2wVwJOxOgXAXOe/J2lJWtSX0Bz/BRk=
-github.com/netbirdio/wireguard-go v0.0.0-20260523085312-4b4a4e36017f/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
+github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
+github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
From 998ade6e6dc656eb4dd80cbba9c3218151ada818 Mon Sep 17 00:00:00 2001
From: MAAZIZ Adel Ayoub
Date: Sun, 28 Jun 2026 13:51:21 +0100
Subject: [PATCH 04/20] [client] fix nil pointer panic when applying SSH server
setting to an existing config (#6556)
---
client/internal/profilemanager/config.go | 2 +-
client/internal/profilemanager/config_test.go | 29 +++++++++++++++++++
2 files changed, 30 insertions(+), 1 deletion(-)
diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go
index a77f0ff32..5a71a981e 100644
--- a/client/internal/profilemanager/config.go
+++ b/client/internal/profilemanager/config.go
@@ -433,7 +433,7 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
- if input.ServerSSHAllowed != nil && *input.ServerSSHAllowed != *config.ServerSSHAllowed {
+ if input.ServerSSHAllowed != nil && (config.ServerSSHAllowed == nil || *input.ServerSSHAllowed != *config.ServerSSHAllowed) {
if *input.ServerSSHAllowed {
log.Infof("enabling SSH server")
} else {
diff --git a/client/internal/profilemanager/config_test.go b/client/internal/profilemanager/config_test.go
index 5216f2423..736ff3412 100644
--- a/client/internal/profilemanager/config_test.go
+++ b/client/internal/profilemanager/config_test.go
@@ -242,6 +242,35 @@ func TestWireguardPortDefaultVsExplicit(t *testing.T) {
}
}
+func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) {
+ // Configs written before ServerSSHAllowed was introduced lack the field and
+ // unmarshal to nil. Supplying the SSH server flag on top of such a config must
+ // apply the value instead of panicking on a nil pointer dereference.
+ tests := []struct {
+ name string
+ input *bool
+ want bool
+ }{
+ {"enable", util.True(), true},
+ {"disable", util.False(), false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ configPath := filepath.Join(t.TempDir(), "config.json")
+ require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600))
+
+ config, err := UpdateConfig(ConfigInput{
+ ConfigPath: configPath,
+ ServerSSHAllowed: tt.input,
+ })
+ require.NoError(t, err)
+ require.NotNil(t, config.ServerSSHAllowed, "ServerSSHAllowed should be set from input")
+ assert.Equal(t, tt.want, *config.ServerSSHAllowed)
+ })
+ }
+}
+
func TestUpdateOldManagementURL(t *testing.T) {
origProber := newMgmProber
newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) {
From 2bb54216313e7fcd4c47a86115339818370289a6 Mon Sep 17 00:00:00 2001
From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
Date: Sun, 28 Jun 2026 14:52:41 +0200
Subject: [PATCH 05/20] These logs are needed for troubleshooting (debug)
(#6565)
---
client/internal/peer/handshaker.go | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/client/internal/peer/handshaker.go b/client/internal/peer/handshaker.go
index 1d44096b6..56e82e6e3 100644
--- a/client/internal/peer/handshaker.go
+++ b/client/internal/peer/handshaker.go
@@ -195,14 +195,14 @@ func (h *Handshaker) sendOffer() error {
}
offer := h.buildOfferAnswer()
- h.log.Infof("sending offer with serial: %s", offer.SessionIDString())
+ h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
answer := h.buildOfferAnswer()
- h.log.Infof("sending answer with serial: %s", answer.SessionIDString())
+ h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
return h.signaler.SignalAnswer(answer, h.config.Key)
}
From 739e36a31326ef1acad4fcb7bd3fb8024b3330f1 Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Sun, 28 Jun 2026 14:56:42 +0200
Subject: [PATCH 06/20] [self-hosted] Add agent-network preset with dedicated
configurations (#6569)
---
infrastructure_files/getting-started.sh | 96 +++++++++++++++++++++++--
1 file changed, 90 insertions(+), 6 deletions(-)
diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh
index 770cecc44..46bef5a1f 100755
--- a/infrastructure_files/getting-started.sh
+++ b/infrastructure_files/getting-started.sh
@@ -351,6 +351,11 @@ initialize_default_values() {
NETBIRD_STUN_PORT=3478
# Docker images
+ # Record whether the operator explicitly pinned the server/proxy images via
+ # env vars, so the agent-network preset can pick its own defaults without
+ # clobbering an explicit override.
+ NETBIRD_SERVER_IMAGE_EXPLICIT=${NETBIRD_SERVER_IMAGE:+true}
+ NETBIRD_PROXY_IMAGE_EXPLICIT=${NETBIRD_PROXY_IMAGE:+true}
DASHBOARD_IMAGE=${DASHBOARD_IMAGE:-"netbirdio/dashboard:latest"}
# Combined server replaces separate signal, relay, and management containers
NETBIRD_SERVER_IMAGE=${NETBIRD_SERVER_IMAGE:-"netbirdio/netbird-server:latest"}
@@ -398,7 +403,53 @@ configure_domain() {
return 0
}
+apply_agent_network_preset() {
+ # Agent-network turnkey install: built-in Traefik + NetBird Proxy with
+ # NB_PROXY_PRIVATE=true, dashboard locked to agent-network-only mode.
+ # Bypasses every reverse-proxy / proxy / CrowdSec prompt. The only
+ # inputs we still need from the operator are the domain (handled by
+ # configure_domain via NETBIRD_DOMAIN env var or interactive prompt)
+ # and the ACME email — both honor env vars first and fall back to a
+ # prompt only when unset. CrowdSec is intentionally off.
+ REVERSE_PROXY_TYPE="0"
+ ENABLE_PROXY="true"
+ ENABLE_CROWDSEC="false"
+
+ # Agent-network ships dedicated server/proxy images. Honor an explicit
+ # env override; otherwise pin the agent-network builds.
+ if [[ "${NETBIRD_SERVER_IMAGE_EXPLICIT}" != "true" ]]; then
+ NETBIRD_SERVER_IMAGE="netbirdio/netbird-server:0.74.0-rc.2"
+ fi
+ if [[ "${NETBIRD_PROXY_IMAGE_EXPLICIT}" != "true" ]]; then
+ NETBIRD_PROXY_IMAGE="netbirdio/reverse-proxy:0.74.0-rc.2"
+ fi
+
+ if [[ -n "${NETBIRD_LETSENCRYPT_EMAIL}" ]]; then
+ TRAEFIK_ACME_EMAIL="${NETBIRD_LETSENCRYPT_EMAIL}"
+ else
+ TRAEFIK_ACME_EMAIL=$(read_traefik_acme_email)
+ fi
+
+ echo "" > /dev/stderr
+ echo "Agent-network preset enabled (NETBIRD_AGENT_NETWORK=true):" > /dev/stderr
+ echo " - reverse proxy: built-in Traefik" > /dev/stderr
+ echo " - NetBird Proxy: enabled with NB_PROXY_PRIVATE=true" > /dev/stderr
+ echo " - server image: ${NETBIRD_SERVER_IMAGE}" > /dev/stderr
+ echo " - proxy image: ${NETBIRD_PROXY_IMAGE}" > /dev/stderr
+ echo " - dashboard: NETBIRD_AGENT_NETWORK_ONLY=true" > /dev/stderr
+ echo " - CrowdSec: disabled" > /dev/stderr
+ echo " - Let's Encrypt email: ${TRAEFIK_ACME_EMAIL}" > /dev/stderr
+ echo "" > /dev/stderr
+}
+
configure_reverse_proxy() {
+ # Short-circuit: agent-network preset locks every reverse-proxy /
+ # proxy / CrowdSec choice and bypasses the interactive prompts.
+ if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then
+ apply_agent_network_preset
+ return 0
+ fi
+
# Prompt for reverse proxy type
REVERSE_PROXY_TYPE=$(read_reverse_proxy_type)
@@ -910,6 +961,15 @@ NGINX_SSL_PORT=443
# Letsencrypt
LETSENCRYPT_DOMAIN=none
EOF
+
+ if [[ "${NETBIRD_AGENT_NETWORK}" == "true" ]]; then
+ cat <
Date: Sun, 28 Jun 2026 15:00:05 +0200
Subject: [PATCH 07/20] Bump the actions group across 1 directory with 4
updates (#6550)
Bumps the actions group with 4 updates in the / directory: [actions/setup-go](https://github.com/actions/setup-go), [actions/cache](https://github.com/actions/cache), [actions/cache/restore](https://github.com/actions/cache) and [actions/setup-java](https://github.com/actions/setup-java).
Updates `actions/setup-go` from 6.4.0 to 6.5.0
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/4a3601121dd01d1626a1e23e37211e3254c1c06c...924ae3a1cded613372ab5595356fb5720e22ba16)
Updates `actions/cache` from 5.0.5 to 6.0.0
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...2c8a9bd7457de244a408f35966fab2fb45fda9c8)
Updates `actions/cache/restore` from 5.0.5 to 6.0.0
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/27d5ce7f107fe9357f9df03efb73ab90386fccae...2c8a9bd7457de244a408f35966fab2fb45fda9c8)
Updates `actions/setup-java` from 5.3.0 to 5.4.0
- [Release notes](https://github.com/actions/setup-java/releases)
- [Commits](https://github.com/actions/setup-java/compare/ad2b38190b15e4d6bdf0c97fb4fca8412226d287...1bcf9fb12cf4aa7d266a90ae39939e61372fe520)
---
updated-dependencies:
- dependency-name: actions/setup-go
dependency-version: 6.5.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: actions
- dependency-name: actions/cache
dependency-version: 6.0.0
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: actions
- dependency-name: actions/cache/restore
dependency-version: 6.0.0
dependency-type: direct:production
update-type: version-update:semver-major
dependency-group: actions
- dependency-name: actions/setup-java
dependency-version: 5.4.0
dependency-type: direct:production
update-type: version-update:semver-minor
dependency-group: actions
...
Signed-off-by: dependabot[bot]
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
---
.../workflows/check-license-dependencies.yml | 2 +-
.github/workflows/golang-test-darwin.yml | 4 +-
.github/workflows/golang-test-linux.yml | 40 +++++++++----------
.github/workflows/golang-test-windows.yml | 4 +-
.github/workflows/golangci-lint.yml | 2 +-
.github/workflows/mobile-build-validation.yml | 8 ++--
.github/workflows/release.yml | 12 +++---
.../workflows/test-infrastructure-files.yml | 4 +-
.github/workflows/wasm-build-validation.yml | 4 +-
9 files changed, 40 insertions(+), 40 deletions(-)
diff --git a/.github/workflows/check-license-dependencies.yml b/.github/workflows/check-license-dependencies.yml
index 50510368b..17c9fdc8d 100644
--- a/.github/workflows/check-license-dependencies.yml
+++ b/.github/workflows/check-license-dependencies.yml
@@ -64,7 +64,7 @@ jobs:
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: true
diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index 7ecec0e92..b27ff24e4 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -21,13 +21,13 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/go/pkg/mod
key: macos-gotest-${{ hashFiles('**/go.sum') }}
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index f9d2755bf..da1603b79 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -30,7 +30,7 @@ jobs:
- 'management/**'
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -41,7 +41,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
id: cache
with:
path: |
@@ -124,7 +124,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -135,7 +135,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -180,7 +180,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -192,7 +192,7 @@ jobs:
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
id: cache-restore
with:
path: |
@@ -251,7 +251,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -266,7 +266,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -311,7 +311,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -325,7 +325,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -368,7 +368,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -383,7 +383,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -429,7 +429,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -440,7 +440,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -534,7 +534,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -545,7 +545,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -629,7 +629,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -640,7 +640,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
@@ -699,7 +699,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
@@ -710,7 +710,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index a6064d574..d587e8b6e 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -23,7 +23,7 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
id: go
with:
go-version-file: "go.mod"
@@ -35,7 +35,7 @@ jobs:
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
${{ env.cache }}
diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml
index 66882ac05..5d26d678d 100644
--- a/.github/workflows/golangci-lint.yml
+++ b/.github/workflows/golangci-lint.yml
@@ -48,7 +48,7 @@ jobs:
run: |
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
diff --git a/.github/workflows/mobile-build-validation.yml b/.github/workflows/mobile-build-validation.yml
index 778462a21..44e912c73 100644
--- a/.github/workflows/mobile-build-validation.yml
+++ b/.github/workflows/mobile-build-validation.yml
@@ -20,7 +20,7 @@ jobs:
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Setup Android SDK
@@ -28,13 +28,13 @@ jobs:
with:
cmdline-tools-version: 8512546
- name: Setup Java
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287
+ uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520
with:
java-version: "11"
distribution: "adopt"
- name: NDK Cache
id: ndk-cache
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: /usr/local/lib/android/sdk/ndk
key: ndk-cache-23.1.7779620
@@ -58,7 +58,7 @@ jobs:
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: install gomobile
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 4e533687b..16eae31fb 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -166,12 +166,12 @@ jobs:
fi
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache/restore@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
@@ -374,12 +374,12 @@ jobs:
fi
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
@@ -469,12 +469,12 @@ jobs:
fetch-depth: 0 # It is required for GoReleaser to work properly
persist-credentials: false
- name: Set up Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: |
~/go/pkg/mod
diff --git a/.github/workflows/test-infrastructure-files.yml b/.github/workflows/test-infrastructure-files.yml
index 1d7753177..0a4f2e371 100644
--- a/.github/workflows/test-infrastructure-files.yml
+++ b/.github/workflows/test-infrastructure-files.yml
@@ -73,12 +73,12 @@ jobs:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Cache Go modules
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
+ uses: actions/cache@2c8a9bd7457de244a408f35966fab2fb45fda9c8 # v6.0.0
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
diff --git a/.github/workflows/wasm-build-validation.yml b/.github/workflows/wasm-build-validation.yml
index a5ae59720..35855918d 100644
--- a/.github/workflows/wasm-build-validation.yml
+++ b/.github/workflows/wasm-build-validation.yml
@@ -23,7 +23,7 @@ jobs:
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Install dependencies
@@ -48,7 +48,7 @@ jobs:
with:
persist-credentials: false
- name: Install Go
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
+ uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
- name: Build Wasm client
From 5968cff242746a819ecfb69382e2995523ba0a3d Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Sun, 28 Jun 2026 22:33:30 +0900
Subject: [PATCH 08/20] [client] Keep signal stream alive while receive loop is
blocked on worker handoff (#6530)
---
shared/signal/client/grpc.go | 24 +++++++++++++++++++++++-
shared/signal/client/watchdog_test.go | 24 ++++++++++++++++++++++++
2 files changed, 47 insertions(+), 1 deletion(-)
diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go
index 2086e0fe6..611ab0c45 100644
--- a/shared/signal/client/grpc.go
+++ b/shared/signal/client/grpc.go
@@ -78,6 +78,13 @@ type GrpcClient struct {
// transport-alive but no longer delivering messages. It is the source of
// truth IsHealthy reads, and is cleared once any frame is received again.
receiveStalled atomic.Bool
+ // receiveHandoffBlocked is set while the receive loop is parked handing a
+ // message to a busy decryption worker. The loop stops calling Recv (and
+ // markReceived) in that window, so the stream looks silent though it is
+ // healthy. The watchdog reads this to avoid misreading self-inflicted
+ // receive backpressure as a dead stream: reconnecting cannot help, since the
+ // new stream feeds the same worker, and only triggers a reconnect storm.
+ receiveHandoffBlocked atomic.Bool
}
// NewClient creates a new Signal client
@@ -439,6 +446,16 @@ func (c *GrpcClient) idleSinceReceive() time.Duration {
return time.Since(time.Unix(0, c.lastReceived.Load()))
}
+// receiveAlive reports whether the receive stream shows liveness: it delivered a
+// frame within the inactivity threshold, or the receive loop is currently parked
+// handing a message to a busy decryption worker. In the latter case the loop has
+// stopped calling Recv, so the stream looks silent while being healthy, and
+// reconnecting would not help, so the watchdog must treat it as alive.
+func (c *GrpcClient) receiveAlive() bool {
+ return c.idleSinceReceive() < receiveInactivityThreshold ||
+ c.receiveHandoffBlocked.Load()
+}
+
// watchReceiveStream guards against a receive stream that is transport-alive but
// no longer delivering messages. While the stream is idle past
// receiveInactivityThreshold it sends a self-addressed probe that the Signal
@@ -455,7 +472,7 @@ func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream contex
case <-ctx.Done():
return
case <-ticker.C:
- if c.idleSinceReceive() < receiveInactivityThreshold {
+ if c.receiveAlive() {
probeSentAt = time.Time{}
continue
}
@@ -517,9 +534,14 @@ func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) er
continue
}
+ // The handoff blocks while the worker is busy, which parks this loop and
+ // stops Recv. Flag it so the watchdog does not read the resulting silence
+ // as a dead stream.
+ c.receiveHandoffBlocked.Store(true)
if err := c.decryptionWorker.AddMsg(c.ctx, msg); err != nil {
log.Errorf("failed to add message to decryption worker: %v", err)
}
+ c.receiveHandoffBlocked.Store(false)
}
}
diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go
index b780cb969..bc6b5520b 100644
--- a/shared/signal/client/watchdog_test.go
+++ b/shared/signal/client/watchdog_test.go
@@ -82,3 +82,27 @@ func TestReceiveProbeRoundTrips(t *testing.T) {
t.Fatal("self-addressed heartbeat did not round-trip back through the signal server")
}
}
+
+// TestReceiveAliveTreatsHandoffBlockAsLiveness reproduces the false positive
+// where a busy decryption worker parks the receive loop on the worker handoff,
+// so Recv (and markReceived) stops firing even though the stream is healthy.
+// With the receive stream silent past the inactivity threshold but the loop
+// blocked on handoff, the watchdog must consider the stream alive rather than
+// tear it down (reconnecting feeds the same worker and would not help).
+func TestReceiveAliveTreatsHandoffBlockAsLiveness(t *testing.T) {
+ c := &GrpcClient{}
+
+ // Receive stream silent and the loop not blocked on handoff: genuinely stalled.
+ c.lastReceived.Store(time.Now().Add(-2 * receiveInactivityThreshold).UnixNano())
+ require.False(t, c.receiveAlive(), "silent stream with the receive loop idle must be treated as stalled")
+
+ // Receive stream silent but the loop is parked handing a message to a busy
+ // worker: self-inflicted backpressure, not a dead stream, must not tear down.
+ c.receiveHandoffBlocked.Store(true)
+ require.True(t, c.receiveAlive(), "a receive loop blocked on worker handoff must keep the stream alive")
+
+ // Handoff drained, loop back to reading, a frame just arrived: alive via the receive path.
+ c.receiveHandoffBlocked.Store(false)
+ c.markReceived()
+ require.True(t, c.receiveAlive(), "a freshly received frame must keep the stream alive")
+}
From 2d7b309004d79d6dc066bd7f7bf62422c7ed61b4 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Sun, 28 Jun 2026 16:15:54 +0200
Subject: [PATCH 09/20] [client] Categorize privileged tests behind a build tag
and run them in Docker (#6425)
* [client] categorize root/system-mutating tests behind a privileged build tag
Tests that need root or mutate host state (nftables/iptables/DNS, TUN/WireGuard
interfaces, routes, eBPF, SSH/service install) are now gated behind a
//go:build privileged tag. The default `go test ./client/...` runs as a non-root
user with no sudo and leaves host networking untouched; mixed files were split so
pure-logic tests stay in the default suite.
A self-hosting ory/dockertest/v4 harness (client/testutil/privileged) runs the
privileged suite inside a --privileged --cap-add=NET_ADMIN container via
`make test-privileged`; a DOCKER_CI=true guard skips the spawn when already inside
the container. Added `make test-unit` for the host-safe run.
* [client] add PRIV_RUN/PRIV_PKGS filters to the privileged test harness
The dockertest harness now reads two optional env vars when building the
in-container `go test` command: PRIV_RUN adds a -run test-name filter and
PRIV_PKGS overrides the package list. Both empty reproduce the full privileged
suite, so CI and `make test-privileged` behave as before. Lets a developer run a
single privileged test in the container, e.g.:
PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
* [client] fix unused-helper lint after the privileged test split
Splitting privileged tests into *_privileged_test.go left their shared helpers in
the untagged files, so in the default (no-tag) build they had no callers and
golangci-lint flagged them as unused.
Moved the privileged-only helpers into the privileged files next to their callers
(generateDummyHandler; createEngine/startSignal/startManagement/getConnectedPeers/
getPeers + kaep/kasp; (*mockDaemon).setJWTToken). Annotated the shared routing-test
fixtures that must stay untagged for cross-platform compilation with //nolint:unused
(systemops_bsd expected* vars, ensureIPv6DefaultRoute on bsd/windows,
loopbackIfaceWindows), matching the existing linux variant.
* [client] fix privileged test CI failures and run the harness on macOS
The host-safe unit run dropped sudo but two privileged test groups were
never tagged, and the Docker privileged job silently never ran the suite:
- Gate the ssh/server PrivilegeDropper command-construction tests behind
the privileged tag (they require root to target a different UID); split
them into executor_unix_privileged_test.go.
- Tag sharedsock raw-socket tests privileged (need CAP_NET_RAW).
- Fix the Docker job command: nested single quotes around the build tags
closed the sh -c wrapper early, dropping the go list package set and the
privileged tag, so go test ran on the empty repo root. Use double quotes.
Make the self-hosting harness usable from a dev Mac:
- Build it on darwin as well as linux; it only drives Docker.
- Resolve the active docker context endpoint into DOCKER_HOST when the
default /var/run/docker.sock is absent (Docker Desktop, Colima, OrbStack).
- Rename the misspelled containerGoModache constant to containerGoModCache.
* Update client/internal/engine_privileged_test.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* Update client/internal/routemanager/systemops/systemops_linux_test.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* Update client/internal/routemanager/systemops/systemops_windows_test.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* Update client/server/server_privileged_test.go
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
* [ci] Run privileged-tagged tests on darwin, windows and freebsd
The privileged build tag split moved root/system-mutating tests behind
//go:build privileged, but only the linux docker job was given the tag.
The native darwin (sudo), windows (PsExec64 -s) and freebsd VM runners
already have the required privileges, so add the privileged tag there too
to keep CI running the same set of tests as before the split.
* [ci] Exclude dockertest harness from the darwin privileged run
The privileged tag now compiles client/testutil/privileged on darwin, whose
TestRunPrivilegedSuiteInDocker spawns a container the macOS runner has no
Docker for. Exclude the harness package from the darwin list, matching the
linux job, so the privileged tests run in place without a container spawn.
---------
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
---
.github/workflows/golang-test-darwin.yml | 2 +-
.github/workflows/golang-test-freebsd.yml | 20 +-
.github/workflows/golang-test-linux.yml | 4 +-
.github/workflows/golang-test-windows.yml | 2 +-
Makefile | 14 +-
client/cmd/service_privileged_test.go | 196 ++++++
client/cmd/service_test.go | 184 ------
.../firewall/iptables/manager_linux_test.go | 2 +
client/firewall/iptables/router_linux_test.go | 2 +-
.../firewall/nftables/manager_linux_test.go | 2 +
client/firewall/nftables/router_linux_test.go | 2 +-
client/iface/iface_test.go | 2 +
client/iface/wgproxy/proxy_linux_test.go | 2 +-
client/iface/wgproxy/proxy_seed_test.go | 2 +-
client/iface/wgproxy/redirect_test.go | 118 ++--
client/internal/dns/server_privileged_test.go | 485 +++++++++++++++
client/internal/dns/server_test.go | 462 --------------
client/internal/engine_privileged_test.go | 565 ++++++++++++++++++
client/internal/engine_test.go | 539 -----------------
client/internal/routemanager/manager_test.go | 2 +
.../systemops/rt_tables_linux_test.go | 69 +++
.../systemops_bsd_privileged_test.go | 191 ++++++
.../systemops/systemops_bsd_test.go | 190 +-----
.../systemops/systemops_dialer_test.go | 17 +
.../systemops/systemops_generic_test.go | 129 +---
.../systemops/systemops_isvpnroute_test.go | 132 ++++
.../systemops/systemops_linux_test.go | 65 +-
.../systemops_routing_data_linux_test.go | 15 +
.../systemops/systemops_routing_data_test.go | 83 +++
.../systemops/systemops_unix_test.go | 69 +--
.../systemops/systemops_windows_test.go | 2 +
.../systemops/v6route_bsd_test.go | 2 +
.../systemops/v6route_linux_test.go | 2 +-
.../systemops/v6route_windows_test.go | 3 +
client/server/server_privileged_test.go | 235 ++++++++
client/server/server_test.go | 220 +------
client/ssh/client/client_privileged_test.go | 118 ++++
client/ssh/client/client_test.go | 101 ----
client/ssh/proxy/proxy_privileged_test.go | 423 +++++++++++++
client/ssh/proxy/proxy_test.go | 406 -------------
.../server/executor_unix_privileged_test.go | 66 ++
client/ssh/server/executor_unix_test.go | 55 --
client/testutil/privileged/runner_test.go | 196 ++++++
docs/testing-privileged.md | 78 +++
go.mod | 9 +-
go.sum | 24 +-
sharedsock/sock_linux_test.go | 2 +
47 files changed, 3014 insertions(+), 2495 deletions(-)
create mode 100644 client/cmd/service_privileged_test.go
create mode 100644 client/internal/dns/server_privileged_test.go
create mode 100644 client/internal/engine_privileged_test.go
create mode 100644 client/internal/routemanager/systemops/rt_tables_linux_test.go
create mode 100644 client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
create mode 100644 client/internal/routemanager/systemops/systemops_dialer_test.go
create mode 100644 client/internal/routemanager/systemops/systemops_isvpnroute_test.go
create mode 100644 client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
create mode 100644 client/internal/routemanager/systemops/systemops_routing_data_test.go
create mode 100644 client/server/server_privileged_test.go
create mode 100644 client/ssh/client/client_privileged_test.go
create mode 100644 client/ssh/proxy/proxy_privileged_test.go
create mode 100644 client/ssh/server/executor_unix_privileged_test.go
create mode 100644 client/testutil/privileged/runner_test.go
create mode 100644 docs/testing-privileged.md
diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index b27ff24e4..748e3f996 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -45,7 +45,7 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/testutil/privileged)
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
diff --git a/.github/workflows/golang-test-freebsd.yml b/.github/workflows/golang-test-freebsd.yml
index 4243613b1..9c795e783 100644
--- a/.github/workflows/golang-test-freebsd.yml
+++ b/.github/workflows/golang-test-freebsd.yml
@@ -48,14 +48,14 @@ jobs:
export PATH=$PATH:/usr/local/go/bin:$HOME/go/bin
time go build -o netbird client/main.go
# check all component except management, since we do not support management server on freebsd
- time go test -timeout 1m -failfast ./base62/...
+ time go test -tags privileged -timeout 1m -failfast ./base62/...
# NOTE: without -p1 `client/internal/dns` will fail because of `listen udp4 :33100: bind: address already in use`
- time go test -timeout 8m -failfast -v -p 1 ./client/...
- time go test -timeout 1m -failfast ./dns/...
- time go test -timeout 1m -failfast ./encryption/...
- time go test -timeout 1m -failfast ./formatter/...
- time go test -timeout 1m -failfast ./client/iface/...
- time go test -timeout 1m -failfast ./route/...
- time go test -timeout 1m -failfast ./sharedsock/...
- time go test -timeout 1m -failfast ./util/...
- time go test -timeout 1m -failfast ./version/...
+ time go test -tags privileged -timeout 8m -failfast -v -p 1 ./client/...
+ time go test -tags privileged -timeout 1m -failfast ./dns/...
+ time go test -tags privileged -timeout 1m -failfast ./encryption/...
+ time go test -tags privileged -timeout 1m -failfast ./formatter/...
+ time go test -tags privileged -timeout 1m -failfast ./client/iface/...
+ time go test -tags privileged -timeout 1m -failfast ./route/...
+ time go test -tags privileged -timeout 1m -failfast ./sharedsock/...
+ time go test -tags privileged -timeout 1m -failfast ./util/...
+ time go test -tags privileged -timeout 1m -failfast ./version/...
diff --git a/.github/workflows/golang-test-linux.yml b/.github/workflows/golang-test-linux.yml
index da1603b79..34b215c60 100644
--- a/.github/workflows/golang-test-linux.yml
+++ b/.github/workflows/golang-test-linux.yml
@@ -158,7 +158,7 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
- run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
+ run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -coverprofile=coverage.txt -tags devcert -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
@@ -229,7 +229,7 @@ jobs:
sh -c ' \
apk update; apk add --no-cache \
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
- go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server)
+ go test -buildvcs=false -tags "devcert privileged" -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server -e /client/testutil/privileged)
'
test_relay:
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index d587e8b6e..b61c87cf6 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -68,7 +68,7 @@ jobs:
run: |
$packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
- $cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
+ $cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
- name: test
diff --git a/Makefile b/Makefile
index 5d52b94fa..0a4fad2f2 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: lint lint-all lint-install setup-hooks
+.PHONY: lint lint-all lint-install setup-hooks test-unit test-privileged
GOLANGCI_LINT := $(shell pwd)/bin/golangci-lint
# Install golangci-lint locally if needed
@@ -25,3 +25,15 @@ setup-hooks:
@git config core.hooksPath .githooks
@chmod +x .githooks/pre-push
@echo "✅ Git hooks configured! Pre-push will now run 'make lint'"
+
+# Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating).
+# Runs as a normal user with no sudo and leaves host networking untouched.
+test-unit:
+ @go test -tags devcert -timeout 10m ./...
+
+# Privileged suite: runs the `privileged`-tagged tests inside a --privileged
+# --cap-add=NET_ADMIN container via the ory/dockertest harness. Requires Docker.
+# Narrow the run with env vars, e.g.:
+# PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
+test-privileged:
+ @go test -tags 'devcert privileged' -timeout 30m -run TestRunPrivilegedSuiteInDocker -v ./client/testutil/privileged/...
diff --git a/client/cmd/service_privileged_test.go b/client/cmd/service_privileged_test.go
new file mode 100644
index 000000000..075d7f378
--- /dev/null
+++ b/client/cmd/service_privileged_test.go
@@ -0,0 +1,196 @@
+//go:build privileged
+
+package cmd
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "runtime"
+ "testing"
+ "time"
+
+ "github.com/kardianos/service"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+const (
+ serviceStartTimeout = 10 * time.Second
+ serviceStopTimeout = 5 * time.Second
+ statusPollInterval = 500 * time.Millisecond
+)
+
+// waitForServiceStatus waits for service to reach expected status with timeout
+func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) {
+ cfg, err := newSVCConfig()
+ if err != nil {
+ return false, err
+ }
+
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ if err != nil {
+ return false, err
+ }
+
+ ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout)
+ defer timeoutCancel()
+
+ ticker := time.NewTicker(statusPollInterval)
+ defer ticker.Stop()
+
+ for {
+ select {
+ case <-ctx.Done():
+ return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus)
+ case <-ticker.C:
+ status, err := s.Status()
+ if err != nil {
+ // Continue polling on transient errors
+ continue
+ }
+ if status == expectedStatus {
+ return true, nil
+ }
+ }
+ }
+}
+
+// TestServiceLifecycle tests the complete service lifecycle
+func TestServiceLifecycle(t *testing.T) {
+ // TODO: Add support for Windows and macOS
+ if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
+ t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS)
+ }
+
+ if os.Getenv("CONTAINER") == "true" {
+ t.Skip("Skipping service lifecycle test in container environment")
+ }
+
+ originalServiceName := serviceName
+ serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix())
+ defer func() {
+ serviceName = originalServiceName
+ }()
+
+ tempDir := t.TempDir()
+ configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir)
+ logLevel = "info"
+ daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir)
+
+ // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run.
+ t.Cleanup(func() {
+ cfg, err := newSVCConfig()
+ if err != nil {
+ t.Errorf("cleanup: create service config: %v", err)
+ return
+ }
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ if err != nil {
+ t.Errorf("cleanup: create service: %v", err)
+ return
+ }
+
+ // If the subtests already cleaned up, there's nothing to do.
+ if _, err := s.Status(); err != nil {
+ return
+ }
+
+ if err := s.Stop(); err != nil {
+ t.Errorf("cleanup: stop service: %v", err)
+ }
+ if err := s.Uninstall(); err != nil {
+ t.Errorf("cleanup: uninstall service: %v", err)
+ }
+ })
+
+ ctx := context.Background()
+
+ t.Run("Install", func(t *testing.T) {
+ installCmd.SetContext(ctx)
+ err := installCmd.RunE(installCmd, []string{})
+ require.NoError(t, err)
+
+ cfg, err := newSVCConfig()
+ require.NoError(t, err)
+
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ require.NoError(t, err)
+
+ status, err := s.Status()
+ assert.NoError(t, err)
+ assert.NotEqual(t, service.StatusUnknown, status)
+ })
+
+ t.Run("Start", func(t *testing.T) {
+ startCmd.SetContext(ctx)
+ err := startCmd.RunE(startCmd, []string{})
+ require.NoError(t, err)
+
+ running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
+ require.NoError(t, err)
+ assert.True(t, running)
+ })
+
+ t.Run("Restart", func(t *testing.T) {
+ restartCmd.SetContext(ctx)
+ err := restartCmd.RunE(restartCmd, []string{})
+ require.NoError(t, err)
+
+ running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
+ require.NoError(t, err)
+ assert.True(t, running)
+ })
+
+ t.Run("Reconfigure", func(t *testing.T) {
+ originalLogLevel := logLevel
+ logLevel = "debug"
+ defer func() {
+ logLevel = originalLogLevel
+ }()
+
+ reconfigureCmd.SetContext(ctx)
+ err := reconfigureCmd.RunE(reconfigureCmd, []string{})
+ require.NoError(t, err)
+
+ running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
+ require.NoError(t, err)
+ assert.True(t, running)
+ })
+
+ t.Run("Stop", func(t *testing.T) {
+ stopCmd.SetContext(ctx)
+ err := stopCmd.RunE(stopCmd, []string{})
+ require.NoError(t, err)
+
+ stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout)
+ require.NoError(t, err)
+ assert.True(t, stopped)
+ })
+
+ t.Run("Uninstall", func(t *testing.T) {
+ uninstallCmd.SetContext(ctx)
+ err := uninstallCmd.RunE(uninstallCmd, []string{})
+ require.NoError(t, err)
+
+ cfg, err := newSVCConfig()
+ require.NoError(t, err)
+
+ ctxSvc, cancel := context.WithCancel(context.Background())
+ defer cancel()
+
+ s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
+ require.NoError(t, err)
+
+ _, err = s.Status()
+ assert.Error(t, err)
+ })
+}
diff --git a/client/cmd/service_test.go b/client/cmd/service_test.go
index ce6f71550..22eba206d 100644
--- a/client/cmd/service_test.go
+++ b/client/cmd/service_test.go
@@ -1,16 +1,12 @@
package cmd
import (
- "context"
- "fmt"
"os"
"os/signal"
"runtime"
"syscall"
"testing"
- "time"
- "github.com/kardianos/service"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -31,186 +27,6 @@ func TestMain(m *testing.M) {
os.Exit(m.Run())
}
-const (
- serviceStartTimeout = 10 * time.Second
- serviceStopTimeout = 5 * time.Second
- statusPollInterval = 500 * time.Millisecond
-)
-
-// waitForServiceStatus waits for service to reach expected status with timeout
-func waitForServiceStatus(expectedStatus service.Status, timeout time.Duration) (bool, error) {
- cfg, err := newSVCConfig()
- if err != nil {
- return false, err
- }
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- if err != nil {
- return false, err
- }
-
- ctx, timeoutCancel := context.WithTimeout(context.Background(), timeout)
- defer timeoutCancel()
-
- ticker := time.NewTicker(statusPollInterval)
- defer ticker.Stop()
-
- for {
- select {
- case <-ctx.Done():
- return false, fmt.Errorf("timeout waiting for service status %v", expectedStatus)
- case <-ticker.C:
- status, err := s.Status()
- if err != nil {
- // Continue polling on transient errors
- continue
- }
- if status == expectedStatus {
- return true, nil
- }
- }
- }
-}
-
-// TestServiceLifecycle tests the complete service lifecycle
-func TestServiceLifecycle(t *testing.T) {
- // TODO: Add support for Windows and macOS
- if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
- t.Skipf("Skipping service lifecycle test on unsupported OS: %s", runtime.GOOS)
- }
-
- if os.Getenv("CONTAINER") == "true" {
- t.Skip("Skipping service lifecycle test in container environment")
- }
-
- originalServiceName := serviceName
- serviceName = "netbirdtest" + fmt.Sprintf("%d", time.Now().Unix())
- defer func() {
- serviceName = originalServiceName
- }()
-
- tempDir := t.TempDir()
- configPath = fmt.Sprintf("%s/netbird-test-config.json", tempDir)
- logLevel = "info"
- daemonAddr = fmt.Sprintf("unix://%s/netbird-test.sock", tempDir)
-
- // Ensure cleanup even if a subtest fails and Stop/Uninstall subtests don't run.
- t.Cleanup(func() {
- cfg, err := newSVCConfig()
- if err != nil {
- t.Errorf("cleanup: create service config: %v", err)
- return
- }
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- if err != nil {
- t.Errorf("cleanup: create service: %v", err)
- return
- }
-
- // If the subtests already cleaned up, there's nothing to do.
- if _, err := s.Status(); err != nil {
- return
- }
-
- if err := s.Stop(); err != nil {
- t.Errorf("cleanup: stop service: %v", err)
- }
- if err := s.Uninstall(); err != nil {
- t.Errorf("cleanup: uninstall service: %v", err)
- }
- })
-
- ctx := context.Background()
-
- t.Run("Install", func(t *testing.T) {
- installCmd.SetContext(ctx)
- err := installCmd.RunE(installCmd, []string{})
- require.NoError(t, err)
-
- cfg, err := newSVCConfig()
- require.NoError(t, err)
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- require.NoError(t, err)
-
- status, err := s.Status()
- assert.NoError(t, err)
- assert.NotEqual(t, service.StatusUnknown, status)
- })
-
- t.Run("Start", func(t *testing.T) {
- startCmd.SetContext(ctx)
- err := startCmd.RunE(startCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Restart", func(t *testing.T) {
- restartCmd.SetContext(ctx)
- err := restartCmd.RunE(restartCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Reconfigure", func(t *testing.T) {
- originalLogLevel := logLevel
- logLevel = "debug"
- defer func() {
- logLevel = originalLogLevel
- }()
-
- reconfigureCmd.SetContext(ctx)
- err := reconfigureCmd.RunE(reconfigureCmd, []string{})
- require.NoError(t, err)
-
- running, err := waitForServiceStatus(service.StatusRunning, serviceStartTimeout)
- require.NoError(t, err)
- assert.True(t, running)
- })
-
- t.Run("Stop", func(t *testing.T) {
- stopCmd.SetContext(ctx)
- err := stopCmd.RunE(stopCmd, []string{})
- require.NoError(t, err)
-
- stopped, err := waitForServiceStatus(service.StatusStopped, serviceStopTimeout)
- require.NoError(t, err)
- assert.True(t, stopped)
- })
-
- t.Run("Uninstall", func(t *testing.T) {
- uninstallCmd.SetContext(ctx)
- err := uninstallCmd.RunE(uninstallCmd, []string{})
- require.NoError(t, err)
-
- cfg, err := newSVCConfig()
- require.NoError(t, err)
-
- ctxSvc, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- s, err := newSVC(newProgram(ctxSvc, cancel), cfg)
- require.NoError(t, err)
-
- _, err = s.Status()
- assert.Error(t, err)
- })
-}
-
// TestServiceEnvVars tests environment variable parsing
func TestServiceEnvVars(t *testing.T) {
tests := []struct {
diff --git a/client/firewall/iptables/manager_linux_test.go b/client/firewall/iptables/manager_linux_test.go
index cc4bda0e0..7b0989f6c 100644
--- a/client/firewall/iptables/manager_linux_test.go
+++ b/client/firewall/iptables/manager_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package iptables
import (
diff --git a/client/firewall/iptables/router_linux_test.go b/client/firewall/iptables/router_linux_test.go
index 6707573be..9ca6b9f7e 100644
--- a/client/firewall/iptables/router_linux_test.go
+++ b/client/firewall/iptables/router_linux_test.go
@@ -1,4 +1,4 @@
-//go:build !android
+//go:build !android && privileged
package iptables
diff --git a/client/firewall/nftables/manager_linux_test.go b/client/firewall/nftables/manager_linux_test.go
index be4f65881..4eb466281 100644
--- a/client/firewall/nftables/manager_linux_test.go
+++ b/client/firewall/nftables/manager_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package nftables
import (
diff --git a/client/firewall/nftables/router_linux_test.go b/client/firewall/nftables/router_linux_test.go
index c5d6729d9..2fc664d51 100644
--- a/client/firewall/nftables/router_linux_test.go
+++ b/client/firewall/nftables/router_linux_test.go
@@ -1,4 +1,4 @@
-//go:build !android
+//go:build !android && privileged
package nftables
diff --git a/client/iface/iface_test.go b/client/iface/iface_test.go
index dbeb69bc6..8ff2bbb54 100644
--- a/client/iface/iface_test.go
+++ b/client/iface/iface_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package iface
import (
diff --git a/client/iface/wgproxy/proxy_linux_test.go b/client/iface/wgproxy/proxy_linux_test.go
index 7f7abcb4a..e34dd3b6b 100644
--- a/client/iface/wgproxy/proxy_linux_test.go
+++ b/client/iface/wgproxy/proxy_linux_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package wgproxy
diff --git a/client/iface/wgproxy/proxy_seed_test.go b/client/iface/wgproxy/proxy_seed_test.go
index 9278029a5..4fb9ed77a 100644
--- a/client/iface/wgproxy/proxy_seed_test.go
+++ b/client/iface/wgproxy/proxy_seed_test.go
@@ -1,4 +1,4 @@
-//go:build !linux
+//go:build !linux || !privileged
package wgproxy
diff --git a/client/iface/wgproxy/redirect_test.go b/client/iface/wgproxy/redirect_test.go
index b52eead25..135970838 100644
--- a/client/iface/wgproxy/redirect_test.go
+++ b/client/iface/wgproxy/redirect_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package wgproxy
@@ -26,64 +26,6 @@ func compareUDPAddr(addr1, addr2 net.Addr) bool {
return udpAddr1.IP.Equal(udpAddr2.IP) && udpAddr1.Port == udpAddr2.Port
}
-// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses
-func TestRedirectAs_eBPF_IPv4(t *testing.T) {
- wgPort := 51850
- ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
- if err := ebpfProxy.Listen(); err != nil {
- t.Fatalf("failed to initialize ebpf proxy: %v", err)
- }
- defer func() {
- if err := ebpfProxy.Free(); err != nil {
- t.Errorf("failed to free ebpf proxy: %v", err)
- }
- }()
-
- proxy := ebpf.NewProxyWrapper(ebpfProxy)
-
- // NetBird UDP address of the remote peer
- nbAddr := &net.UDPAddr{
- IP: net.ParseIP("100.108.111.177"),
- Port: 38746,
- }
-
- p2pEndpoint := &net.UDPAddr{
- IP: net.ParseIP("192.168.0.56"),
- Port: 51820,
- }
-
- testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
-}
-
-// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses
-func TestRedirectAs_eBPF_IPv6(t *testing.T) {
- wgPort := 51851
- ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
- if err := ebpfProxy.Listen(); err != nil {
- t.Fatalf("failed to initialize ebpf proxy: %v", err)
- }
- defer func() {
- if err := ebpfProxy.Free(); err != nil {
- t.Errorf("failed to free ebpf proxy: %v", err)
- }
- }()
-
- proxy := ebpf.NewProxyWrapper(ebpfProxy)
-
- // NetBird UDP address of the remote peer
- nbAddr := &net.UDPAddr{
- IP: net.ParseIP("100.108.111.177"),
- Port: 38746,
- }
-
- p2pEndpoint := &net.UDPAddr{
- IP: net.ParseIP("fe80::56"),
- Port: 51820,
- }
-
- testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
-}
-
// TestRedirectAs_UDP_IPv4 tests RedirectAs with UDP proxy using IPv4 addresses
func TestRedirectAs_UDP_IPv4(t *testing.T) {
wgPort := 51852
@@ -256,6 +198,64 @@ func testRedirectAs(t *testing.T, proxy Proxy, wgPort int, nbAddr, p2pEndpoint *
}
}
+// TestRedirectAs_eBPF_IPv4 tests RedirectAs with eBPF proxy using IPv4 addresses
+func TestRedirectAs_eBPF_IPv4(t *testing.T) {
+ wgPort := 51850
+ ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
+ if err := ebpfProxy.Listen(); err != nil {
+ t.Fatalf("failed to initialize ebpf proxy: %v", err)
+ }
+ defer func() {
+ if err := ebpfProxy.Free(); err != nil {
+ t.Errorf("failed to free ebpf proxy: %v", err)
+ }
+ }()
+
+ proxy := ebpf.NewProxyWrapper(ebpfProxy)
+
+ // NetBird UDP address of the remote peer
+ nbAddr := &net.UDPAddr{
+ IP: net.ParseIP("100.108.111.177"),
+ Port: 38746,
+ }
+
+ p2pEndpoint := &net.UDPAddr{
+ IP: net.ParseIP("192.168.0.56"),
+ Port: 51820,
+ }
+
+ testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
+}
+
+// TestRedirectAs_eBPF_IPv6 tests RedirectAs with eBPF proxy using IPv6 addresses
+func TestRedirectAs_eBPF_IPv6(t *testing.T) {
+ wgPort := 51851
+ ebpfProxy := ebpf.NewWGEBPFProxy(wgPort, 1280)
+ if err := ebpfProxy.Listen(); err != nil {
+ t.Fatalf("failed to initialize ebpf proxy: %v", err)
+ }
+ defer func() {
+ if err := ebpfProxy.Free(); err != nil {
+ t.Errorf("failed to free ebpf proxy: %v", err)
+ }
+ }()
+
+ proxy := ebpf.NewProxyWrapper(ebpfProxy)
+
+ // NetBird UDP address of the remote peer
+ nbAddr := &net.UDPAddr{
+ IP: net.ParseIP("100.108.111.177"),
+ Port: 38746,
+ }
+
+ p2pEndpoint := &net.UDPAddr{
+ IP: net.ParseIP("fe80::56"),
+ Port: 51820,
+ }
+
+ testRedirectAs(t, proxy, wgPort, nbAddr, p2pEndpoint)
+}
+
// TestRedirectAs_Multiple_Switches tests switching between multiple endpoints
func TestRedirectAs_Multiple_Switches(t *testing.T) {
wgPort := 51856
diff --git a/client/internal/dns/server_privileged_test.go b/client/internal/dns/server_privileged_test.go
new file mode 100644
index 000000000..a03aea169
--- /dev/null
+++ b/client/internal/dns/server_privileged_test.go
@@ -0,0 +1,485 @@
+//go:build privileged
+
+package dns
+
+import (
+ "context"
+ "fmt"
+ "net/netip"
+ "os"
+ "testing"
+
+ "github.com/golang/mock/gomock"
+ "github.com/miekg/dns"
+ "github.com/stretchr/testify/assert"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+ "github.com/netbirdio/netbird/client/iface"
+ pfmock "github.com/netbirdio/netbird/client/iface/mocks"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/dns/local"
+ "github.com/netbirdio/netbird/client/internal/dns/test"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/stdnet"
+ nbdns "github.com/netbirdio/netbird/dns"
+)
+
+func TestUpdateDNSServer(t *testing.T) {
+
+ nameServers := []nbdns.NameServer{
+ {
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ {
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ }
+
+ testCases := []struct {
+ name string
+ initUpstreamMap []handlerWrapper
+ initLocalZones []nbdns.CustomZone
+ initSerial uint64
+ inputSerial uint64
+ inputUpdate nbdns.Config
+ shouldFail bool
+ expectedUpstreamMap []handlerWrapper
+ expectedLocalQs []dns.Question
+ }{
+ {
+ name: "Initial Config Should Succeed",
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.io",
+ priority: PriorityUpstream,
+ },
+ {
+ domain: "netbird.cloud",
+ priority: PriorityLocal,
+ },
+ {
+ domain: nbdns.RootZone,
+ priority: PriorityDefault,
+ },
+ },
+ expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
+ },
+ {
+ name: "New Config Should Succeed",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.cloud",
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{
+ {
+ domain: "netbird.io",
+ priority: PriorityUpstream,
+ },
+ {
+ domain: "netbird.cloud",
+ priority: PriorityLocal,
+ },
+ },
+ expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
+ },
+ {
+ name: "Smaller Config Serial Should Be Skipped",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 2,
+ inputSerial: 1,
+ shouldFail: true,
+ },
+ {
+ name: "Empty NS Group Domain Or Not Primary Element Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ },
+ },
+ },
+ shouldFail: true,
+ },
+ {
+ name: "Invalid NS Group Nameservers list Should Fail",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ },
+ },
+ },
+ shouldFail: true,
+ },
+ {
+ name: "Invalid Custom Zone Records list Should Skip",
+ initLocalZones: []nbdns.CustomZone{},
+ initUpstreamMap: nil,
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ },
+ expectedUpstreamMap: []handlerWrapper{{
+ domain: ".",
+ priority: PriorityDefault,
+ }},
+ },
+ {
+ name: "Empty Config Should Succeed and Clean Maps",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{ServiceEnable: true},
+ expectedUpstreamMap: nil,
+ expectedLocalQs: []dns.Question{},
+ },
+ {
+ name: "Disabled Service Should clean map",
+ initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
+ initUpstreamMap: []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &mockHandler{},
+ priority: PriorityUpstream,
+ },
+ },
+ initSerial: 0,
+ inputSerial: 1,
+ inputUpdate: nbdns.Config{ServiceEnable: false},
+ expectedUpstreamMap: nil,
+ expectedLocalQs: []dns.Question{},
+ },
+ }
+
+ for n, testCase := range testCases {
+ t.Run(testCase.name, func(t *testing.T) {
+ privKey, _ := wgtypes.GenerateKey()
+ newNet, err := stdnet.NewNet(context.Background(), nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ opts := iface.WGIFaceOpts{
+ IFaceName: fmt.Sprintf("utun230%d", n),
+ Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)),
+ WGPort: 33100,
+ WGPrivKey: privKey.String(),
+ MTU: iface.DefaultMTU,
+ TransportNet: newNet,
+ }
+
+ wgIface, err := iface.NewWGIFace(opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = wgIface.Create()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ err = wgIface.Close()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+ dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
+ WgInterface: wgIface,
+ CustomAddress: "",
+ StatusRecorder: peer.NewRecorder("mgm"),
+ StateManager: nil,
+ DisableSys: false,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ err = dnsServer.Initialize()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer func() {
+ err = dnsServer.hostManager.restoreHostDNS()
+ if err != nil {
+ t.Log(err)
+ }
+ }()
+
+ dnsServer.dnsMuxHandlers = testCase.initUpstreamMap
+ dnsServer.localResolver.Update(testCase.initLocalZones)
+ dnsServer.updateSerial = testCase.initSerial
+
+ err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
+ if err != nil {
+ if testCase.shouldFail {
+ return
+ }
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ }
+
+ if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) {
+ t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers))
+ }
+
+ for _, expected := range testCase.expectedUpstreamMap {
+ found := false
+ for _, got := range dnsServer.dnsMuxHandlers {
+ if got.domain == expected.domain && got.priority == expected.priority {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers)
+ }
+ }
+
+ var responseMSG *dns.Msg
+ responseWriter := &test.MockResponseWriter{
+ WriteMsgFunc: func(m *dns.Msg) error {
+ responseMSG = m
+ return nil
+ },
+ }
+ for _, q := range testCase.expectedLocalQs {
+ dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{
+ Question: []dns.Question{q},
+ })
+ }
+
+ if len(testCase.expectedLocalQs) > 0 {
+ assert.NotNil(t, responseMSG, "response message should not be nil")
+ assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success")
+ assert.NotEmpty(t, responseMSG.Answer, "response message should have answers")
+ }
+ })
+ }
+}
+
+func TestDNSFakeResolverHandleUpdates(t *testing.T) {
+ ov := os.Getenv("NB_WG_KERNEL_DISABLED")
+ defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
+
+ t.Setenv("NB_WG_KERNEL_DISABLED", "true")
+ newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
+ if err != nil {
+ t.Errorf("create stdnet: %v", err)
+ return
+ }
+
+ privKey, _ := wgtypes.GeneratePrivateKey()
+ opts := iface.WGIFaceOpts{
+ IFaceName: "utun2301",
+ Address: wgaddr.MustParseWGAddress("100.66.100.1/32"),
+ WGPort: 33100,
+ WGPrivKey: privKey.String(),
+ MTU: iface.DefaultMTU,
+ TransportNet: newNet,
+ }
+ wgIface, err := iface.NewWGIFace(opts)
+ if err != nil {
+ t.Errorf("build interface wireguard: %v", err)
+ return
+ }
+
+ err = wgIface.Create()
+ if err != nil {
+ t.Errorf("create and init wireguard interface: %v", err)
+ return
+ }
+ defer func() {
+ if err = wgIface.Close(); err != nil {
+ t.Logf("close wireguard interface: %v", err)
+ }
+ }()
+
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ packetfilter := pfmock.NewMockPacketFilter(ctrl)
+ packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes()
+ packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+ packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
+
+ if err := wgIface.SetFilter(packetfilter); err != nil {
+ t.Errorf("set packet filter: %v", err)
+ return
+ }
+
+ dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
+ WgInterface: wgIface,
+ CustomAddress: "",
+ StatusRecorder: peer.NewRecorder("mgm"),
+ StateManager: nil,
+ DisableSys: false,
+ })
+ if err != nil {
+ t.Errorf("create DNS server: %v", err)
+ return
+ }
+
+ err = dnsServer.Initialize()
+ if err != nil {
+ t.Errorf("run DNS server: %v", err)
+ return
+ }
+ defer func() {
+ if err = dnsServer.hostManager.restoreHostDNS(); err != nil {
+ t.Logf("restore DNS settings on the host: %v", err)
+ return
+ }
+ }()
+
+ dnsServer.dnsMuxHandlers = []handlerWrapper{
+ {
+ domain: zoneRecords[0].Name,
+ handler: &local.Resolver{},
+ priority: PriorityUpstream,
+ },
+ }
+ dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
+ dnsServer.updateSerial = 0
+
+ nameServers := []nbdns.NameServer{
+ {
+ IP: netip.MustParseAddr("8.8.8.8"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ {
+ IP: netip.MustParseAddr("8.8.4.4"),
+ NSType: nbdns.UDPNameServerType,
+ Port: 53,
+ },
+ }
+
+ update := nbdns.Config{
+ ServiceEnable: true,
+ CustomZones: []nbdns.CustomZone{
+ {
+ Domain: "netbird.cloud",
+ Records: zoneRecords,
+ },
+ },
+ NameServerGroups: []*nbdns.NameServerGroup{
+ {
+ Domains: []string{"netbird.io"},
+ NameServers: nameServers,
+ },
+ {
+ NameServers: nameServers,
+ Primary: true,
+ },
+ },
+ }
+
+ // Start the server with regular configuration
+ if err := dnsServer.UpdateDNSServer(1, update); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+
+ update2 := update
+ update2.ServiceEnable = false
+ // Disable the server, stop the listener
+ if err := dnsServer.UpdateDNSServer(2, update2); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+
+ update3 := update2
+ update3.NameServerGroups = update3.NameServerGroups[:1]
+ // But service still get updates and we checking that we handle
+ // internal state in the right way
+ if err := dnsServer.UpdateDNSServer(3, update3); err != nil {
+ t.Fatalf("update dns server should not fail, got error: %v", err)
+ return
+ }
+}
diff --git a/client/internal/dns/server_test.go b/client/internal/dns/server_test.go
index 4ef790412..96e55a354 100644
--- a/client/internal/dns/server_test.go
+++ b/client/internal/dns/server_test.go
@@ -10,7 +10,6 @@ import (
"testing"
"time"
- "github.com/golang/mock/gomock"
"github.com/miekg/dns"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -23,7 +22,6 @@ import (
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/configurer"
"github.com/netbirdio/netbird/client/iface/device"
- pfmock "github.com/netbirdio/netbird/client/iface/mocks"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/dns/local"
"github.com/netbirdio/netbird/client/internal/dns/test"
@@ -104,466 +102,6 @@ func init() {
formatter.SetTextFormatter(log.StandardLogger())
}
-func TestUpdateDNSServer(t *testing.T) {
-
- nameServers := []nbdns.NameServer{
- {
- IP: netip.MustParseAddr("8.8.8.8"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- {
- IP: netip.MustParseAddr("8.8.4.4"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- }
-
- testCases := []struct {
- name string
- initUpstreamMap []handlerWrapper
- initLocalZones []nbdns.CustomZone
- initSerial uint64
- inputSerial uint64
- inputUpdate nbdns.Config
- shouldFail bool
- expectedUpstreamMap []handlerWrapper
- expectedLocalQs []dns.Question
- }{
- {
- name: "Initial Config Should Succeed",
- initUpstreamMap: nil,
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- },
- expectedUpstreamMap: []handlerWrapper{
- {
- domain: "netbird.io",
- priority: PriorityUpstream,
- },
- {
- domain: "netbird.cloud",
- priority: PriorityLocal,
- },
- {
- domain: nbdns.RootZone,
- priority: PriorityDefault,
- },
- },
- expectedLocalQs: []dns.Question{{Name: "peera.netbird.cloud.", Qtype: dns.TypeA, Qclass: dns.ClassINET}},
- },
- {
- name: "New Config Should Succeed",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: []handlerWrapper{
- {
- domain: "netbird.cloud",
- handler: &mockHandler{},
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- },
- },
- expectedUpstreamMap: []handlerWrapper{
- {
- domain: "netbird.io",
- priority: PriorityUpstream,
- },
- {
- domain: "netbird.cloud",
- priority: PriorityLocal,
- },
- },
- expectedLocalQs: []dns.Question{{Name: zoneRecords[0].Name, Qtype: 1, Qclass: 1}},
- },
- {
- name: "Smaller Config Serial Should Be Skipped",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: nil,
- initSerial: 2,
- inputSerial: 1,
- shouldFail: true,
- },
- {
- name: "Empty NS Group Domain Or Not Primary Element Should Fail",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: nil,
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- },
- },
- },
- shouldFail: true,
- },
- {
- name: "Invalid NS Group Nameservers list Should Fail",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: nil,
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- },
- },
- },
- shouldFail: true,
- },
- {
- name: "Invalid Custom Zone Records list Should Skip",
- initLocalZones: []nbdns.CustomZone{},
- initUpstreamMap: nil,
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- },
- expectedUpstreamMap: []handlerWrapper{{
- domain: ".",
- priority: PriorityDefault,
- }},
- },
- {
- name: "Empty Config Should Succeed and Clean Maps",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: []handlerWrapper{
- {
- domain: zoneRecords[0].Name,
- handler: &mockHandler{},
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{ServiceEnable: true},
- expectedUpstreamMap: nil,
- expectedLocalQs: []dns.Question{},
- },
- {
- name: "Disabled Service Should clean map",
- initLocalZones: []nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}},
- initUpstreamMap: []handlerWrapper{
- {
- domain: zoneRecords[0].Name,
- handler: &mockHandler{},
- priority: PriorityUpstream,
- },
- },
- initSerial: 0,
- inputSerial: 1,
- inputUpdate: nbdns.Config{ServiceEnable: false},
- expectedUpstreamMap: nil,
- expectedLocalQs: []dns.Question{},
- },
- }
-
- for n, testCase := range testCases {
- t.Run(testCase.name, func(t *testing.T) {
- privKey, _ := wgtypes.GenerateKey()
- newNet, err := stdnet.NewNet(context.Background(), nil)
- if err != nil {
- t.Fatal(err)
- }
-
- opts := iface.WGIFaceOpts{
- IFaceName: fmt.Sprintf("utun230%d", n),
- Address: wgaddr.MustParseWGAddress(fmt.Sprintf("100.66.100.%d/32", n+1)),
- WGPort: 33100,
- WGPrivKey: privKey.String(),
- MTU: iface.DefaultMTU,
- TransportNet: newNet,
- }
-
- wgIface, err := iface.NewWGIFace(opts)
- if err != nil {
- t.Fatal(err)
- }
- err = wgIface.Create()
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- err = wgIface.Close()
- if err != nil {
- t.Log(err)
- }
- }()
- dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
- WgInterface: wgIface,
- CustomAddress: "",
- StatusRecorder: peer.NewRecorder("mgm"),
- StateManager: nil,
- DisableSys: false,
- })
- if err != nil {
- t.Fatal(err)
- }
- err = dnsServer.Initialize()
- if err != nil {
- t.Fatal(err)
- }
- defer func() {
- err = dnsServer.hostManager.restoreHostDNS()
- if err != nil {
- t.Log(err)
- }
- }()
-
- dnsServer.dnsMuxHandlers = testCase.initUpstreamMap
- dnsServer.localResolver.Update(testCase.initLocalZones)
- dnsServer.updateSerial = testCase.initSerial
-
- err = dnsServer.UpdateDNSServer(testCase.inputSerial, testCase.inputUpdate)
- if err != nil {
- if testCase.shouldFail {
- return
- }
- t.Fatalf("update dns server should not fail, got error: %v", err)
- }
-
- if len(dnsServer.dnsMuxHandlers) != len(testCase.expectedUpstreamMap) {
- t.Fatalf("update upstream failed, map size is different than expected, want %d, got %d", len(testCase.expectedUpstreamMap), len(dnsServer.dnsMuxHandlers))
- }
-
- for _, expected := range testCase.expectedUpstreamMap {
- found := false
- for _, got := range dnsServer.dnsMuxHandlers {
- if got.domain == expected.domain && got.priority == expected.priority {
- found = true
- break
- }
- }
- if !found {
- t.Fatalf("update upstream failed, handler for domain=%s priority=%d not found in dnsMuxHandlers: %#v", expected.domain, expected.priority, dnsServer.dnsMuxHandlers)
- }
- }
-
- var responseMSG *dns.Msg
- responseWriter := &test.MockResponseWriter{
- WriteMsgFunc: func(m *dns.Msg) error {
- responseMSG = m
- return nil
- },
- }
- for _, q := range testCase.expectedLocalQs {
- dnsServer.localResolver.ServeDNS(responseWriter, &dns.Msg{
- Question: []dns.Question{q},
- })
- }
-
- if len(testCase.expectedLocalQs) > 0 {
- assert.NotNil(t, responseMSG, "response message should not be nil")
- assert.Equal(t, dns.RcodeSuccess, responseMSG.Rcode, "response code should be success")
- assert.NotEmpty(t, responseMSG.Answer, "response message should have answers")
- }
- })
- }
-}
-
-func TestDNSFakeResolverHandleUpdates(t *testing.T) {
- ov := os.Getenv("NB_WG_KERNEL_DISABLED")
- defer t.Setenv("NB_WG_KERNEL_DISABLED", ov)
-
- t.Setenv("NB_WG_KERNEL_DISABLED", "true")
- newNet, err := stdnet.NewNet(context.Background(), []string{"utun2301"})
- if err != nil {
- t.Errorf("create stdnet: %v", err)
- return
- }
-
- privKey, _ := wgtypes.GeneratePrivateKey()
- opts := iface.WGIFaceOpts{
- IFaceName: "utun2301",
- Address: wgaddr.MustParseWGAddress("100.66.100.1/32"),
- WGPort: 33100,
- WGPrivKey: privKey.String(),
- MTU: iface.DefaultMTU,
- TransportNet: newNet,
- }
- wgIface, err := iface.NewWGIFace(opts)
- if err != nil {
- t.Errorf("build interface wireguard: %v", err)
- return
- }
-
- err = wgIface.Create()
- if err != nil {
- t.Errorf("create and init wireguard interface: %v", err)
- return
- }
- defer func() {
- if err = wgIface.Close(); err != nil {
- t.Logf("close wireguard interface: %v", err)
- }
- }()
-
- ctrl := gomock.NewController(t)
- defer ctrl.Finish()
-
- packetfilter := pfmock.NewMockPacketFilter(ctrl)
- packetfilter.EXPECT().FilterOutbound(gomock.Any(), gomock.Any()).AnyTimes()
- packetfilter.EXPECT().SetUDPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
- packetfilter.EXPECT().SetTCPPacketHook(gomock.Any(), gomock.Any(), gomock.Any()).AnyTimes()
-
- if err := wgIface.SetFilter(packetfilter); err != nil {
- t.Errorf("set packet filter: %v", err)
- return
- }
-
- dnsServer, err := NewDefaultServer(context.Background(), DefaultServerConfig{
- WgInterface: wgIface,
- CustomAddress: "",
- StatusRecorder: peer.NewRecorder("mgm"),
- StateManager: nil,
- DisableSys: false,
- })
- if err != nil {
- t.Errorf("create DNS server: %v", err)
- return
- }
-
- err = dnsServer.Initialize()
- if err != nil {
- t.Errorf("run DNS server: %v", err)
- return
- }
- defer func() {
- if err = dnsServer.hostManager.restoreHostDNS(); err != nil {
- t.Logf("restore DNS settings on the host: %v", err)
- return
- }
- }()
-
- dnsServer.dnsMuxHandlers = []handlerWrapper{
- {
- domain: zoneRecords[0].Name,
- handler: &local.Resolver{},
- priority: PriorityUpstream,
- },
- }
- dnsServer.localResolver.Update([]nbdns.CustomZone{{Domain: "netbird.cloud", Records: []nbdns.SimpleRecord{{Name: "netbird.cloud", Type: int(dns.TypeA), Class: nbdns.DefaultClass, TTL: 300, RData: "10.0.0.1"}}}})
- dnsServer.updateSerial = 0
-
- nameServers := []nbdns.NameServer{
- {
- IP: netip.MustParseAddr("8.8.8.8"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- {
- IP: netip.MustParseAddr("8.8.4.4"),
- NSType: nbdns.UDPNameServerType,
- Port: 53,
- },
- }
-
- update := nbdns.Config{
- ServiceEnable: true,
- CustomZones: []nbdns.CustomZone{
- {
- Domain: "netbird.cloud",
- Records: zoneRecords,
- },
- },
- NameServerGroups: []*nbdns.NameServerGroup{
- {
- Domains: []string{"netbird.io"},
- NameServers: nameServers,
- },
- {
- NameServers: nameServers,
- Primary: true,
- },
- },
- }
-
- // Start the server with regular configuration
- if err := dnsServer.UpdateDNSServer(1, update); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-
- update2 := update
- update2.ServiceEnable = false
- // Disable the server, stop the listener
- if err := dnsServer.UpdateDNSServer(2, update2); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-
- update3 := update2
- update3.NameServerGroups = update3.NameServerGroups[:1]
- // But service still get updates and we checking that we handle
- // internal state in the right way
- if err := dnsServer.UpdateDNSServer(3, update3); err != nil {
- t.Fatalf("update dns server should not fail, got error: %v", err)
- return
- }
-}
-
func TestDNSServerStartStop(t *testing.T) {
testCases := []struct {
name string
diff --git a/client/internal/engine_privileged_test.go b/client/internal/engine_privileged_test.go
new file mode 100644
index 000000000..f787f741f
--- /dev/null
+++ b/client/internal/engine_privileged_test.go
@@ -0,0 +1,565 @@
+//go:build privileged
+
+package internal
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/google/uuid"
+ log "github.com/sirupsen/logrus"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+ "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+
+ "github.com/netbirdio/netbird/client/iface"
+ "github.com/netbirdio/netbird/client/iface/device"
+ "github.com/netbirdio/netbird/client/iface/wgaddr"
+ "github.com/netbirdio/netbird/client/internal/dns"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ "github.com/netbirdio/netbird/client/system"
+ nbdns "github.com/netbirdio/netbird/dns"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbcache "github.com/netbirdio/netbird/management/server/cache"
+ "github.com/netbirdio/netbird/management/server/groups"
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
+ "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/job"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/telemetry"
+ "github.com/netbirdio/netbird/management/server/types"
+ mgmt "github.com/netbirdio/netbird/shared/management/client"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ relayClient "github.com/netbirdio/netbird/shared/relay/client"
+ signal "github.com/netbirdio/netbird/shared/signal/client"
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ signalServer "github.com/netbirdio/netbird/signal/server"
+ "github.com/netbirdio/netbird/util"
+)
+
+func TestEngine_SSH(t *testing.T) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ engine := NewEngine(
+ ctx, cancel,
+ &EngineConfig{
+ WgIfaceName: "utun101",
+ WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
+ WgPrivateKey: key,
+ WgPort: 33100,
+ ServerSSHAllowed: true,
+ MTU: iface.DefaultMTU,
+ SSHKey: sshKey,
+ },
+ EngineServices{
+ SignalClient: &signal.MockClient{},
+ MgmClient: &mgmt.MockClient{},
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ },
+ MobileDependency{},
+ )
+
+ engine.dnsServer = &dns.MockServer{
+ UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
+ }
+
+ err = engine.Start(nil, nil)
+ require.NoError(t, err)
+
+ defer func() {
+ err := engine.Stop()
+ if err != nil {
+ return
+ }
+ }()
+
+ peerWithSSH := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ AllowedIps: []string{"100.64.0.21/24"},
+ SshConfig: &mgmtProto.SSHConfig{
+ SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"),
+ },
+ }
+
+ // SSH server is not enabled so SSH config of a remote peer should be ignored
+ networkMap := &mgmtProto.NetworkMap{
+ Serial: 6,
+ PeerConfig: nil,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ assert.Nil(t, engine.sshServer)
+
+ // SSH server is enabled, therefore SSH config should be applied
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 7,
+ PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
+ SshConfig: &mgmtProto.SSHConfig{
+ SshEnabled: true,
+ JwtConfig: &mgmtProto.JWTConfig{
+ Issuer: "test-issuer",
+ Audience: "test-audience",
+ KeysLocation: "test-keys",
+ MaxTokenAge: 3600,
+ },
+ }},
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ time.Sleep(250 * time.Millisecond)
+ assert.NotNil(t, engine.sshServer)
+
+ // now remove peer
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 8,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ // time.Sleep(250 * time.Millisecond)
+ assert.NotNil(t, engine.sshServer)
+
+ // now disable SSH server
+ networkMap = &mgmtProto.NetworkMap{
+ Serial: 9,
+ PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
+ SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}},
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
+ RemotePeersIsEmpty: false,
+ }
+
+ err = engine.updateNetworkMap(networkMap)
+ require.NoError(t, err)
+
+ assert.Nil(t, engine.sshServer)
+}
+
+func TestEngine_Sync(t *testing.T) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ // feed updates to Engine via mocked Management client
+ updates := make(chan *mgmtProto.SyncResponse)
+ defer close(updates)
+ syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
+ for msg := range updates {
+ err := msgHandler(msg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ }
+ return nil
+ }
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ engine := NewEngine(ctx, cancel, &EngineConfig{
+ WgIfaceName: "utun103",
+ WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
+ WgPrivateKey: key,
+ WgPort: 33100,
+ MTU: iface.DefaultMTU,
+ }, EngineServices{
+ SignalClient: &signal.MockClient{},
+ MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ }, MobileDependency{})
+ engine.ctx = ctx
+
+ engine.dnsServer = &dns.MockServer{
+ UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
+ }
+
+ defer func() {
+ err := engine.Stop()
+ if err != nil {
+ return
+ }
+ }()
+
+ err = engine.Start(nil, nil)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+
+ peer1 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
+ AllowedIps: []string{"100.64.0.10/24"},
+ }
+ peer2 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
+ AllowedIps: []string{"100.64.0.11/24"},
+ }
+ peer3 := &mgmtProto.RemotePeerConfig{
+ WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
+ AllowedIps: []string{"100.64.0.12/24"},
+ }
+ // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update
+ updates <- &mgmtProto.SyncResponse{
+ NetworkMap: &mgmtProto.NetworkMap{
+ Serial: 10,
+ PeerConfig: nil,
+ RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3},
+ RemotePeersIsEmpty: false,
+ },
+ }
+
+ timeout := time.After(time.Second * 2)
+ for {
+ select {
+ case <-timeout:
+ t.Fatalf("timeout while waiting for test to finish")
+ return
+ default:
+ }
+
+ if getPeers(engine) == 3 && engine.networkSerial == 10 {
+ break
+ }
+ }
+}
+
+func TestEngine_MultiplePeers(t *testing.T) {
+ // log.SetLevel(log.DebugLevel)
+
+ ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
+ defer cancel()
+
+ sigServer, signalAddr, err := startSignal(t)
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+ defer sigServer.Stop()
+ mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql")
+ if err != nil {
+ t.Fatal(err)
+ return
+ }
+ defer mgmtServer.GracefulStop()
+
+ setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
+
+ mu := sync.Mutex{}
+ engines := []*Engine{}
+ numPeers := 10
+ wg := sync.WaitGroup{}
+ wg.Add(numPeers)
+ // create and start peers
+ for i := 0; i < numPeers; i++ {
+ j := i
+ go func() {
+ engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr)
+ if err != nil {
+ wg.Done()
+ t.Errorf("unable to create the engine for peer %d with error %v", j, err)
+ return
+ }
+ engine.dnsServer = &dns.MockServer{}
+ mu.Lock()
+ defer mu.Unlock()
+ guid := fmt.Sprintf("{%s}", uuid.New().String())
+ device.CustomWindowsGUIDString = strings.ToLower(guid)
+ err = engine.Start(nil, nil)
+ if err != nil {
+ t.Errorf("unable to start engine for peer %d with error %v", j, err)
+ wg.Done()
+ return
+ }
+ engines = append(engines, engine)
+ wg.Done()
+ }()
+ }
+
+ // wait until all have been created and started
+ wg.Wait()
+ if len(engines) != numPeers {
+ t.Fatal("not all peers were started")
+ }
+ // check whether all the peer have expected peers connected
+
+ expectedConnected := numPeers * (numPeers - 1)
+
+ // adjust according to timeouts
+ timeout := 50 * time.Second
+ timeoutChan := time.After(timeout)
+ ticker := time.NewTicker(time.Second)
+ defer ticker.Stop()
+loop:
+ for {
+ select {
+ case <-timeoutChan:
+ t.Fatalf("waiting for expected connections timeout after %s", timeout.String())
+ break loop
+ case <-ticker.C:
+ totalConnected := 0
+ for _, engine := range engines {
+ totalConnected += getConnectedPeers(engine)
+ }
+ if totalConnected == expectedConnected {
+ log.Infof("total connected=%d", totalConnected)
+ break loop
+ }
+ log.Infof("total connected=%d", totalConnected)
+ }
+ }
+ // cleanup test
+ for n, peerEngine := range engines {
+ t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n)
+ errStop := peerEngine.mgmClient.Close()
+ if errStop != nil {
+ log.Infoln("got error trying to close management clients from engine: ", errStop)
+ }
+ errStop = peerEngine.Stop()
+ if errStop != nil {
+ log.Infoln("got error trying to close testing peers engine: ", errStop)
+ }
+ }
+}
+
+var (
+ kaep = keepalive.EnforcementPolicy{
+ MinTime: 15 * time.Second,
+ PermitWithoutStream: true,
+ }
+
+ kasp = keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Second,
+ MaxConnectionAgeGrace: 5 * time.Second,
+ Time: 5 * time.Second,
+ Timeout: 2 * time.Second,
+ }
+)
+
+func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) {
+ key, err := wgtypes.GeneratePrivateKey()
+ if err != nil {
+ return nil, err
+ }
+ mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false)
+ if err != nil {
+ return nil, err
+ }
+ signalClient, err := signal.NewClient(ctx, signalAddr, key, false)
+ if err != nil {
+ return nil, err
+ }
+
+ info := system.GetInfo(ctx)
+ resp, err := mgmtClient.Register(setupKey, "", info, nil, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ var ifaceName string
+ if runtime.GOOS == "darwin" {
+ ifaceName = fmt.Sprintf("utun1%d", i)
+ } else {
+ ifaceName = fmt.Sprintf("wt%d", i)
+ }
+
+ wgPort := 33100 + i
+ conf := &EngineConfig{
+ WgIfaceName: ifaceName,
+ WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
+ WgPrivateKey: key,
+ WgPort: wgPort,
+ MTU: iface.DefaultMTU,
+ }
+
+ relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
+ e, err := NewEngine(ctx, cancel, conf, EngineServices{
+ SignalClient: signalClient,
+ MgmClient: mgmtClient,
+ RelayManager: relayMgr,
+ StatusRecorder: peer.NewRecorder("https://mgm"),
+ }, MobileDependency{}), nil
+ e.ctx = ctx
+ return e, err
+}
+
+func startSignal(t *testing.T) (*grpc.Server, string, error) {
+ t.Helper()
+
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ log.Fatalf("failed to listen: %v", err)
+ }
+
+ srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ proto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) {
+ t.Helper()
+
+ config := &config.Config{
+ Stuns: []*config.Host{},
+ TURNConfig: &config.TURNConfig{},
+ Relay: &config.Relay{
+ Addresses: []string{"127.0.0.1:1234"},
+ CredentialsTTL: util.Duration{Duration: time.Hour},
+ Secret: "222222222222222222",
+ },
+ Signal: &config.Host{
+ Proto: "http",
+ URI: "localhost:10000",
+ },
+ Datadir: dataDir,
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir)
+ if err != nil {
+ return nil, "", err
+ }
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+ if err != nil {
+ return nil, "", err
+ }
+
+ permissionsManager := permissions.NewManager(store)
+ peersManager := peers.NewManager(store, permissionsManager)
+ jobManager := job.NewJobManager(nil, store, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
+
+ metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+ require.NoError(t, err)
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+ settingsMockManager := settings.NewMockManager(ctrl)
+ settingsMockManager.EXPECT().
+ GetSettings(gomock.Any(), gomock.Any(), gomock.Any()).
+ Return(&types.Settings{}, nil).
+ AnyTimes()
+ settingsMockManager.EXPECT().
+ GetExtraSettings(gomock.Any(), gomock.Any()).
+ Return(&types.ExtraSettings{}, nil).
+ AnyTimes()
+
+ groupsManager := groups.NewManagerMock()
+
+ updateManager := update_channel.NewPeersUpdateManager(metrics)
+ requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
+ networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
+ if err != nil {
+ return nil, "", err
+ }
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+// getConnectedPeers returns a connection Status or nil if peer connection wasn't found
+func getConnectedPeers(e *Engine) int {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+ i := 0
+ for _, id := range e.peerStore.PeersPubKey() {
+ conn, _ := e.peerStore.PeerConn(id)
+ if conn.IsConnected() {
+ i++
+ }
+ }
+ return i
+}
+
+func getPeers(e *Engine) int {
+ e.syncMsgMux.Lock()
+ defer e.syncMsgMux.Unlock()
+
+ return len(e.peerStore.PeersPubKey())
+}
diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go
index 8f29bf072..1ac9ceff7 100644
--- a/client/internal/engine_test.go
+++ b/client/internal/engine_test.go
@@ -6,37 +6,18 @@ import (
"net"
"net/netip"
"os"
- "runtime"
"strings"
"sync"
"testing"
"time"
- "github.com/golang/mock/gomock"
- "github.com/google/uuid"
- log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- "go.opentelemetry.io/otel"
wgdevice "golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
- "google.golang.org/grpc"
- "google.golang.org/grpc/keepalive"
"github.com/netbirdio/netbird/client/internal/stdnet"
- "github.com/netbirdio/netbird/management/server/job"
-
- "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
-
- "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
- "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
- "github.com/netbirdio/netbird/management/internals/modules/peers"
- "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
- nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
-
- "github.com/netbirdio/netbird/management/internals/server/config"
- "github.com/netbirdio/netbird/management/server/groups"
"github.com/netbirdio/netbird/client/iface"
"github.com/netbirdio/netbird/client/iface/configurer"
@@ -50,18 +31,7 @@ import (
icemaker "github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/routemanager"
- nbssh "github.com/netbirdio/netbird/client/ssh"
- "github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
- "github.com/netbirdio/netbird/management/server"
- "github.com/netbirdio/netbird/management/server/activity"
- nbcache "github.com/netbirdio/netbird/management/server/cache"
- "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
- "github.com/netbirdio/netbird/management/server/permissions"
- "github.com/netbirdio/netbird/management/server/settings"
- "github.com/netbirdio/netbird/management/server/store"
- "github.com/netbirdio/netbird/management/server/telemetry"
- "github.com/netbirdio/netbird/management/server/types"
"github.com/netbirdio/netbird/monotime"
"github.com/netbirdio/netbird/route"
mgmt "github.com/netbirdio/netbird/shared/management/client"
@@ -69,25 +39,9 @@ import (
"github.com/netbirdio/netbird/shared/netiputil"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
signal "github.com/netbirdio/netbird/shared/signal/client"
- "github.com/netbirdio/netbird/shared/signal/proto"
- signalServer "github.com/netbirdio/netbird/signal/server"
"github.com/netbirdio/netbird/util"
)
-var (
- kaep = keepalive.EnforcementPolicy{
- MinTime: 15 * time.Second,
- PermitWithoutStream: true,
- }
-
- kasp = keepalive.ServerParameters{
- MaxConnectionIdle: 15 * time.Second,
- MaxConnectionAgeGrace: 5 * time.Second,
- Time: 5 * time.Second,
- Timeout: 2 * time.Second,
- }
-)
-
type MockWGIface struct {
CreateFunc func() error
CreateOnAndroidFunc func(routeRange []string, ip string, domains []string) error
@@ -234,129 +188,6 @@ func TestMain(m *testing.M) {
os.Exit(code)
}
-func TestEngine_SSH(t *testing.T) {
- key, err := wgtypes.GeneratePrivateKey()
- if err != nil {
- t.Fatal(err)
- return
- }
-
- sshKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- if err != nil {
- t.Fatal(err)
- return
- }
-
- ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
- defer cancel()
-
- relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
- engine := NewEngine(
- ctx, cancel,
- &EngineConfig{
- WgIfaceName: "utun101",
- WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
- WgPrivateKey: key,
- WgPort: 33100,
- ServerSSHAllowed: true,
- MTU: iface.DefaultMTU,
- SSHKey: sshKey,
- },
- EngineServices{
- SignalClient: &signal.MockClient{},
- MgmClient: &mgmt.MockClient{},
- RelayManager: relayMgr,
- StatusRecorder: peer.NewRecorder("https://mgm"),
- },
- MobileDependency{},
- )
-
- engine.dnsServer = &dns.MockServer{
- UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
- }
-
- err = engine.Start(nil, nil)
- require.NoError(t, err)
-
- defer func() {
- err := engine.Stop()
- if err != nil {
- return
- }
- }()
-
- peerWithSSH := &mgmtProto.RemotePeerConfig{
- WgPubKey: "MNHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
- AllowedIps: []string{"100.64.0.21/24"},
- SshConfig: &mgmtProto.SSHConfig{
- SshPubKey: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFATYCqaQw/9id1Qkq3n16JYhDhXraI6Pc1fgB8ynEfQ"),
- },
- }
-
- // SSH server is not enabled so SSH config of a remote peer should be ignored
- networkMap := &mgmtProto.NetworkMap{
- Serial: 6,
- PeerConfig: nil,
- RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- assert.Nil(t, engine.sshServer)
-
- // SSH server is enabled, therefore SSH config should be applied
- networkMap = &mgmtProto.NetworkMap{
- Serial: 7,
- PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
- SshConfig: &mgmtProto.SSHConfig{
- SshEnabled: true,
- JwtConfig: &mgmtProto.JWTConfig{
- Issuer: "test-issuer",
- Audience: "test-audience",
- KeysLocation: "test-keys",
- MaxTokenAge: 3600,
- },
- }},
- RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- time.Sleep(250 * time.Millisecond)
- assert.NotNil(t, engine.sshServer)
-
- // now remove peer
- networkMap = &mgmtProto.NetworkMap{
- Serial: 8,
- RemotePeers: []*mgmtProto.RemotePeerConfig{},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- // time.Sleep(250 * time.Millisecond)
- assert.NotNil(t, engine.sshServer)
-
- // now disable SSH server
- networkMap = &mgmtProto.NetworkMap{
- Serial: 9,
- PeerConfig: &mgmtProto.PeerConfig{Address: "100.64.0.1/24",
- SshConfig: &mgmtProto.SSHConfig{SshEnabled: false}},
- RemotePeers: []*mgmtProto.RemotePeerConfig{peerWithSSH},
- RemotePeersIsEmpty: false,
- }
-
- err = engine.updateNetworkMap(networkMap)
- require.NoError(t, err)
-
- assert.Nil(t, engine.sshServer)
-}
-
func TestEngine_SSHUpdateLogic(t *testing.T) {
// Test that SSH server start/stop logic works based on config
engine := &Engine{
@@ -631,97 +462,6 @@ func TestEngine_UpdateNetworkMap(t *testing.T) {
}
}
-func TestEngine_Sync(t *testing.T) {
- key, err := wgtypes.GeneratePrivateKey()
- if err != nil {
- t.Fatal(err)
- return
- }
-
- ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
- defer cancel()
-
- // feed updates to Engine via mocked Management client
- updates := make(chan *mgmtProto.SyncResponse)
- defer close(updates)
- syncFunc := func(ctx context.Context, info *system.Info, msgHandler func(msg *mgmtProto.SyncResponse) error) error {
- for msg := range updates {
- err := msgHandler(msg)
- if err != nil {
- t.Fatal(err)
- }
- }
- return nil
- }
- relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
- engine := NewEngine(ctx, cancel, &EngineConfig{
- WgIfaceName: "utun103",
- WgAddr: wgaddr.MustParseWGAddress("100.64.0.1/24"),
- WgPrivateKey: key,
- WgPort: 33100,
- MTU: iface.DefaultMTU,
- }, EngineServices{
- SignalClient: &signal.MockClient{},
- MgmClient: &mgmt.MockClient{SyncFunc: syncFunc},
- RelayManager: relayMgr,
- StatusRecorder: peer.NewRecorder("https://mgm"),
- }, MobileDependency{})
- engine.ctx = ctx
-
- engine.dnsServer = &dns.MockServer{
- UpdateDNSServerFunc: func(serial uint64, update nbdns.Config) error { return nil },
- }
-
- defer func() {
- err := engine.Stop()
- if err != nil {
- return
- }
- }()
-
- err = engine.Start(nil, nil)
- if err != nil {
- t.Fatal(err)
- return
- }
-
- peer1 := &mgmtProto.RemotePeerConfig{
- WgPubKey: "RRHf3Ma6z6mdLbriAJbqhX7+nM/B71lgw2+91q3LfhU=",
- AllowedIps: []string{"100.64.0.10/24"},
- }
- peer2 := &mgmtProto.RemotePeerConfig{
- WgPubKey: "LLHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
- AllowedIps: []string{"100.64.0.11/24"},
- }
- peer3 := &mgmtProto.RemotePeerConfig{
- WgPubKey: "GGHf3Ma6z6mdLbriAJbqhX9+nM/B71lgw2+91q3LlhU=",
- AllowedIps: []string{"100.64.0.12/24"},
- }
- // 1st update with just 1 peer and serial larger than the current serial of the engine => apply update
- updates <- &mgmtProto.SyncResponse{
- NetworkMap: &mgmtProto.NetworkMap{
- Serial: 10,
- PeerConfig: nil,
- RemotePeers: []*mgmtProto.RemotePeerConfig{peer1, peer2, peer3},
- RemotePeersIsEmpty: false,
- },
- }
-
- timeout := time.After(time.Second * 2)
- for {
- select {
- case <-timeout:
- t.Fatalf("timeout while waiting for test to finish")
- return
- default:
- }
-
- if getPeers(engine) == 3 && engine.networkSerial == 10 {
- break
- }
- }
-}
-
func TestEngine_UpdateNetworkMapWithRoutes(t *testing.T) {
testCases := []struct {
name string
@@ -1105,104 +845,6 @@ func TestEngine_UpdateNetworkMapWithDNSUpdate(t *testing.T) {
}
}
-func TestEngine_MultiplePeers(t *testing.T) {
- // log.SetLevel(log.DebugLevel)
-
- ctx, cancel := context.WithCancel(CtxInitState(context.Background()))
- defer cancel()
-
- sigServer, signalAddr, err := startSignal(t)
- if err != nil {
- t.Fatal(err)
- return
- }
- defer sigServer.Stop()
- mgmtServer, mgmtAddr, err := startManagement(t, t.TempDir(), "../testdata/store.sql")
- if err != nil {
- t.Fatal(err)
- return
- }
- defer mgmtServer.GracefulStop()
-
- setupKey := "A2C8E62B-38F5-4553-B31E-DD66C696CEBB"
-
- mu := sync.Mutex{}
- engines := []*Engine{}
- numPeers := 10
- wg := sync.WaitGroup{}
- wg.Add(numPeers)
- // create and start peers
- for i := 0; i < numPeers; i++ {
- j := i
- go func() {
- engine, err := createEngine(ctx, cancel, setupKey, j, mgmtAddr, signalAddr)
- if err != nil {
- wg.Done()
- t.Errorf("unable to create the engine for peer %d with error %v", j, err)
- return
- }
- engine.dnsServer = &dns.MockServer{}
- mu.Lock()
- defer mu.Unlock()
- guid := fmt.Sprintf("{%s}", uuid.New().String())
- device.CustomWindowsGUIDString = strings.ToLower(guid)
- err = engine.Start(nil, nil)
- if err != nil {
- t.Errorf("unable to start engine for peer %d with error %v", j, err)
- wg.Done()
- return
- }
- engines = append(engines, engine)
- wg.Done()
- }()
- }
-
- // wait until all have been created and started
- wg.Wait()
- if len(engines) != numPeers {
- t.Fatal("not all peers was started")
- }
- // check whether all the peer have expected peers connected
-
- expectedConnected := numPeers * (numPeers - 1)
-
- // adjust according to timeouts
- timeout := 50 * time.Second
- timeoutChan := time.After(timeout)
- ticker := time.NewTicker(time.Second)
- defer ticker.Stop()
-loop:
- for {
- select {
- case <-timeoutChan:
- t.Fatalf("waiting for expected connections timeout after %s", timeout.String())
- break loop
- case <-ticker.C:
- totalConnected := 0
- for _, engine := range engines {
- totalConnected += getConnectedPeers(engine)
- }
- if totalConnected == expectedConnected {
- log.Infof("total connected=%d", totalConnected)
- break loop
- }
- log.Infof("total connected=%d", totalConnected)
- }
- }
- // cleanup test
- for n, peerEngine := range engines {
- t.Logf("stopping peer with interface %s from multipeer test, loopIndex %d", peerEngine.wgInterface.Name(), n)
- errStop := peerEngine.mgmClient.Close()
- if errStop != nil {
- log.Infoln("got error trying to close management clients from engine: ", errStop)
- }
- errStop = peerEngine.Stop()
- if errStop != nil {
- log.Infoln("got error trying to close testing peers engine: ", errStop)
- }
- }
-}
-
func Test_ParseNATExternalIPMappings(t *testing.T) {
ifaceList, err := net.Interfaces()
if err != nil {
@@ -1526,187 +1168,6 @@ func TestCompareNetIPLists(t *testing.T) {
}
}
-func createEngine(ctx context.Context, cancel context.CancelFunc, setupKey string, i int, mgmtAddr string, signalAddr string) (*Engine, error) {
- key, err := wgtypes.GeneratePrivateKey()
- if err != nil {
- return nil, err
- }
- mgmtClient, err := mgmt.NewClient(ctx, mgmtAddr, key, false)
- if err != nil {
- return nil, err
- }
- signalClient, err := signal.NewClient(ctx, signalAddr, key, false)
- if err != nil {
- return nil, err
- }
-
- info := system.GetInfo(ctx)
- resp, err := mgmtClient.Register(setupKey, "", info, nil, nil)
- if err != nil {
- return nil, err
- }
-
- var ifaceName string
- if runtime.GOOS == "darwin" {
- ifaceName = fmt.Sprintf("utun1%d", i)
- } else {
- ifaceName = fmt.Sprintf("wt%d", i)
- }
-
- wgPort := 33100 + i
- conf := &EngineConfig{
- WgIfaceName: ifaceName,
- WgAddr: wgaddr.MustParseWGAddress(resp.PeerConfig.Address),
- WgPrivateKey: key,
- WgPort: wgPort,
- MTU: iface.DefaultMTU,
- }
-
- relayMgr := relayClient.NewManager(ctx, nil, key.PublicKey().String(), iface.DefaultMTU)
- e, err := NewEngine(ctx, cancel, conf, EngineServices{
- SignalClient: signalClient,
- MgmClient: mgmtClient,
- RelayManager: relayMgr,
- StatusRecorder: peer.NewRecorder("https://mgm"),
- }, MobileDependency{}), nil
- e.ctx = ctx
- return e, err
-}
-
-func startSignal(t *testing.T) (*grpc.Server, string, error) {
- t.Helper()
-
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- log.Fatalf("failed to listen: %v", err)
- }
-
- srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
- require.NoError(t, err)
- proto.RegisterSignalExchangeServer(s, srv)
-
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
-
-func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, string, error) {
- t.Helper()
-
- config := &config.Config{
- Stuns: []*config.Host{},
- TURNConfig: &config.TURNConfig{},
- Relay: &config.Relay{
- Addresses: []string{"127.0.0.1:1234"},
- CredentialsTTL: util.Duration{Duration: time.Hour},
- Secret: "222222222222222222",
- },
- Signal: &config.Host{
- Proto: "http",
- URI: "localhost:10000",
- },
- Datadir: dataDir,
- HttpConfig: nil,
- }
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- return nil, "", err
- }
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
-
- store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), testFile, config.Datadir)
- if err != nil {
- return nil, "", err
- }
- t.Cleanup(cleanUp)
-
- eventStore := &activity.InMemoryEventStore{}
- if err != nil {
- return nil, "", err
- }
-
- permissionsManager := permissions.NewManager(store)
- peersManager := peers.NewManager(store, permissionsManager)
- jobManager := job.NewJobManager(nil, store, peersManager)
-
- cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
- if err != nil {
- return nil, "", err
- }
-
- ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
-
- metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
- require.NoError(t, err)
-
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
- settingsMockManager := settings.NewMockManager(ctrl)
- settingsMockManager.EXPECT().
- GetSettings(gomock.Any(), gomock.Any(), gomock.Any()).
- Return(&types.Settings{}, nil).
- AnyTimes()
- settingsMockManager.EXPECT().
- GetExtraSettings(gomock.Any(), gomock.Any()).
- Return(&types.ExtraSettings{}, nil).
- AnyTimes()
-
- groupsManager := groups.NewManagerMock()
-
- updateManager := update_channel.NewPeersUpdateManager(metrics)
- requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
- networkMapController := controller.NewController(context.Background(), store, metrics, updateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
- accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore)
- if err != nil {
- return nil, "", err
- }
-
- secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
- if err != nil {
- return nil, "", err
- }
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
- if err != nil {
- return nil, "", err
- }
- mgmtProto.RegisterManagementServiceServer(s, mgmtServer)
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
-
-// getConnectedPeers returns a connection Status or nil if peer connection wasn't found
-func getConnectedPeers(e *Engine) int {
- e.syncMsgMux.Lock()
- defer e.syncMsgMux.Unlock()
- i := 0
- for _, id := range e.peerStore.PeersPubKey() {
- conn, _ := e.peerStore.PeerConn(id)
- if conn.IsConnected() {
- i++
- }
- }
- return i
-}
-
-func getPeers(e *Engine) int {
- e.syncMsgMux.Lock()
- defer e.syncMsgMux.Unlock()
-
- return len(e.peerStore.PeersPubKey())
-}
-
func mustEncodePrefix(t *testing.T, p netip.Prefix) []byte {
t.Helper()
b, err := netiputil.EncodePrefix(p)
diff --git a/client/internal/routemanager/manager_test.go b/client/internal/routemanager/manager_test.go
index 926f06bc9..18b44820a 100644
--- a/client/internal/routemanager/manager_test.go
+++ b/client/internal/routemanager/manager_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package routemanager
import (
diff --git a/client/internal/routemanager/systemops/rt_tables_linux_test.go b/client/internal/routemanager/systemops/rt_tables_linux_test.go
new file mode 100644
index 000000000..bc9cca8b1
--- /dev/null
+++ b/client/internal/routemanager/systemops/rt_tables_linux_test.go
@@ -0,0 +1,69 @@
+//go:build linux && !android
+
+package systemops
+
+import (
+ "fmt"
+ "os"
+ "strings"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestEntryExists(t *testing.T) {
+ tempDir := t.TempDir()
+ tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir)
+
+ content := []string{
+ "1000 reserved",
+ fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName),
+ "9999 other_table",
+ }
+ require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644))
+
+ file, err := os.Open(tempFilePath)
+ require.NoError(t, err)
+ defer func() {
+ assert.NoError(t, file.Close())
+ }()
+
+ tests := []struct {
+ name string
+ id int
+ shouldExist bool
+ err error
+ }{
+ {
+ name: "ExistsWithNetbirdPrefix",
+ id: 7120,
+ shouldExist: true,
+ err: nil,
+ },
+ {
+ name: "ExistsWithDifferentName",
+ id: 1000,
+ shouldExist: true,
+ err: ErrTableIDExists,
+ },
+ {
+ name: "DoesNotExist",
+ id: 1234,
+ shouldExist: false,
+ err: nil,
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ exists, err := entryExists(file, tc.id)
+ if tc.err != nil {
+ assert.ErrorIs(t, err, tc.err)
+ } else {
+ assert.NoError(t, err)
+ }
+ assert.Equal(t, tc.shouldExist, exists)
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
new file mode 100644
index 000000000..d45028c19
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_bsd_privileged_test.go
@@ -0,0 +1,191 @@
+//go:build (darwin || dragonfly || freebsd || netbsd || openbsd) && privileged
+
+package systemops
+
+import (
+ "fmt"
+ "net"
+ "net/netip"
+ "os/exec"
+ "regexp"
+ "runtime"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func init() {
+ testCases = append(testCases, []testCase{
+ {
+ name: "To more specific route without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
+ },
+ }...)
+}
+
+func TestConcurrentRoutes(t *testing.T) {
+ baseIP := netip.MustParseAddr("192.0.2.0")
+
+ var intf *net.Interface
+ var nexthop Nexthop
+
+ _, intf = setupDummyInterface(t)
+ nexthop = Nexthop{netip.Addr{}, intf}
+
+ r := New(nil, nil)
+
+ var wg sync.WaitGroup
+ for i := 0; i < 1024; i++ {
+ wg.Add(1)
+ go func(ip netip.Addr) {
+ defer wg.Done()
+ prefix := netip.PrefixFrom(ip, 32)
+ if err := r.addToRouteTable(prefix, nexthop); err != nil {
+ t.Errorf("Failed to add route for %s: %v", prefix, err)
+ }
+ }(baseIP)
+ baseIP = baseIP.Next()
+ }
+
+ wg.Wait()
+
+ baseIP = netip.MustParseAddr("192.0.2.0")
+
+ for i := 0; i < 1024; i++ {
+ wg.Add(1)
+ go func(ip netip.Addr) {
+ defer wg.Done()
+ prefix := netip.PrefixFrom(ip, 32)
+ if err := r.removeFromRouteTable(prefix, nexthop); err != nil {
+ t.Errorf("Failed to remove route for %s: %v", prefix, err)
+ }
+ }(baseIP)
+ baseIP = baseIP.Next()
+ }
+
+ wg.Wait()
+}
+
+func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
+ t.Helper()
+
+ if runtime.GOOS == "darwin" {
+ err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run()
+ require.NoError(t, err, "Failed to create loopback alias")
+
+ t.Cleanup(func() {
+ err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run()
+ assert.NoError(t, err, "Failed to remove loopback alias")
+ })
+
+ return intf
+ }
+
+ prefix, err := netip.ParsePrefix(ipAddressCIDR)
+ require.NoError(t, err, "Failed to parse prefix")
+
+ netIntf, err := net.InterfaceByName(intf)
+ require.NoError(t, err, "Failed to get interface by name")
+
+ nexthop := Nexthop{netip.Addr{}, netIntf}
+
+ r := New(nil, nil)
+ err = r.addToRouteTable(prefix, nexthop)
+ require.NoError(t, err, "Failed to add route to table")
+
+ t.Cleanup(func() {
+ err := r.removeFromRouteTable(prefix, nexthop)
+ assert.NoError(t, err, "Failed to remove route from table")
+ })
+
+ return intf
+}
+
+func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) {
+ t.Helper()
+
+ var originalNexthop net.IP
+ if dstCIDR == "0.0.0.0/0" {
+ var err error
+ originalNexthop, err = fetchOriginalGateway()
+ if err != nil {
+ t.Logf("Failed to fetch original gateway: %v", err)
+ }
+
+ if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil {
+ t.Logf("Failed to delete route: %v, output: %s", err, output)
+ }
+ }
+
+ t.Cleanup(func() {
+ if originalNexthop != nil {
+ err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run()
+ assert.NoError(t, err, "Failed to restore original route")
+ }
+ })
+
+ err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run()
+ require.NoError(t, err, "Failed to add route")
+
+ t.Cleanup(func() {
+ err := exec.Command("route", "delete", "-net", dstCIDR).Run()
+ assert.NoError(t, err, "Failed to remove route")
+ })
+}
+
+func fetchOriginalGateway() (net.IP, error) {
+ output, err := exec.Command("route", "-n", "get", "default").CombinedOutput()
+ if err != nil {
+ return nil, err
+ }
+
+ matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output))
+ if len(matches) == 0 {
+ return nil, fmt.Errorf("gateway not found")
+ }
+
+ return net.ParseIP(matches[1]), nil
+}
+
+// setupDummyInterface creates a dummy tun interface for FreeBSD route testing
+func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) {
+ t.Helper()
+
+ if runtime.GOOS == "darwin" {
+ return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"}
+ }
+
+ output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput()
+ require.NoError(t, err, "Failed to create tun interface: %s", string(output))
+
+ tunName := strings.TrimSpace(string(output))
+
+ output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput()
+ require.NoError(t, err, "Failed to configure tun interface: %s", string(output))
+
+ intf, err := net.InterfaceByName(tunName)
+ require.NoError(t, err, "Failed to get interface by name")
+
+ t.Cleanup(func() {
+ if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil {
+ t.Logf("Failed to destroy tun interface %s: %v", tunName, err)
+ }
+ })
+
+ return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf
+}
+
+func setupDummyInterfacesAndRoutes(t *testing.T) {
+ t.Helper()
+
+ defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
+ addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy)
+
+ otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
+ addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy)
+}
diff --git a/client/internal/routemanager/systemops/systemops_bsd_test.go b/client/internal/routemanager/systemops/systemops_bsd_test.go
index ec4fc406e..9650945b3 100644
--- a/client/internal/routemanager/systemops/systemops_bsd_test.go
+++ b/client/internal/routemanager/systemops/systemops_bsd_test.go
@@ -3,79 +3,24 @@
package systemops
import (
- "fmt"
- "net"
- "net/netip"
- "os/exec"
- "regexp"
- "runtime"
- "strings"
- "sync"
"testing"
"github.com/stretchr/testify/assert"
- "github.com/stretchr/testify/require"
"golang.org/x/net/route"
)
+// Interface names used by the shared routing test fixtures. Kept untagged (no
+// privileged build tag) so the non-privileged test files in this package compile.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedVPNint = "utun100"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedExternalInt = "lo0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
var expectedInternalInt = "lo0"
-func init() {
- testCases = append(testCases, []testCase{
- {
- name: "To more specific route without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
- },
- }...)
-}
-
-func TestConcurrentRoutes(t *testing.T) {
- baseIP := netip.MustParseAddr("192.0.2.0")
-
- var intf *net.Interface
- var nexthop Nexthop
-
- _, intf = setupDummyInterface(t)
- nexthop = Nexthop{netip.Addr{}, intf}
-
- r := New(nil, nil)
-
- var wg sync.WaitGroup
- for i := 0; i < 1024; i++ {
- wg.Add(1)
- go func(ip netip.Addr) {
- defer wg.Done()
- prefix := netip.PrefixFrom(ip, 32)
- if err := r.addToRouteTable(prefix, nexthop); err != nil {
- t.Errorf("Failed to add route for %s: %v", prefix, err)
- }
- }(baseIP)
- baseIP = baseIP.Next()
- }
-
- wg.Wait()
-
- baseIP = netip.MustParseAddr("192.0.2.0")
-
- for i := 0; i < 1024; i++ {
- wg.Add(1)
- go func(ip netip.Addr) {
- defer wg.Done()
- prefix := netip.PrefixFrom(ip, 32)
- if err := r.removeFromRouteTable(prefix, nexthop); err != nil {
- t.Errorf("Failed to remove route for %s: %v", prefix, err)
- }
- }(baseIP)
- baseIP = baseIP.Next()
- }
-
- wg.Wait()
-}
-
func TestBits(t *testing.T) {
tests := []struct {
name string
@@ -122,122 +67,3 @@ func TestBits(t *testing.T) {
})
}
}
-
-func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
- t.Helper()
-
- if runtime.GOOS == "darwin" {
- err := exec.Command("ifconfig", intf, "alias", ipAddressCIDR).Run()
- require.NoError(t, err, "Failed to create loopback alias")
-
- t.Cleanup(func() {
- err := exec.Command("ifconfig", intf, ipAddressCIDR, "-alias").Run()
- assert.NoError(t, err, "Failed to remove loopback alias")
- })
-
- return intf
- }
-
- prefix, err := netip.ParsePrefix(ipAddressCIDR)
- require.NoError(t, err, "Failed to parse prefix")
-
- netIntf, err := net.InterfaceByName(intf)
- require.NoError(t, err, "Failed to get interface by name")
-
- nexthop := Nexthop{netip.Addr{}, netIntf}
-
- r := New(nil, nil)
- err = r.addToRouteTable(prefix, nexthop)
- require.NoError(t, err, "Failed to add route to table")
-
- t.Cleanup(func() {
- err := r.removeFromRouteTable(prefix, nexthop)
- assert.NoError(t, err, "Failed to remove route from table")
- })
-
- return intf
-}
-
-func addDummyRoute(t *testing.T, dstCIDR string, gw netip.Addr, _ string) {
- t.Helper()
-
- var originalNexthop net.IP
- if dstCIDR == "0.0.0.0/0" {
- var err error
- originalNexthop, err = fetchOriginalGateway()
- if err != nil {
- t.Logf("Failed to fetch original gateway: %v", err)
- }
-
- if output, err := exec.Command("route", "delete", "-net", dstCIDR).CombinedOutput(); err != nil {
- t.Logf("Failed to delete route: %v, output: %s", err, output)
- }
- }
-
- t.Cleanup(func() {
- if originalNexthop != nil {
- err := exec.Command("route", "add", "-net", dstCIDR, originalNexthop.String()).Run()
- assert.NoError(t, err, "Failed to restore original route")
- }
- })
-
- err := exec.Command("route", "add", "-net", dstCIDR, gw.String()).Run()
- require.NoError(t, err, "Failed to add route")
-
- t.Cleanup(func() {
- err := exec.Command("route", "delete", "-net", dstCIDR).Run()
- assert.NoError(t, err, "Failed to remove route")
- })
-}
-
-func fetchOriginalGateway() (net.IP, error) {
- output, err := exec.Command("route", "-n", "get", "default").CombinedOutput()
- if err != nil {
- return nil, err
- }
-
- matches := regexp.MustCompile(`gateway: (\S+)`).FindStringSubmatch(string(output))
- if len(matches) == 0 {
- return nil, fmt.Errorf("gateway not found")
- }
-
- return net.ParseIP(matches[1]), nil
-}
-
-// setupDummyInterface creates a dummy tun interface for FreeBSD route testing
-func setupDummyInterface(t *testing.T) (netip.Addr, *net.Interface) {
- t.Helper()
-
- if runtime.GOOS == "darwin" {
- return netip.AddrFrom4([4]byte{192, 168, 1, 2}), &net.Interface{Name: "lo0"}
- }
-
- output, err := exec.Command("ifconfig", "tun", "create").CombinedOutput()
- require.NoError(t, err, "Failed to create tun interface: %s", string(output))
-
- tunName := strings.TrimSpace(string(output))
-
- output, err = exec.Command("ifconfig", tunName, "192.168.1.1", "netmask", "255.255.0.0", "192.168.1.2", "up").CombinedOutput()
- require.NoError(t, err, "Failed to configure tun interface: %s", string(output))
-
- intf, err := net.InterfaceByName(tunName)
- require.NoError(t, err, "Failed to get interface by name")
-
- t.Cleanup(func() {
- if err := exec.Command("ifconfig", tunName, "destroy").Run(); err != nil {
- t.Logf("Failed to destroy tun interface %s: %v", tunName, err)
- }
- })
-
- return netip.AddrFrom4([4]byte{192, 168, 1, 2}), intf
-}
-
-func setupDummyInterfacesAndRoutes(t *testing.T) {
- t.Helper()
-
- defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
- addDummyRoute(t, "0.0.0.0/0", netip.AddrFrom4([4]byte{192, 168, 0, 1}), defaultDummy)
-
- otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
- addDummyRoute(t, "10.0.0.0/8", netip.AddrFrom4([4]byte{192, 168, 1, 1}), otherDummy)
-}
diff --git a/client/internal/routemanager/systemops/systemops_dialer_test.go b/client/internal/routemanager/systemops/systemops_dialer_test.go
new file mode 100644
index 000000000..f00f9099c
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_dialer_test.go
@@ -0,0 +1,17 @@
+//go:build !android && !ios
+
+package systemops
+
+import (
+ "context"
+ "net"
+)
+
+// dialer is shared by the per-platform routing test cases. Kept untagged (no
+// privileged build tag) so the non-privileged test files compile on every platform.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
+type dialer interface {
+ Dial(network, address string) (net.Conn, error)
+ DialContext(ctx context.Context, network, address string) (net.Conn, error)
+}
diff --git a/client/internal/routemanager/systemops/systemops_generic_test.go b/client/internal/routemanager/systemops/systemops_generic_test.go
index 5695c40c3..c4f739c30 100644
--- a/client/internal/routemanager/systemops/systemops_generic_test.go
+++ b/client/internal/routemanager/systemops/systemops_generic_test.go
@@ -1,4 +1,4 @@
-//go:build !android && !ios
+//go:build !android && !ios && privileged
package systemops
@@ -26,11 +26,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
-type dialer interface {
- Dial(network, address string) (net.Conn, error)
- DialContext(ctx context.Context, network, address string) (net.Conn, error)
-}
-
func TestAddVPNRoute(t *testing.T) {
testCases := []struct {
name string
@@ -515,125 +510,3 @@ func setupTestEnv(t *testing.T) {
// unique route in vpn table
setupRouteAndCleanup(t, r, netip.MustParsePrefix("172.16.0.0/12"), intf)
}
-
-func TestIsVpnRoute(t *testing.T) {
- tests := []struct {
- name string
- addr string
- vpnRoutes []string
- localRoutes []string
- expectedVpn bool
- expectedPrefix netip.Prefix
- }{
- {
- name: "Match in VPN routes",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Match in local routes",
- addr: "10.1.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"),
- },
- {
- name: "No match",
- addr: "172.16.0.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.Prefix{},
- },
- {
- name: "Default route ignored",
- addr: "192.168.1.1",
- vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Default route matches but ignored",
- addr: "172.16.1.1",
- vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
- localRoutes: []string{"10.0.0.0/8"},
- expectedVpn: false,
- expectedPrefix: netip.Prefix{},
- },
- {
- name: "Longest prefix match local",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.0.0/16"},
- localRoutes: []string{"192.168.1.0/24"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Longest prefix match local multiple",
- addr: "192.168.0.1",
- vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
- localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"),
- },
- {
- name: "Longest prefix match vpn",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"192.168.0.0/16"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- {
- name: "Longest prefix match vpn multiple",
- addr: "192.168.0.1",
- vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
- localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"},
- expectedVpn: true,
- expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"),
- },
- {
- name: "Duplicate prefix in both",
- addr: "192.168.1.1",
- vpnRoutes: []string{"192.168.1.0/24"},
- localRoutes: []string{"192.168.1.0/24"},
- expectedVpn: false,
- expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
- },
- }
-
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- addr, err := netip.ParseAddr(tt.addr)
- if err != nil {
- t.Fatalf("Failed to parse address %s: %v", tt.addr, err)
- }
-
- var vpnRoutes, localRoutes []netip.Prefix
- for _, route := range tt.vpnRoutes {
- prefix, err := netip.ParsePrefix(route)
- if err != nil {
- t.Fatalf("Failed to parse VPN route %s: %v", route, err)
- }
- vpnRoutes = append(vpnRoutes, prefix)
- }
-
- for _, route := range tt.localRoutes {
- prefix, err := netip.ParsePrefix(route)
- if err != nil {
- t.Fatalf("Failed to parse local route %s: %v", route, err)
- }
- localRoutes = append(localRoutes, prefix)
- }
-
- isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes)
- assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value")
- assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix")
- })
- }
-}
diff --git a/client/internal/routemanager/systemops/systemops_isvpnroute_test.go b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go
new file mode 100644
index 000000000..677fe1287
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_isvpnroute_test.go
@@ -0,0 +1,132 @@
+//go:build !android && !ios
+
+package systemops
+
+import (
+ "net/netip"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestIsVpnRoute(t *testing.T) {
+ tests := []struct {
+ name string
+ addr string
+ vpnRoutes []string
+ localRoutes []string
+ expectedVpn bool
+ expectedPrefix netip.Prefix
+ }{
+ {
+ name: "Match in VPN routes",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Match in local routes",
+ addr: "10.1.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("10.0.0.0/8"),
+ },
+ {
+ name: "No match",
+ addr: "172.16.0.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.Prefix{},
+ },
+ {
+ name: "Default route ignored",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Default route matches but ignored",
+ addr: "172.16.1.1",
+ vpnRoutes: []string{"0.0.0.0/0", "192.168.1.0/24"},
+ localRoutes: []string{"10.0.0.0/8"},
+ expectedVpn: false,
+ expectedPrefix: netip.Prefix{},
+ },
+ {
+ name: "Longest prefix match local",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.0.0/16"},
+ localRoutes: []string{"192.168.1.0/24"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Longest prefix match local multiple",
+ addr: "192.168.0.1",
+ vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
+ localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26", "192.168.0.0/28"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.0.0/28"),
+ },
+ {
+ name: "Longest prefix match vpn",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"192.168.0.0/16"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ {
+ name: "Longest prefix match vpn multiple",
+ addr: "192.168.0.1",
+ vpnRoutes: []string{"192.168.0.0/16", "192.168.0.0/25", "192.168.0.0/27"},
+ localRoutes: []string{"192.168.0.0/24", "192.168.0.0/26"},
+ expectedVpn: true,
+ expectedPrefix: netip.MustParsePrefix("192.168.0.0/27"),
+ },
+ {
+ name: "Duplicate prefix in both",
+ addr: "192.168.1.1",
+ vpnRoutes: []string{"192.168.1.0/24"},
+ localRoutes: []string{"192.168.1.0/24"},
+ expectedVpn: false,
+ expectedPrefix: netip.MustParsePrefix("192.168.1.0/24"),
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ addr, err := netip.ParseAddr(tt.addr)
+ if err != nil {
+ t.Fatalf("Failed to parse address %s: %v", tt.addr, err)
+ }
+
+ var vpnRoutes, localRoutes []netip.Prefix
+ for _, route := range tt.vpnRoutes {
+ prefix, err := netip.ParsePrefix(route)
+ if err != nil {
+ t.Fatalf("Failed to parse VPN route %s: %v", route, err)
+ }
+ vpnRoutes = append(vpnRoutes, prefix)
+ }
+
+ for _, route := range tt.localRoutes {
+ prefix, err := netip.ParsePrefix(route)
+ if err != nil {
+ t.Fatalf("Failed to parse local route %s: %v", route, err)
+ }
+ localRoutes = append(localRoutes, prefix)
+ }
+
+ isVpn, matchedPrefix := isVpnRoute(addr, vpnRoutes, localRoutes)
+ assert.Equal(t, tt.expectedVpn, isVpn, "isVpnRoute should return expectedVpn value")
+ assert.Equal(t, tt.expectedPrefix, matchedPrefix, "isVpnRoute should return expectedVpn prefix")
+ })
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_linux_test.go b/client/internal/routemanager/systemops/systemops_linux_test.go
index 880296d91..06c528ce5 100644
--- a/client/internal/routemanager/systemops/systemops_linux_test.go
+++ b/client/internal/routemanager/systemops/systemops_linux_test.go
@@ -1,13 +1,10 @@
-//go:build !android
+//go:build linux && !android && privileged
package systemops
import (
"errors"
- "fmt"
"net"
- "os"
- "strings"
"syscall"
"testing"
@@ -18,10 +15,6 @@ import (
"github.com/netbirdio/netbird/client/internal/routemanager/vars"
)
-var expectedVPNint = "wgtest0"
-var expectedExternalInt = "dummyext0"
-var expectedInternalInt = "dummyint0"
-
func init() {
testCases = append(testCases, []testCase{
{
@@ -33,62 +26,6 @@ func init() {
}...)
}
-func TestEntryExists(t *testing.T) {
- tempDir := t.TempDir()
- tempFilePath := fmt.Sprintf("%s/rt_tables", tempDir)
-
- content := []string{
- "1000 reserved",
- fmt.Sprintf("%d %s", NetbirdVPNTableID, NetbirdVPNTableName),
- "9999 other_table",
- }
- require.NoError(t, os.WriteFile(tempFilePath, []byte(strings.Join(content, "\n")), 0644))
-
- file, err := os.Open(tempFilePath)
- require.NoError(t, err)
- defer func() {
- assert.NoError(t, file.Close())
- }()
-
- tests := []struct {
- name string
- id int
- shouldExist bool
- err error
- }{
- {
- name: "ExistsWithNetbirdPrefix",
- id: 7120,
- shouldExist: true,
- err: nil,
- },
- {
- name: "ExistsWithDifferentName",
- id: 1000,
- shouldExist: true,
- err: ErrTableIDExists,
- },
- {
- name: "DoesNotExist",
- id: 1234,
- shouldExist: false,
- err: nil,
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- exists, err := entryExists(file, tc.id)
- if tc.err != nil {
- assert.ErrorIs(t, err, tc.err)
- } else {
- assert.NoError(t, err)
- }
- assert.Equal(t, tc.shouldExist, exists)
- })
- }
-}
-
func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) string {
t.Helper()
diff --git a/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
new file mode 100644
index 000000000..9be267980
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_routing_data_linux_test.go
@@ -0,0 +1,15 @@
+//go:build linux && !android
+
+package systemops
+
+// Interface names used by the shared routing test fixtures. Kept untagged (no
+// privileged build tag) so the non-privileged test files in this package compile.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedVPNint = "wgtest0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedExternalInt = "dummyext0"
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var expectedInternalInt = "dummyint0"
diff --git a/client/internal/routemanager/systemops/systemops_routing_data_test.go b/client/internal/routemanager/systemops/systemops_routing_data_test.go
new file mode 100644
index 000000000..16f17f5b9
--- /dev/null
+++ b/client/internal/routemanager/systemops/systemops_routing_data_test.go
@@ -0,0 +1,83 @@
+//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+
+package systemops
+
+import (
+ "net"
+
+ nbnet "github.com/netbirdio/netbird/client/net"
+)
+
+// Shared, non-privileged routing test fixtures. The privileged TestRouting (and its
+// per-platform init() appenders) consume these; they live here so the unprivileged
+// BSD/darwin test files compile without the privileged build tag.
+
+type PacketExpectation struct {
+ SrcIP net.IP
+ DstIP net.IP
+ SrcPort int
+ DstPort int
+ UDP bool
+ TCP bool
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+type testCase struct {
+ name string
+ expectedInterface string
+ dialer dialer
+ expectedPacket PacketExpectation
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+var testCases = []testCase{
+ {
+ name: "To external host without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
+ },
+ {
+ name: "To external host with custom dialer via physical interface",
+ expectedInterface: expectedExternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
+ },
+
+ {
+ name: "To duplicate internal route with custom dialer via physical interface",
+ expectedInterface: expectedInternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
+ },
+ {
+ name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
+ expectedInterface: expectedInternalInt,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
+ },
+
+ {
+ name: "To unique vpn route with custom dialer via physical interface",
+ expectedInterface: expectedExternalInt,
+ dialer: nbnet.NewDialer(),
+ expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53),
+ },
+ {
+ name: "To unique vpn route without custom dialer via vpn",
+ expectedInterface: expectedVPNint,
+ dialer: &net.Dialer{},
+ expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
+ },
+}
+
+//nolint:unused // consumed by the privileged-tagged routing tests
+func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation {
+ return PacketExpectation{
+ SrcIP: net.ParseIP(srcIP),
+ DstIP: net.ParseIP(dstIP),
+ SrcPort: srcPort,
+ DstPort: dstPort,
+ UDP: true,
+ }
+}
diff --git a/client/internal/routemanager/systemops/systemops_unix_test.go b/client/internal/routemanager/systemops/systemops_unix_test.go
index 959c697e4..efb0ae4e4 100644
--- a/client/internal/routemanager/systemops/systemops_unix_test.go
+++ b/client/internal/routemanager/systemops/systemops_unix_test.go
@@ -1,4 +1,4 @@
-//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+//go:build ((linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly) && privileged
package systemops
@@ -20,63 +20,6 @@ import (
nbnet "github.com/netbirdio/netbird/client/net"
)
-type PacketExpectation struct {
- SrcIP net.IP
- DstIP net.IP
- SrcPort int
- DstPort int
- UDP bool
- TCP bool
-}
-
-type testCase struct {
- name string
- expectedInterface string
- dialer dialer
- expectedPacket PacketExpectation
-}
-
-var testCases = []testCase{
- {
- name: "To external host without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
- },
- {
- name: "To external host with custom dialer via physical interface",
- expectedInterface: expectedExternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
- },
-
- {
- name: "To duplicate internal route with custom dialer via physical interface",
- expectedInterface: expectedInternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
- },
- {
- name: "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
- expectedInterface: expectedInternalInt,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("192.168.1.1", 12345, "10.0.0.2", 53),
- },
-
- {
- name: "To unique vpn route with custom dialer via physical interface",
- expectedInterface: expectedExternalInt,
- dialer: nbnet.NewDialer(),
- expectedPacket: createPacketExpectation("192.168.0.1", 12345, "172.16.0.2", 53),
- },
- {
- name: "To unique vpn route without custom dialer via vpn",
- expectedInterface: expectedVPNint,
- dialer: &net.Dialer{},
- expectedPacket: createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
- },
-}
-
func TestRouting(t *testing.T) {
nbnet.Init()
for _, tc := range testCases {
@@ -102,16 +45,6 @@ func TestRouting(t *testing.T) {
}
}
-func createPacketExpectation(srcIP string, srcPort int, dstIP string, dstPort int) PacketExpectation {
- return PacketExpectation{
- SrcIP: net.ParseIP(srcIP),
- DstIP: net.ParseIP(dstIP),
- SrcPort: srcPort,
- DstPort: dstPort,
- UDP: true,
- }
-}
-
func startPacketCapture(t *testing.T, intf, filter string) *pcap.Handle {
t.Helper()
diff --git a/client/internal/routemanager/systemops/systemops_windows_test.go b/client/internal/routemanager/systemops/systemops_windows_test.go
index 3561adec4..77e349bd6 100644
--- a/client/internal/routemanager/systemops/systemops_windows_test.go
+++ b/client/internal/routemanager/systemops/systemops_windows_test.go
@@ -1,3 +1,5 @@
+//go:build windows && privileged
+
package systemops
import (
diff --git a/client/internal/routemanager/systemops/v6route_bsd_test.go b/client/internal/routemanager/systemops/v6route_bsd_test.go
index 98ce29c6d..90e49f54e 100644
--- a/client/internal/routemanager/systemops/v6route_bsd_test.go
+++ b/client/internal/routemanager/systemops/v6route_bsd_test.go
@@ -11,6 +11,8 @@ import (
// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback
// interface so route lookups for global IPv6 prefixes resolve in environments
// without v6 connectivity. If a default already exists it is left alone.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
func ensureIPv6DefaultRoute(t *testing.T) {
t.Helper()
diff --git a/client/internal/routemanager/systemops/v6route_linux_test.go b/client/internal/routemanager/systemops/v6route_linux_test.go
index 0b17cefff..449d4cbd2 100644
--- a/client/internal/routemanager/systemops/v6route_linux_test.go
+++ b/client/internal/routemanager/systemops/v6route_linux_test.go
@@ -1,4 +1,4 @@
-//go:build linux && !android
+//go:build linux && !android && privileged
package systemops
diff --git a/client/internal/routemanager/systemops/v6route_windows_test.go b/client/internal/routemanager/systemops/v6route_windows_test.go
index f79277b87..2c813a790 100644
--- a/client/internal/routemanager/systemops/v6route_windows_test.go
+++ b/client/internal/routemanager/systemops/v6route_windows_test.go
@@ -8,11 +8,14 @@ import (
"testing"
)
+//nolint:unused // consumed by the privileged-tagged routing tests
const loopbackIfaceWindows = "Loopback Pseudo-Interface 1"
// ensureIPv6DefaultRoute installs an IPv6 default route via the loopback
// interface so route lookups for global IPv6 prefixes resolve in environments
// without v6 connectivity. If a default already exists it is left alone.
+//
+//nolint:unused // consumed by the privileged-tagged routing tests
func ensureIPv6DefaultRoute(t *testing.T) {
t.Helper()
diff --git a/client/server/server_privileged_test.go b/client/server/server_privileged_test.go
new file mode 100644
index 000000000..225cf6494
--- /dev/null
+++ b/client/server/server_privileged_test.go
@@ -0,0 +1,235 @@
+//go:build privileged
+
+package server
+
+import (
+ "context"
+ "net"
+ "os/user"
+ "testing"
+ "time"
+
+ "github.com/golang/mock/gomock"
+ "github.com/stretchr/testify/require"
+ "go.opentelemetry.io/otel"
+
+ "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
+
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
+ "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
+ "github.com/netbirdio/netbird/management/internals/modules/peers"
+ "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
+ nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
+ "github.com/netbirdio/netbird/management/server/job"
+
+ "github.com/netbirdio/netbird/management/internals/server/config"
+ "github.com/netbirdio/netbird/management/server/groups"
+
+ log "github.com/sirupsen/logrus"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/keepalive"
+
+ "github.com/netbirdio/netbird/client/internal"
+ "github.com/netbirdio/netbird/client/internal/peer"
+ "github.com/netbirdio/netbird/client/internal/profilemanager"
+ "github.com/netbirdio/netbird/management/server"
+ "github.com/netbirdio/netbird/management/server/activity"
+ nbcache "github.com/netbirdio/netbird/management/server/cache"
+ "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
+ "github.com/netbirdio/netbird/management/server/permissions"
+ "github.com/netbirdio/netbird/management/server/settings"
+ "github.com/netbirdio/netbird/management/server/store"
+ "github.com/netbirdio/netbird/management/server/telemetry"
+ mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
+ "github.com/netbirdio/netbird/shared/signal/proto"
+ signalServer "github.com/netbirdio/netbird/signal/server"
+)
+
+var (
+ kaep = keepalive.EnforcementPolicy{
+ MinTime: 15 * time.Second,
+ PermitWithoutStream: true,
+ }
+
+ kasp = keepalive.ServerParameters{
+ MaxConnectionIdle: 15 * time.Second,
+ MaxConnectionAgeGrace: 5 * time.Second,
+ Time: 5 * time.Second,
+ Timeout: 2 * time.Second,
+ }
+)
+
+// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables
+// we will use a management server started via to simulate the server and capture the number of retries
+func TestConnectWithRetryRuns(t *testing.T) {
+ // start the signal server
+ _, signalAddr, err := startSignal(t)
+ if err != nil {
+ t.Fatalf("failed to start signal server: %v", err)
+ }
+
+ counter := 0
+ // start the management server
+ _, mgmtAddr, err := startManagement(t, signalAddr, &counter)
+ if err != nil {
+ t.Fatalf("failed to start management server: %v", err)
+ }
+
+ ctx := internal.CtxInitState(context.Background())
+
+ ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second))
+ defer cancel()
+ // create new server
+ ic := profilemanager.ConfigInput{
+ ManagementURL: "http://" + mgmtAddr,
+ ConfigPath: t.TempDir() + "/test-profile.json",
+ }
+
+ config, err := profilemanager.UpdateOrCreateConfig(ic)
+ if err != nil {
+ t.Fatalf("failed to create config: %v", err)
+ }
+
+ currUser, err := user.Current()
+ require.NoError(t, err)
+
+ pm := profilemanager.ServiceManager{}
+ err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
+ ID: "test-profile",
+ Username: currUser.Username,
+ })
+ if err != nil {
+ t.Fatalf("failed to set active profile state: %v", err)
+ }
+
+ s := New(ctx, "debug", "", false, false, false, false)
+
+ s.config = config
+
+ s.statusRecorder = peer.NewRecorder(config.ManagementURL.String())
+ t.Setenv(retryInitialIntervalVar, "1s")
+ t.Setenv(maxRetryIntervalVar, "2s")
+ t.Setenv(maxRetryTimeVar, "5s")
+ t.Setenv(retryMultiplierVar, "1")
+
+ s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
+ if counter < 3 {
+ t.Fatalf("expected counter > 2, got %d", counter)
+ }
+}
+
+type mockServer struct {
+ mgmtProto.ManagementServiceServer
+ counter *int
+}
+
+func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
+ *m.counter++
+ return m.ManagementServiceServer.Login(ctx, req)
+}
+
+func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) {
+ t.Helper()
+ dataDir := t.TempDir()
+
+ config := &config.Config{
+ Stuns: []*config.Host{},
+ TURNConfig: &config.TURNConfig{},
+ Signal: &config.Host{
+ Proto: "http",
+ URI: signalAddr,
+ },
+ Datadir: dataDir,
+ HttpConfig: nil,
+ }
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+ store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir)
+ if err != nil {
+ return nil, "", err
+ }
+ t.Cleanup(cleanUp)
+
+ eventStore := &activity.InMemoryEventStore{}
+ if err != nil {
+ return nil, "", err
+ }
+
+ ctrl := gomock.NewController(t)
+ t.Cleanup(ctrl.Finish)
+
+ permissionsManagerMock := permissions.NewMockManager(ctrl)
+ peersManager := peers.NewManager(store, permissionsManagerMock)
+ settingsManagerMock := settings.NewMockManager(ctrl)
+
+ jobManager := job.NewJobManager(nil, store, peersManager)
+
+ cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
+ if err != nil {
+ return nil, "", err
+ }
+
+ ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
+
+ metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
+ require.NoError(t, err)
+
+ settingsMockManager := settings.NewMockManager(ctrl)
+ groupsManager := groups.NewManagerMock()
+
+ requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
+ peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
+ networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
+ accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
+ if err != nil {
+ return nil, "", err
+ }
+
+ secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
+ if err != nil {
+ return nil, "", err
+ }
+ mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
+ if err != nil {
+ return nil, "", err
+ }
+ mock := &mockServer{
+ ManagementServiceServer: mgmtServer,
+ counter: counter,
+ }
+ mgmtProto.RegisterManagementServiceServer(s, mock)
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
+
+func startSignal(t *testing.T) (*grpc.Server, string, error) {
+ t.Helper()
+
+ s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
+
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ return nil, "", err
+ }
+
+ srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
+ require.NoError(t, err)
+ proto.RegisterSignalExchangeServer(s, srv)
+
+ go func() {
+ if err = s.Serve(lis); err != nil {
+ log.Fatalf("failed to serve: %v", err)
+ }
+ }()
+
+ return s, lis.Addr().String(), nil
+}
diff --git a/client/server/server_test.go b/client/server/server_test.go
index fa9599818..7717cfcf8 100644
--- a/client/server/server_test.go
+++ b/client/server/server_test.go
@@ -2,124 +2,22 @@ package server
import (
"context"
- "net"
"net/url"
"os/user"
"path/filepath"
"testing"
"time"
- "github.com/golang/mock/gomock"
- "github.com/stretchr/testify/require"
- "go.opentelemetry.io/otel"
-
- "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
-
- "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
- "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
- "github.com/netbirdio/netbird/management/internals/modules/peers"
- "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
- nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
- "github.com/netbirdio/netbird/management/server/job"
-
- "github.com/netbirdio/netbird/management/internals/server/config"
- "github.com/netbirdio/netbird/management/server/groups"
-
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
"google.golang.org/grpc"
- "google.golang.org/grpc/keepalive"
"github.com/netbirdio/netbird/client/internal"
- "github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
daemonProto "github.com/netbirdio/netbird/client/proto"
- "github.com/netbirdio/netbird/management/server"
- "github.com/netbirdio/netbird/management/server/activity"
- nbcache "github.com/netbirdio/netbird/management/server/cache"
- "github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
- "github.com/netbirdio/netbird/management/server/permissions"
- "github.com/netbirdio/netbird/management/server/settings"
- "github.com/netbirdio/netbird/management/server/store"
- "github.com/netbirdio/netbird/management/server/telemetry"
- mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
- "github.com/netbirdio/netbird/shared/signal/proto"
- signalServer "github.com/netbirdio/netbird/signal/server"
)
-var (
- kaep = keepalive.EnforcementPolicy{
- MinTime: 15 * time.Second,
- PermitWithoutStream: true,
- }
-
- kasp = keepalive.ServerParameters{
- MaxConnectionIdle: 15 * time.Second,
- MaxConnectionAgeGrace: 5 * time.Second,
- Time: 5 * time.Second,
- Timeout: 2 * time.Second,
- }
-)
-
-// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables
-// we will use a management server started via to simulate the server and capture the number of retries
-func TestConnectWithRetryRuns(t *testing.T) {
- // start the signal server
- _, signalAddr, err := startSignal(t)
- if err != nil {
- t.Fatalf("failed to start signal server: %v", err)
- }
-
- counter := 0
- // start the management server
- _, mgmtAddr, err := startManagement(t, signalAddr, &counter)
- if err != nil {
- t.Fatalf("failed to start management server: %v", err)
- }
-
- ctx := internal.CtxInitState(context.Background())
-
- ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second))
- defer cancel()
- // create new server
- ic := profilemanager.ConfigInput{
- ManagementURL: "http://" + mgmtAddr,
- ConfigPath: t.TempDir() + "/test-profile.json",
- }
-
- config, err := profilemanager.UpdateOrCreateConfig(ic)
- if err != nil {
- t.Fatalf("failed to create config: %v", err)
- }
-
- currUser, err := user.Current()
- require.NoError(t, err)
-
- pm := profilemanager.ServiceManager{}
- err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
- ID: "test-profile",
- Username: currUser.Username,
- })
- if err != nil {
- t.Fatalf("failed to set active profile state: %v", err)
- }
-
- s := New(ctx, "debug", "", false, false, false, false)
-
- s.config = config
-
- s.statusRecorder = peer.NewRecorder(config.ManagementURL.String())
- t.Setenv(retryInitialIntervalVar, "1s")
- t.Setenv(maxRetryIntervalVar, "2s")
- t.Setenv(maxRetryTimeVar, "5s")
- t.Setenv(retryMultiplierVar, "1")
-
- s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
- if counter < 3 {
- t.Fatalf("expected counter > 2, got %d", counter)
- }
-}
-
func TestServer_Up(t *testing.T) {
tempDir := t.TempDir()
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
@@ -259,119 +157,3 @@ func TestServer_SubcribeEvents(t *testing.T) {
assert.NoError(t, err)
}
-
-type mockServer struct {
- mgmtProto.ManagementServiceServer
- counter *int
-}
-
-func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
- *m.counter++
- return m.ManagementServiceServer.Login(ctx, req)
-}
-
-func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) {
- t.Helper()
- dataDir := t.TempDir()
-
- config := &config.Config{
- Stuns: []*config.Host{},
- TURNConfig: &config.TURNConfig{},
- Signal: &config.Host{
- Proto: "http",
- URI: signalAddr,
- },
- Datadir: dataDir,
- HttpConfig: nil,
- }
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- return nil, "", err
- }
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
- store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir)
- if err != nil {
- return nil, "", err
- }
- t.Cleanup(cleanUp)
-
- eventStore := &activity.InMemoryEventStore{}
- if err != nil {
- return nil, "", err
- }
-
- ctrl := gomock.NewController(t)
- t.Cleanup(ctrl.Finish)
-
- permissionsManagerMock := permissions.NewMockManager(ctrl)
- peersManager := peers.NewManager(store, permissionsManagerMock)
- settingsManagerMock := settings.NewMockManager(ctrl)
-
- jobManager := job.NewJobManager(nil, store, peersManager)
-
- cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
- if err != nil {
- return nil, "", err
- }
-
- ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
-
- metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
- require.NoError(t, err)
-
- settingsMockManager := settings.NewMockManager(ctrl)
- groupsManager := groups.NewManagerMock()
-
- requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
- peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
- networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
- accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
- if err != nil {
- return nil, "", err
- }
-
- secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
- if err != nil {
- return nil, "", err
- }
- mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
- if err != nil {
- return nil, "", err
- }
- mock := &mockServer{
- ManagementServiceServer: mgmtServer,
- counter: counter,
- }
- mgmtProto.RegisterManagementServiceServer(s, mock)
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
-
-func startSignal(t *testing.T) (*grpc.Server, string, error) {
- t.Helper()
-
- s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
-
- lis, err := net.Listen("tcp", "localhost:0")
- if err != nil {
- log.Fatalf("failed to listen: %v", err)
- }
-
- srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
- require.NoError(t, err)
- proto.RegisterSignalExchangeServer(s, srv)
-
- go func() {
- if err = s.Serve(lis); err != nil {
- log.Fatalf("failed to serve: %v", err)
- }
- }()
-
- return s, lis.Addr().String(), nil
-}
diff --git a/client/ssh/client/client_privileged_test.go b/client/ssh/client/client_privileged_test.go
new file mode 100644
index 000000000..12edbbc06
--- /dev/null
+++ b/client/ssh/client/client_privileged_test.go
@@ -0,0 +1,118 @@
+//go:build privileged
+
+package client
+
+import (
+ "context"
+ "errors"
+ "runtime"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ cryptossh "golang.org/x/crypto/ssh"
+
+ "github.com/netbirdio/netbird/client/ssh/testutil"
+)
+
+func TestSSHClient_CommandExecution(t *testing.T) {
+ if runtime.GOOS == "windows" && testutil.IsCI() {
+ t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues")
+ }
+
+ server, _, client := setupTestSSHServerAndClient(t)
+ defer func() {
+ err := server.Stop()
+ require.NoError(t, err)
+ }()
+ defer func() {
+ err := client.Close()
+ assert.NoError(t, err)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+ defer cancel()
+
+ t.Run("ExecuteCommand captures output", func(t *testing.T) {
+ output, err := client.ExecuteCommand(ctx, "echo hello")
+ assert.NoError(t, err)
+ assert.Contains(t, string(output), "hello")
+ })
+
+ t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) {
+ err := client.ExecuteCommandWithIO(ctx, "echo world")
+ assert.NoError(t, err)
+ })
+
+ t.Run("commands with flags work", func(t *testing.T) {
+ output, err := client.ExecuteCommand(ctx, "echo -n test_flag")
+ assert.NoError(t, err)
+ assert.Equal(t, "test_flag", strings.TrimSpace(string(output)))
+ })
+
+ t.Run("non-zero exit codes don't return errors", func(t *testing.T) {
+ var testCmd string
+ if runtime.GOOS == "windows" {
+ testCmd = "echo hello | Select-String notfound"
+ } else {
+ testCmd = "echo 'hello' | grep 'notfound'"
+ }
+ _, err := client.ExecuteCommand(ctx, testCmd)
+ assert.NoError(t, err)
+ })
+}
+
+func TestSSHClient_ContextCancellation(t *testing.T) {
+ server, serverAddr, _ := setupTestSSHServerAndClient(t)
+ defer func() {
+ err := server.Stop()
+ require.NoError(t, err)
+ }()
+
+ t.Run("connection with short timeout", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
+ defer cancel()
+
+ currentUser := testutil.GetTestUsername(t)
+ _, err := Dial(ctx, serverAddr, currentUser, DialOptions{
+ InsecureSkipVerify: true,
+ })
+ if err != nil {
+ // Check for actual timeout-related errors rather than string matching
+ assert.True(t,
+ errors.Is(err, context.DeadlineExceeded) ||
+ errors.Is(err, context.Canceled) ||
+ strings.Contains(err.Error(), "timeout"),
+ "Expected timeout-related error, got: %v", err)
+ }
+ })
+
+ t.Run("command execution cancellation", func(t *testing.T) {
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+ currentUser := testutil.GetTestUsername(t)
+ client, err := Dial(ctx, serverAddr, currentUser, DialOptions{
+ InsecureSkipVerify: true,
+ })
+ require.NoError(t, err)
+ defer func() {
+ if err := client.Close(); err != nil {
+ t.Logf("client close error: %v", err)
+ }
+ }()
+
+ cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
+ defer cmdCancel()
+
+ err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10")
+ if err != nil {
+ var exitMissingErr *cryptossh.ExitMissingError
+ isValidCancellation := errors.Is(err, context.DeadlineExceeded) ||
+ errors.Is(err, context.Canceled) ||
+ errors.As(err, &exitMissingErr)
+ assert.True(t, isValidCancellation, "Should handle command cancellation properly")
+ }
+ })
+}
diff --git a/client/ssh/client/client_test.go b/client/ssh/client/client_test.go
index e38e02a86..191362940 100644
--- a/client/ssh/client/client_test.go
+++ b/client/ssh/client/client_test.go
@@ -15,7 +15,6 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
- cryptossh "golang.org/x/crypto/ssh"
"github.com/netbirdio/netbird/client/ssh"
sshserver "github.com/netbirdio/netbird/client/ssh/server"
@@ -78,53 +77,6 @@ func TestSSHClient_DialWithKey(t *testing.T) {
assert.NotNil(t, client.client)
}
-func TestSSHClient_CommandExecution(t *testing.T) {
- if runtime.GOOS == "windows" && testutil.IsCI() {
- t.Skip("Skipping Windows command execution tests in CI due to S4U authentication issues")
- }
-
- server, _, client := setupTestSSHServerAndClient(t)
- defer func() {
- err := server.Stop()
- require.NoError(t, err)
- }()
- defer func() {
- err := client.Close()
- assert.NoError(t, err)
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
- defer cancel()
-
- t.Run("ExecuteCommand captures output", func(t *testing.T) {
- output, err := client.ExecuteCommand(ctx, "echo hello")
- assert.NoError(t, err)
- assert.Contains(t, string(output), "hello")
- })
-
- t.Run("ExecuteCommandWithIO streams output", func(t *testing.T) {
- err := client.ExecuteCommandWithIO(ctx, "echo world")
- assert.NoError(t, err)
- })
-
- t.Run("commands with flags work", func(t *testing.T) {
- output, err := client.ExecuteCommand(ctx, "echo -n test_flag")
- assert.NoError(t, err)
- assert.Equal(t, "test_flag", strings.TrimSpace(string(output)))
- })
-
- t.Run("non-zero exit codes don't return errors", func(t *testing.T) {
- var testCmd string
- if runtime.GOOS == "windows" {
- testCmd = "echo hello | Select-String notfound"
- } else {
- testCmd = "echo 'hello' | grep 'notfound'"
- }
- _, err := client.ExecuteCommand(ctx, testCmd)
- assert.NoError(t, err)
- })
-}
-
func TestSSHClient_ConnectionHandling(t *testing.T) {
server, serverAddr, _ := setupTestSSHServerAndClient(t)
defer func() {
@@ -154,59 +106,6 @@ func TestSSHClient_ConnectionHandling(t *testing.T) {
}
}
-func TestSSHClient_ContextCancellation(t *testing.T) {
- server, serverAddr, _ := setupTestSSHServerAndClient(t)
- defer func() {
- err := server.Stop()
- require.NoError(t, err)
- }()
-
- t.Run("connection with short timeout", func(t *testing.T) {
- ctx, cancel := context.WithTimeout(context.Background(), 1*time.Millisecond)
- defer cancel()
-
- currentUser := testutil.GetTestUsername(t)
- _, err := Dial(ctx, serverAddr, currentUser, DialOptions{
- InsecureSkipVerify: true,
- })
- if err != nil {
- // Check for actual timeout-related errors rather than string matching
- assert.True(t,
- errors.Is(err, context.DeadlineExceeded) ||
- errors.Is(err, context.Canceled) ||
- strings.Contains(err.Error(), "timeout"),
- "Expected timeout-related error, got: %v", err)
- }
- })
-
- t.Run("command execution cancellation", func(t *testing.T) {
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
- currentUser := testutil.GetTestUsername(t)
- client, err := Dial(ctx, serverAddr, currentUser, DialOptions{
- InsecureSkipVerify: true,
- })
- require.NoError(t, err)
- defer func() {
- if err := client.Close(); err != nil {
- t.Logf("client close error: %v", err)
- }
- }()
-
- cmdCtx, cmdCancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
- defer cmdCancel()
-
- err = client.ExecuteCommandWithPTY(cmdCtx, "sleep 10")
- if err != nil {
- var exitMissingErr *cryptossh.ExitMissingError
- isValidCancellation := errors.Is(err, context.DeadlineExceeded) ||
- errors.Is(err, context.Canceled) ||
- errors.As(err, &exitMissingErr)
- assert.True(t, isValidCancellation, "Should handle command cancellation properly")
- }
- })
-}
-
func TestSSHClient_NoAuthMode(t *testing.T) {
hostKey, err := ssh.GeneratePrivateKey(ssh.ED25519)
require.NoError(t, err)
diff --git a/client/ssh/proxy/proxy_privileged_test.go b/client/ssh/proxy/proxy_privileged_test.go
new file mode 100644
index 000000000..94495a3ae
--- /dev/null
+++ b/client/ssh/proxy/proxy_privileged_test.go
@@ -0,0 +1,423 @@
+//go:build privileged
+
+package proxy
+
+import (
+ "bytes"
+ "context"
+ "crypto/rand"
+ "crypto/rsa"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "math/big"
+ "net"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "runtime"
+ "strconv"
+ "testing"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ cryptossh "golang.org/x/crypto/ssh"
+
+ nbssh "github.com/netbirdio/netbird/client/ssh"
+ sshauth "github.com/netbirdio/netbird/client/ssh/auth"
+ "github.com/netbirdio/netbird/client/ssh/server"
+ "github.com/netbirdio/netbird/client/ssh/testutil"
+ nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
+ sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
+)
+
+func (m *mockDaemon) setJWTToken(token string) {
+ m.impl.jwtToken = token
+}
+
+func TestSSHProxy_Connect(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ // TODO: Windows test times out - user switching and command execution tested on Linux
+ if runtime.GOOS == "windows" {
+ t.Skip("Skipping on Windows - covered by Linux tests")
+ }
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ audience = "test-audience"
+ )
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+ defer jwksServer.Close()
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+ hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
+ require.NoError(t, err)
+
+ serverConfig := &server.Config{
+ HostKeyPEM: hostKey,
+ JWT: &server.JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{audience},
+ KeysLocation: jwksURL,
+ },
+ }
+ sshServer := server.New(serverConfig)
+ sshServer.SetAllowRootLogin(true)
+
+ // Configure SSH authorization for the test user
+ testUsername := testutil.GetTestUsername(t)
+ testJWTUser := "test-username"
+ testUserHash, err := sshuserhash.HashUserID(testJWTUser)
+ require.NoError(t, err)
+
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ testUsername: {0}, // Index 0 in AuthorizedUsers
+ },
+ }
+ sshServer.UpdateSSHAuth(authConfig)
+
+ sshServerAddr := server.StartTestServer(t, sshServer)
+ defer func() { _ = sshServer.Stop() }()
+
+ mockDaemon := startMockDaemon(t)
+ defer mockDaemon.stop()
+
+ host, portStr, err := net.SplitHostPort(sshServerAddr)
+ require.NoError(t, err)
+ port, err := strconv.Atoi(portStr)
+ require.NoError(t, err)
+
+ mockDaemon.setHostKey(host, hostPubKey)
+
+ validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
+ mockDaemon.setJWTToken(validToken)
+
+ proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
+ require.NoError(t, err)
+
+ clientConn, proxyConn := net.Pipe()
+ defer func() { _ = clientConn.Close() }()
+
+ origStdin := os.Stdin
+ origStdout := os.Stdout
+ defer func() {
+ os.Stdin = origStdin
+ os.Stdout = origStdout
+ }()
+
+ stdinReader, stdinWriter, err := os.Pipe()
+ require.NoError(t, err)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ require.NoError(t, err)
+
+ os.Stdin = stdinReader
+ os.Stdout = stdoutWriter
+
+ go func() {
+ _, _ = io.Copy(stdinWriter, proxyConn)
+ }()
+ go func() {
+ _, _ = io.Copy(proxyConn, stdoutReader)
+ }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ connectErrCh := make(chan error, 1)
+ go func() {
+ connectErrCh <- proxyInstance.Connect(ctx)
+ }()
+
+ sshConfig := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{},
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 3 * time.Second,
+ }
+
+ sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
+ require.NoError(t, err, "Should connect to proxy server")
+ defer func() { _ = sshClientConn.Close() }()
+
+ sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
+
+ session, err := sshClient.NewSession()
+ require.NoError(t, err, "Should create session through full proxy to backend")
+
+ outputCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ output, err := session.Output("echo hello-from-proxy")
+ outputCh <- output
+ errCh <- err
+ }()
+
+ select {
+ case output := <-outputCh:
+ err := <-errCh
+ require.NoError(t, err, "Command should execute successfully through proxy")
+ assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
+ case <-time.After(3 * time.Second):
+ t.Fatal("Command execution timed out")
+ }
+
+ _ = session.Close()
+ _ = sshClient.Close()
+ _ = clientConn.Close()
+ cancel()
+}
+
+// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
+// when forwarding commands to the backend. This is critical for tools like
+// Ansible that send commands such as:
+//
+// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
+//
+// The single quotes must be preserved so the backend shell receives the
+// subshell expression as a single argument to -c.
+func TestSSHProxy_CommandQuoting(t *testing.T) {
+ if testing.Short() {
+ t.Skip("Skipping integration test in short mode")
+ }
+
+ sshClient, cleanup := setupProxySSHClient(t)
+ defer cleanup()
+
+ // These commands simulate what the SSH protocol delivers as exec payloads.
+ // When a user types: ssh host '/bin/sh -c "( echo hello )"'
+ // the local shell strips the outer single quotes, and the SSH exec request
+ // contains the raw string: /bin/sh -c "( echo hello )"
+ //
+ // The proxy must forward this string verbatim. Using session.Command()
+ // (shlex.Split + strings.Join) strips the inner double quotes, breaking
+ // the command on the backend.
+ tests := []struct {
+ name string
+ command string
+ expect string
+ }{
+ {
+ name: "subshell_in_double_quotes",
+ command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
+ expect: "from-subshell\nouter\n",
+ },
+ {
+ name: "printf_with_special_chars",
+ command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
+ expect: "hello world\n",
+ },
+ {
+ name: "nested_command_substitution",
+ command: `/bin/sh -c "echo $(echo nested)"`,
+ expect: "nested\n",
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ session, err := sshClient.NewSession()
+ require.NoError(t, err)
+ defer func() { _ = session.Close() }()
+
+ var stderrBuf bytes.Buffer
+ session.Stderr = &stderrBuf
+
+ outputCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ output, err := session.Output(tc.command)
+ outputCh <- output
+ errCh <- err
+ }()
+
+ select {
+ case output := <-outputCh:
+ err := <-errCh
+ if stderrBuf.Len() > 0 {
+ t.Logf("stderr: %s", stderrBuf.String())
+ }
+ require.NoError(t, err, "command should succeed: %s", tc.command)
+ assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
+ case <-time.After(5 * time.Second):
+ t.Fatalf("command timed out: %s", tc.command)
+ }
+ })
+ }
+}
+
+// setupProxySSHClient creates a full proxy test environment and returns
+// an SSH client connected through the proxy to a backend NetBird SSH server.
+func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
+ t.Helper()
+
+ const (
+ issuer = "https://test-issuer.example.com"
+ audience = "test-audience"
+ )
+
+ jwksServer, privateKey, jwksURL := setupJWKSServer(t)
+
+ hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
+ require.NoError(t, err)
+ hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
+ require.NoError(t, err)
+
+ serverConfig := &server.Config{
+ HostKeyPEM: hostKey,
+ JWT: &server.JWTConfig{
+ Issuer: issuer,
+ Audiences: []string{audience},
+ KeysLocation: jwksURL,
+ },
+ }
+ sshServer := server.New(serverConfig)
+ sshServer.SetAllowRootLogin(true)
+
+ testUsername := testutil.GetTestUsername(t)
+ testJWTUser := "test-username"
+ testUserHash, err := sshuserhash.HashUserID(testJWTUser)
+ require.NoError(t, err)
+
+ authConfig := &sshauth.Config{
+ UserIDClaim: sshauth.DefaultUserIDClaim,
+ AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
+ MachineUsers: map[string][]uint32{
+ testUsername: {0},
+ },
+ }
+ sshServer.UpdateSSHAuth(authConfig)
+
+ sshServerAddr := server.StartTestServer(t, sshServer)
+
+ mockDaemon := startMockDaemon(t)
+
+ host, portStr, err := net.SplitHostPort(sshServerAddr)
+ require.NoError(t, err)
+ port, err := strconv.Atoi(portStr)
+ require.NoError(t, err)
+
+ mockDaemon.setHostKey(host, hostPubKey)
+
+ validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
+ mockDaemon.setJWTToken(validToken)
+
+ proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
+ require.NoError(t, err)
+
+ origStdin := os.Stdin
+ origStdout := os.Stdout
+
+ stdinReader, stdinWriter, err := os.Pipe()
+ require.NoError(t, err)
+ stdoutReader, stdoutWriter, err := os.Pipe()
+ require.NoError(t, err)
+
+ os.Stdin = stdinReader
+ os.Stdout = stdoutWriter
+
+ clientConn, proxyConn := net.Pipe()
+
+ go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
+ go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+
+ go func() {
+ _ = proxyInstance.Connect(ctx)
+ }()
+
+ sshConfig := &cryptossh.ClientConfig{
+ User: testutil.GetTestUsername(t),
+ Auth: []cryptossh.AuthMethod{},
+ HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
+ Timeout: 5 * time.Second,
+ }
+
+ sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
+ require.NoError(t, err)
+
+ client := cryptossh.NewClient(sshClientConn, chans, reqs)
+
+ cleanupFn := func() {
+ _ = client.Close()
+ _ = clientConn.Close()
+ cancel()
+ os.Stdin = origStdin
+ os.Stdout = origStdout
+ _ = sshServer.Stop()
+ mockDaemon.stop()
+ jwksServer.Close()
+ }
+
+ return client, cleanupFn
+}
+
+func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
+ t.Helper()
+ privateKey, jwksJSON := generateTestJWKS(t)
+
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/json")
+ if _, err := w.Write(jwksJSON); err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ }
+ }))
+
+ return server, privateKey, server.URL
+}
+
+func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
+ t.Helper()
+ privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+ require.NoError(t, err)
+
+ publicKey := &privateKey.PublicKey
+ n := publicKey.N.Bytes()
+ e := publicKey.E
+
+ jwk := nbjwt.JSONWebKey{
+ Kty: "RSA",
+ Kid: "test-key-id",
+ Use: "sig",
+ N: base64.RawURLEncoding.EncodeToString(n),
+ E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
+ }
+
+ jwks := nbjwt.Jwks{
+ Keys: []nbjwt.JSONWebKey{jwk},
+ }
+
+ jwksJSON, err := json.Marshal(jwks)
+ require.NoError(t, err)
+
+ return privateKey, jwksJSON
+}
+
+func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
+ t.Helper()
+ claims := jwt.MapClaims{
+ "iss": issuer,
+ "aud": audience,
+ "sub": user,
+ "exp": time.Now().Add(time.Hour).Unix(),
+ "iat": time.Now().Unix(),
+ }
+
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
+ token.Header["kid"] = "test-key-id"
+
+ tokenString, err := token.SignedString(privateKey)
+ require.NoError(t, err)
+
+ return tokenString
+}
diff --git a/client/ssh/proxy/proxy_test.go b/client/ssh/proxy/proxy_test.go
index b33d5f8f4..2795c786b 100644
--- a/client/ssh/proxy/proxy_test.go
+++ b/client/ssh/proxy/proxy_test.go
@@ -1,25 +1,12 @@
package proxy
import (
- "bytes"
"context"
- "crypto/rand"
- "crypto/rsa"
- "encoding/base64"
- "encoding/json"
"fmt"
- "io"
- "math/big"
"net"
- "net/http"
- "net/http/httptest"
"os"
- "runtime"
- "strconv"
"testing"
- "time"
- "github.com/golang-jwt/jwt/v5"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
cryptossh "golang.org/x/crypto/ssh"
@@ -28,11 +15,7 @@ import (
"github.com/netbirdio/netbird/client/proto"
nbssh "github.com/netbirdio/netbird/client/ssh"
- sshauth "github.com/netbirdio/netbird/client/ssh/auth"
- "github.com/netbirdio/netbird/client/ssh/server"
"github.com/netbirdio/netbird/client/ssh/testutil"
- nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
- sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
)
func TestMain(m *testing.M) {
@@ -106,331 +89,6 @@ func TestSSHProxy_verifyHostKey(t *testing.T) {
})
}
-func TestSSHProxy_Connect(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping integration test in short mode")
- }
-
- // TODO: Windows test times out - user switching and command execution tested on Linux
- if runtime.GOOS == "windows" {
- t.Skip("Skipping on Windows - covered by Linux tests")
- }
-
- const (
- issuer = "https://test-issuer.example.com"
- audience = "test-audience"
- )
-
- jwksServer, privateKey, jwksURL := setupJWKSServer(t)
- defer jwksServer.Close()
-
- hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- require.NoError(t, err)
- hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
- require.NoError(t, err)
-
- serverConfig := &server.Config{
- HostKeyPEM: hostKey,
- JWT: &server.JWTConfig{
- Issuer: issuer,
- Audiences: []string{audience},
- KeysLocation: jwksURL,
- },
- }
- sshServer := server.New(serverConfig)
- sshServer.SetAllowRootLogin(true)
-
- // Configure SSH authorization for the test user
- testUsername := testutil.GetTestUsername(t)
- testJWTUser := "test-username"
- testUserHash, err := sshuserhash.HashUserID(testJWTUser)
- require.NoError(t, err)
-
- authConfig := &sshauth.Config{
- UserIDClaim: sshauth.DefaultUserIDClaim,
- AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
- MachineUsers: map[string][]uint32{
- testUsername: {0}, // Index 0 in AuthorizedUsers
- },
- }
- sshServer.UpdateSSHAuth(authConfig)
-
- sshServerAddr := server.StartTestServer(t, sshServer)
- defer func() { _ = sshServer.Stop() }()
-
- mockDaemon := startMockDaemon(t)
- defer mockDaemon.stop()
-
- host, portStr, err := net.SplitHostPort(sshServerAddr)
- require.NoError(t, err)
- port, err := strconv.Atoi(portStr)
- require.NoError(t, err)
-
- mockDaemon.setHostKey(host, hostPubKey)
-
- validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
- mockDaemon.setJWTToken(validToken)
-
- proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
- require.NoError(t, err)
-
- clientConn, proxyConn := net.Pipe()
- defer func() { _ = clientConn.Close() }()
-
- origStdin := os.Stdin
- origStdout := os.Stdout
- defer func() {
- os.Stdin = origStdin
- os.Stdout = origStdout
- }()
-
- stdinReader, stdinWriter, err := os.Pipe()
- require.NoError(t, err)
- stdoutReader, stdoutWriter, err := os.Pipe()
- require.NoError(t, err)
-
- os.Stdin = stdinReader
- os.Stdout = stdoutWriter
-
- go func() {
- _, _ = io.Copy(stdinWriter, proxyConn)
- }()
- go func() {
- _, _ = io.Copy(proxyConn, stdoutReader)
- }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- connectErrCh := make(chan error, 1)
- go func() {
- connectErrCh <- proxyInstance.Connect(ctx)
- }()
-
- sshConfig := &cryptossh.ClientConfig{
- User: testutil.GetTestUsername(t),
- Auth: []cryptossh.AuthMethod{},
- HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
- Timeout: 3 * time.Second,
- }
-
- sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
- require.NoError(t, err, "Should connect to proxy server")
- defer func() { _ = sshClientConn.Close() }()
-
- sshClient := cryptossh.NewClient(sshClientConn, chans, reqs)
-
- session, err := sshClient.NewSession()
- require.NoError(t, err, "Should create session through full proxy to backend")
-
- outputCh := make(chan []byte, 1)
- errCh := make(chan error, 1)
- go func() {
- output, err := session.Output("echo hello-from-proxy")
- outputCh <- output
- errCh <- err
- }()
-
- select {
- case output := <-outputCh:
- err := <-errCh
- require.NoError(t, err, "Command should execute successfully through proxy")
- assert.Contains(t, string(output), "hello-from-proxy", "Should receive command output through proxy")
- case <-time.After(3 * time.Second):
- t.Fatal("Command execution timed out")
- }
-
- _ = session.Close()
- _ = sshClient.Close()
- _ = clientConn.Close()
- cancel()
-}
-
-// TestSSHProxy_CommandQuoting verifies that the proxy preserves shell quoting
-// when forwarding commands to the backend. This is critical for tools like
-// Ansible that send commands such as:
-//
-// /bin/sh -c '( umask 77 && mkdir -p ... ) && sleep 0'
-//
-// The single quotes must be preserved so the backend shell receives the
-// subshell expression as a single argument to -c.
-func TestSSHProxy_CommandQuoting(t *testing.T) {
- if testing.Short() {
- t.Skip("Skipping integration test in short mode")
- }
-
- sshClient, cleanup := setupProxySSHClient(t)
- defer cleanup()
-
- // These commands simulate what the SSH protocol delivers as exec payloads.
- // When a user types: ssh host '/bin/sh -c "( echo hello )"'
- // the local shell strips the outer single quotes, and the SSH exec request
- // contains the raw string: /bin/sh -c "( echo hello )"
- //
- // The proxy must forward this string verbatim. Using session.Command()
- // (shlex.Split + strings.Join) strips the inner double quotes, breaking
- // the command on the backend.
- tests := []struct {
- name string
- command string
- expect string
- }{
- {
- name: "subshell_in_double_quotes",
- command: `/bin/sh -c "( echo from-subshell ) && echo outer"`,
- expect: "from-subshell\nouter\n",
- },
- {
- name: "printf_with_special_chars",
- command: `/bin/sh -c "printf '%s\n' 'hello world'"`,
- expect: "hello world\n",
- },
- {
- name: "nested_command_substitution",
- command: `/bin/sh -c "echo $(echo nested)"`,
- expect: "nested\n",
- },
- }
-
- for _, tc := range tests {
- t.Run(tc.name, func(t *testing.T) {
- session, err := sshClient.NewSession()
- require.NoError(t, err)
- defer func() { _ = session.Close() }()
-
- var stderrBuf bytes.Buffer
- session.Stderr = &stderrBuf
-
- outputCh := make(chan []byte, 1)
- errCh := make(chan error, 1)
- go func() {
- output, err := session.Output(tc.command)
- outputCh <- output
- errCh <- err
- }()
-
- select {
- case output := <-outputCh:
- err := <-errCh
- if stderrBuf.Len() > 0 {
- t.Logf("stderr: %s", stderrBuf.String())
- }
- require.NoError(t, err, "command should succeed: %s", tc.command)
- assert.Equal(t, tc.expect, string(output), "output mismatch for: %s", tc.command)
- case <-time.After(5 * time.Second):
- t.Fatalf("command timed out: %s", tc.command)
- }
- })
- }
-}
-
-// setupProxySSHClient creates a full proxy test environment and returns
-// an SSH client connected through the proxy to a backend NetBird SSH server.
-func setupProxySSHClient(t *testing.T) (*cryptossh.Client, func()) {
- t.Helper()
-
- const (
- issuer = "https://test-issuer.example.com"
- audience = "test-audience"
- )
-
- jwksServer, privateKey, jwksURL := setupJWKSServer(t)
-
- hostKey, err := nbssh.GeneratePrivateKey(nbssh.ED25519)
- require.NoError(t, err)
- hostPubKey, err := nbssh.GeneratePublicKey(hostKey)
- require.NoError(t, err)
-
- serverConfig := &server.Config{
- HostKeyPEM: hostKey,
- JWT: &server.JWTConfig{
- Issuer: issuer,
- Audiences: []string{audience},
- KeysLocation: jwksURL,
- },
- }
- sshServer := server.New(serverConfig)
- sshServer.SetAllowRootLogin(true)
-
- testUsername := testutil.GetTestUsername(t)
- testJWTUser := "test-username"
- testUserHash, err := sshuserhash.HashUserID(testJWTUser)
- require.NoError(t, err)
-
- authConfig := &sshauth.Config{
- UserIDClaim: sshauth.DefaultUserIDClaim,
- AuthorizedUsers: []sshuserhash.UserIDHash{testUserHash},
- MachineUsers: map[string][]uint32{
- testUsername: {0},
- },
- }
- sshServer.UpdateSSHAuth(authConfig)
-
- sshServerAddr := server.StartTestServer(t, sshServer)
-
- mockDaemon := startMockDaemon(t)
-
- host, portStr, err := net.SplitHostPort(sshServerAddr)
- require.NoError(t, err)
- port, err := strconv.Atoi(portStr)
- require.NoError(t, err)
-
- mockDaemon.setHostKey(host, hostPubKey)
-
- validToken := generateValidJWT(t, privateKey, issuer, audience, testJWTUser)
- mockDaemon.setJWTToken(validToken)
-
- proxyInstance, err := New(mockDaemon.addr, host, port, io.Discard, nil)
- require.NoError(t, err)
-
- origStdin := os.Stdin
- origStdout := os.Stdout
-
- stdinReader, stdinWriter, err := os.Pipe()
- require.NoError(t, err)
- stdoutReader, stdoutWriter, err := os.Pipe()
- require.NoError(t, err)
-
- os.Stdin = stdinReader
- os.Stdout = stdoutWriter
-
- clientConn, proxyConn := net.Pipe()
-
- go func() { _, _ = io.Copy(stdinWriter, proxyConn) }()
- go func() { _, _ = io.Copy(proxyConn, stdoutReader) }()
-
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
-
- go func() {
- _ = proxyInstance.Connect(ctx)
- }()
-
- sshConfig := &cryptossh.ClientConfig{
- User: testutil.GetTestUsername(t),
- Auth: []cryptossh.AuthMethod{},
- HostKeyCallback: cryptossh.InsecureIgnoreHostKey(),
- Timeout: 5 * time.Second,
- }
-
- sshClientConn, chans, reqs, err := cryptossh.NewClientConn(clientConn, "test", sshConfig)
- require.NoError(t, err)
-
- client := cryptossh.NewClient(sshClientConn, chans, reqs)
-
- cleanupFn := func() {
- _ = client.Close()
- _ = clientConn.Close()
- cancel()
- os.Stdin = origStdin
- os.Stdout = origStdout
- _ = sshServer.Stop()
- mockDaemon.stop()
- jwksServer.Close()
- }
-
- return client, cleanupFn
-}
-
type mockDaemonServer struct {
proto.UnimplementedDaemonServiceServer
hostKeys map[string][]byte
@@ -492,10 +150,6 @@ func (m *mockDaemon) setHostKey(addr string, pubKey []byte) {
m.impl.hostKeys[addr] = pubKey
}
-func (m *mockDaemon) setJWTToken(token string) {
- m.impl.jwtToken = token
-}
-
func (m *mockDaemon) stop() {
if m.server != nil {
m.server.Stop()
@@ -508,63 +162,3 @@ func mustParsePublicKey(t *testing.T, pubKeyBytes []byte) cryptossh.PublicKey {
require.NoError(t, err)
return pubKey
}
-
-func setupJWKSServer(t *testing.T) (*httptest.Server, *rsa.PrivateKey, string) {
- t.Helper()
- privateKey, jwksJSON := generateTestJWKS(t)
-
- server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- w.Header().Set("Content-Type", "application/json")
- if _, err := w.Write(jwksJSON); err != nil {
- http.Error(w, err.Error(), http.StatusInternalServerError)
- }
- }))
-
- return server, privateKey, server.URL
-}
-
-func generateTestJWKS(t *testing.T) (*rsa.PrivateKey, []byte) {
- t.Helper()
- privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
- require.NoError(t, err)
-
- publicKey := &privateKey.PublicKey
- n := publicKey.N.Bytes()
- e := publicKey.E
-
- jwk := nbjwt.JSONWebKey{
- Kty: "RSA",
- Kid: "test-key-id",
- Use: "sig",
- N: base64.RawURLEncoding.EncodeToString(n),
- E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(e)).Bytes()),
- }
-
- jwks := nbjwt.Jwks{
- Keys: []nbjwt.JSONWebKey{jwk},
- }
-
- jwksJSON, err := json.Marshal(jwks)
- require.NoError(t, err)
-
- return privateKey, jwksJSON
-}
-
-func generateValidJWT(t *testing.T, privateKey *rsa.PrivateKey, issuer, audience string, user string) string {
- t.Helper()
- claims := jwt.MapClaims{
- "iss": issuer,
- "aud": audience,
- "sub": user,
- "exp": time.Now().Add(time.Hour).Unix(),
- "iat": time.Now().Unix(),
- }
-
- token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
- token.Header["kid"] = "test-key-id"
-
- tokenString, err := token.SignedString(privateKey)
- require.NoError(t, err)
-
- return tokenString
-}
diff --git a/client/ssh/server/executor_unix_privileged_test.go b/client/ssh/server/executor_unix_privileged_test.go
new file mode 100644
index 000000000..f1b0805d9
--- /dev/null
+++ b/client/ssh/server/executor_unix_privileged_test.go
@@ -0,0 +1,66 @@
+//go:build unix && privileged
+
+package server
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
+ pd := NewPrivilegeDropper()
+
+ config := ExecutorConfig{
+ UID: 1000,
+ GID: 1000,
+ Groups: []uint32{1000, 1001},
+ WorkingDir: "/home/testuser",
+ Shell: "/bin/bash",
+ Command: "ls -la",
+ }
+
+ cmd, err := pd.CreateExecutorCommand(context.Background(), config)
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+
+ // Verify the command is calling netbird ssh exec
+ assert.Contains(t, cmd.Args, "ssh")
+ assert.Contains(t, cmd.Args, "exec")
+ assert.Contains(t, cmd.Args, "--uid")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "--gid")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "--groups")
+ assert.Contains(t, cmd.Args, "1000")
+ assert.Contains(t, cmd.Args, "1001")
+ assert.Contains(t, cmd.Args, "--working-dir")
+ assert.Contains(t, cmd.Args, "/home/testuser")
+ assert.Contains(t, cmd.Args, "--shell")
+ assert.Contains(t, cmd.Args, "/bin/bash")
+ assert.Contains(t, cmd.Args, "--cmd")
+ assert.Contains(t, cmd.Args, "ls -la")
+}
+
+func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
+ pd := NewPrivilegeDropper()
+
+ config := ExecutorConfig{
+ UID: 1000,
+ GID: 1000,
+ Groups: []uint32{1000},
+ WorkingDir: "/home/testuser",
+ Shell: "/bin/bash",
+ Command: "",
+ }
+
+ cmd, err := pd.CreateExecutorCommand(context.Background(), config)
+ require.NoError(t, err)
+ require.NotNil(t, cmd)
+
+ // Verify no command mode (command is empty so no --cmd flag)
+ assert.NotContains(t, cmd.Args, "--cmd")
+ assert.NotContains(t, cmd.Args, "--interactive")
+}
diff --git a/client/ssh/server/executor_unix_test.go b/client/ssh/server/executor_unix_test.go
index 0c5108f57..171e78b83 100644
--- a/client/ssh/server/executor_unix_test.go
+++ b/client/ssh/server/executor_unix_test.go
@@ -73,61 +73,6 @@ func TestPrivilegeDropper_ValidatePrivileges(t *testing.T) {
}
}
-func TestPrivilegeDropper_CreateExecutorCommand(t *testing.T) {
- pd := NewPrivilegeDropper()
-
- config := ExecutorConfig{
- UID: 1000,
- GID: 1000,
- Groups: []uint32{1000, 1001},
- WorkingDir: "/home/testuser",
- Shell: "/bin/bash",
- Command: "ls -la",
- }
-
- cmd, err := pd.CreateExecutorCommand(context.Background(), config)
- require.NoError(t, err)
- require.NotNil(t, cmd)
-
- // Verify the command is calling netbird ssh exec
- assert.Contains(t, cmd.Args, "ssh")
- assert.Contains(t, cmd.Args, "exec")
- assert.Contains(t, cmd.Args, "--uid")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "--gid")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "--groups")
- assert.Contains(t, cmd.Args, "1000")
- assert.Contains(t, cmd.Args, "1001")
- assert.Contains(t, cmd.Args, "--working-dir")
- assert.Contains(t, cmd.Args, "/home/testuser")
- assert.Contains(t, cmd.Args, "--shell")
- assert.Contains(t, cmd.Args, "/bin/bash")
- assert.Contains(t, cmd.Args, "--cmd")
- assert.Contains(t, cmd.Args, "ls -la")
-}
-
-func TestPrivilegeDropper_CreateExecutorCommandInteractive(t *testing.T) {
- pd := NewPrivilegeDropper()
-
- config := ExecutorConfig{
- UID: 1000,
- GID: 1000,
- Groups: []uint32{1000},
- WorkingDir: "/home/testuser",
- Shell: "/bin/bash",
- Command: "",
- }
-
- cmd, err := pd.CreateExecutorCommand(context.Background(), config)
- require.NoError(t, err)
- require.NotNil(t, cmd)
-
- // Verify no command mode (command is empty so no --cmd flag)
- assert.NotContains(t, cmd.Args, "--cmd")
- assert.NotContains(t, cmd.Args, "--interactive")
-}
-
// TestPrivilegeDropper_ActualPrivilegeDrop tests actual privilege dropping
// This test requires root privileges and will be skipped if not running as root
func TestPrivilegeDropper_ActualPrivilegeDrop(t *testing.T) {
diff --git a/client/testutil/privileged/runner_test.go b/client/testutil/privileged/runner_test.go
new file mode 100644
index 000000000..d1945894d
--- /dev/null
+++ b/client/testutil/privileged/runner_test.go
@@ -0,0 +1,196 @@
+//go:build privileged && (linux || darwin)
+
+// Package privileged provides a self-hosting harness that runs the repo's
+// privileged-tagged test suite inside a --privileged --cap-add=NET_ADMIN
+// container, so developers can exercise the root/system-mutating tests on a
+// non-root host with a single `go test` invocation.
+package privileged
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/moby/moby/api/types/container"
+ "github.com/ory/dockertest/v4"
+)
+
+// containerImage / containerTag match the image used by the CI privileged job
+// (.github/workflows/golang-test-linux.yml, test_client_on_docker).
+const (
+ containerImage = "golang"
+ containerTag = "1.25-alpine"
+)
+
+const (
+ containerWorkdir = "/app"
+ containerGoCache = "/root/.cache/go-build"
+ containerGoModCache = "/go/pkg/mod"
+)
+
+// alpinePackages are the build/runtime deps the privileged tests need, mirroring
+// the CI container setup.
+const alpinePackages = "ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base"
+
+// privilegedTestPackages is the package list the suite runs, excluding the
+// server-side trees and UI/upload helpers, matching the CI Docker job's filter.
+const privilegedTestPackages = `go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server`
+
+// testWriter forwards container output to the test log line by line.
+type testWriter struct{ t *testing.T }
+
+func (w testWriter) Write(p []byte) (int, error) {
+ for _, line := range strings.Split(strings.TrimRight(string(p), "\n"), "\n") {
+ w.t.Log(line)
+ }
+ return len(p), nil
+}
+
+// TestRunPrivilegedSuiteInDocker spins up a privileged container, mounts the repo,
+// and runs `go test -tags 'devcert privileged'` inside it. When already running
+// inside that container (DOCKER_CI=true) it returns immediately so the real
+// privileged tests in the suite execute in place instead of recursing.
+func TestRunPrivilegedSuiteInDocker(t *testing.T) {
+ if os.Getenv("DOCKER_CI") == "true" {
+ t.Skip("inside privileged container, skipping container spawn; privileged tests run in place")
+ }
+
+ repoRoot, err := findRepoRoot()
+ if err != nil {
+ t.Fatalf("locate repo root: %v", err)
+ }
+ goCache, goModCache := hostGoCaches(t)
+
+ // dockertest reads DOCKER_HOST; point it at the active context's socket when
+ // the default one is absent (macOS Docker Desktop, Colima, OrbStack).
+ if host := dockerHost(); host != "" {
+ t.Setenv("DOCKER_HOST", host)
+ }
+
+ // NewPoolT registers container cleanup via t.Cleanup automatically.
+ pool := dockertest.NewPoolT(t, "", dockertest.WithMaxWait(30*time.Minute))
+
+ // Keep the container alive so the suite runs via Exec, which yields a clean
+ // exit code (the v4 Resource API exposes no container wait/exit-code).
+ resource := pool.RunT(t, containerImage,
+ dockertest.WithTag(containerTag),
+ dockertest.WithWorkingDir(containerWorkdir),
+ dockertest.WithMounts([]string{
+ repoRoot + ":" + containerWorkdir,
+ goCache + ":" + containerGoCache,
+ goModCache + ":" + containerGoModCache,
+ }),
+ dockertest.WithEnv([]string{
+ "CGO_ENABLED=1",
+ "CI=true",
+ "DOCKER_CI=true",
+ "CONTAINER=true",
+ "GOCACHE=" + containerGoCache,
+ "GOMODCACHE=" + containerGoModCache,
+ }),
+ dockertest.WithCmd([]string{"sleep", "infinity"}),
+ dockertest.WithHostConfig(func(hc *container.HostConfig) {
+ hc.Privileged = true
+ hc.CapAdd = []string{"NET_ADMIN"}
+ }),
+ dockertest.WithoutReuse(),
+ )
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
+ defer cancel()
+
+ result, err := resource.Exec(ctx, []string{"sh", "-c", buildTestScript()})
+ if err != nil {
+ t.Fatalf("run privileged suite in container: %v", err)
+ }
+
+ w := testWriter{t}
+ _, _ = w.Write([]byte(result.StdOut))
+ _, _ = w.Write([]byte(result.StdErr))
+
+ if result.ExitCode != 0 {
+ t.Fatalf("privileged test suite failed in container (exit code %d)", result.ExitCode)
+ }
+}
+
+// findRepoRoot walks up from the test's working directory to the module root.
+func findRepoRoot() (string, error) {
+ dir, err := os.Getwd()
+ if err != nil {
+ return "", err
+ }
+ for {
+ if _, statErr := os.Stat(filepath.Join(dir, "go.mod")); statErr == nil {
+ return dir, nil
+ }
+ parent := filepath.Dir(dir)
+ if parent == dir {
+ return "", fmt.Errorf("go.mod not found above %s", dir)
+ }
+ dir = parent
+ }
+}
+
+// dockerHost returns a DOCKER_HOST override when the default socket is missing.
+// An empty result means the caller should leave DOCKER_HOST untouched (it is
+// already set, or the default unix socket exists). When neither is present
+// (common on macOS Docker Desktop, Colima and OrbStack, which use a per-user
+// socket), it resolves the active docker context's endpoint.
+func dockerHost() string {
+ if os.Getenv("DOCKER_HOST") != "" {
+ return ""
+ }
+ if _, err := os.Stat("/var/run/docker.sock"); err == nil {
+ return ""
+ }
+
+ out, err := exec.Command("docker", "context", "inspect", "-f", "{{.Endpoints.docker.Host}}").Output()
+ if err != nil {
+ return ""
+ }
+ return strings.TrimSpace(string(out))
+}
+
+// hostGoCaches resolves the host GOCACHE/GOMODCACHE so the container reuses the
+// existing build/module cache for speed.
+func hostGoCaches(t *testing.T) (string, string) {
+ t.Helper()
+ return goEnv(t, "GOCACHE"), goEnv(t, "GOMODCACHE")
+}
+
+func goEnv(t *testing.T, key string) string {
+ t.Helper()
+ var out bytes.Buffer
+ cmd := exec.Command("go", "env", key)
+ cmd.Stdout = &out
+ if err := cmd.Run(); err != nil {
+ t.Fatalf("go env %s: %v", key, err)
+ }
+ return strings.TrimSpace(out.String())
+}
+
+// buildTestScript builds the in-container command. PRIV_PKGS overrides the package
+// list (default: the full filtered set); PRIV_RUN adds a -run test-name filter.
+// Both empty reproduces the full privileged suite.
+func buildTestScript() string {
+ pkgs := privilegedTestPackages + " | xargs"
+ if p := os.Getenv("PRIV_PKGS"); p != "" {
+ pkgs = "echo " + p + " | xargs"
+ }
+
+ runFilter := ""
+ if r := os.Getenv("PRIV_RUN"); r != "" {
+ runFilter = "-run '" + r + "' "
+ }
+
+ return fmt.Sprintf(
+ "apk update >/dev/null && apk add --no-cache %s >/dev/null && %s go test -buildvcs=false -tags 'devcert privileged' %s-v -timeout 20m -p 1",
+ alpinePackages, pkgs, runFilter,
+ )
+}
diff --git a/docs/testing-privileged.md b/docs/testing-privileged.md
new file mode 100644
index 000000000..cf2f23171
--- /dev/null
+++ b/docs/testing-privileged.md
@@ -0,0 +1,78 @@
+# Privileged tests
+
+Some tests in this repo need `root` or mutate host network state: they create
+TUN/WireGuard interfaces, open netlink/raw sockets, run eBPF programs, or shell
+out to `ip`/`iptables`/`nft`/`ifconfig`/`route`. Running them on a developer
+machine would require `sudo` and could leave stray interfaces or routes behind.
+
+These tests are gated behind the **`privileged` build tag** so the default test
+run is host-safe.
+
+## Running tests
+
+```bash
+# Host-safe: excludes privileged tests. Runs as a normal user, no sudo.
+make test-unit
+# equivalently:
+go test -tags devcert ./...
+
+# Privileged suite: runs the privileged-tagged tests inside a
+# --privileged --cap-add=NET_ADMIN container (requires Docker).
+make test-privileged
+
+# Narrow the container run to a single test / package:
+PRIV_RUN=TestNftablesManager PRIV_PKGS=./client/firewall/nftables/... make test-privileged
+```
+
+`PRIV_RUN` adds a `-run` test-name filter and `PRIV_PKGS` overrides the package
+list; both are optional and default to the full privileged suite.
+
+`make test-privileged` invokes the `ory/dockertest` harness in
+`client/testutil/privileged/`. The harness:
+
+1. Skips immediately when it detects it is already inside the container
+ (`DOCKER_CI=true`), so the privileged tests run in place instead of recursing.
+2. Otherwise spins up a `golang:1.25-alpine` container (matching CI),
+ bind-mounts the repo and the host Go build/module caches, installs the
+ required packages, and runs `go test -tags 'devcert privileged'` over the
+ client packages.
+3. Streams the container's output to the test log and fails if the suite fails.
+
+## Adding a privileged test
+
+A test is privileged if it does any of:
+
+- creates a real interface via `iface.NewWGIFace(...).Create()`,
+- opens a netlink or raw socket that hard-fails without `CAP_NET_ADMIN`,
+- runs an eBPF program (`ebpf.*.Listen()`),
+- shells out to `ip`, `iptables`, `nft`, `ifconfig`, or `route` to change state.
+
+Add the tag to the **top** of the file, combined with any existing platform
+constraint:
+
+```go
+//go:build privileged && linux
+
+package foo
+```
+
+If a file mixes privileged and pure-logic tests, **split it**: keep the pure
+tests (and any shared data — type/var declarations, table-driven `testCases`,
+helper interfaces) in an untagged file, and move the privileged tests into a
+`*_privileged_test.go` file with the tag. Shared declarations must stay untagged,
+otherwise the unprivileged files in the package will not compile.
+
+Always verify both build modes compile on every target platform:
+
+```bash
+go vet -tags devcert ./...
+go vet -tags 'devcert privileged' ./...
+```
+
+## CI
+
+- The `Client / Unit` job runs `go test -tags devcert` with **no** `sudo` — only
+ host-safe tests.
+- The `Client (Docker) / Unit` job runs `go test -tags 'devcert privileged'`
+ inside a `--privileged --cap-add=NET_ADMIN` container, which is where the
+ privileged tests actually execute.
diff --git a/go.mod b/go.mod
index fa5b431bf..9a57de1c9 100644
--- a/go.mod
+++ b/go.mod
@@ -78,10 +78,12 @@ require (
github.com/mdp/qrterminal/v3 v3.2.1
github.com/miekg/dns v1.1.72
github.com/mitchellh/hashstructure/v2 v2.0.2
+ github.com/moby/moby/api v1.54.1
github.com/netbirdio/management-integrations/integrations v0.0.0-20260416123949-2355d972be42
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45
github.com/oapi-codegen/runtime v1.1.2
github.com/okta/okta-sdk-golang/v2 v2.18.0
+ github.com/ory/dockertest/v4 v4.0.0
github.com/oschwald/maxminddb-golang v1.12.0
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/petermattis/goid v0.0.0-20250303134427-723919f7f203
@@ -145,7 +147,7 @@ require (
dario.cat/mergo v1.0.1 // indirect
filippo.io/edwards25519 v1.1.1 // indirect
github.com/AppsFlyer/go-sundheit v0.6.0 // indirect
- github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
+ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
github.com/Azure/go-ntlmssp v0.1.0 // indirect
github.com/BurntSushi/toml v1.5.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
@@ -177,6 +179,8 @@ require (
github.com/caddyserver/zerossl v0.1.3 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/containerd/errdefs v1.0.0 // indirect
+ github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
@@ -271,11 +275,12 @@ require (
github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/mitchellh/reflectwalk v1.0.2 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
+ github.com/moby/moby/client v0.4.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.5.0 // indirect
github.com/moby/sys/user v0.3.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
- github.com/moby/term v0.5.0 // indirect
+ github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
diff --git a/go.sum b/go.sum
index 3e1d7c97a..7b29b7604 100644
--- a/go.sum
+++ b/go.sum
@@ -23,8 +23,8 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/AppsFlyer/go-sundheit v0.6.0 h1:d2hBvCjBSb2lUsEWGfPigr4MCOt04sxB+Rppl0yUMSk=
github.com/AppsFlyer/go-sundheit v0.6.0/go.mod h1:LDdBHD6tQBtmHsdW+i1GwdTt6Wqc0qazf5ZEJVTbTME=
-github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
-github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
+github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Azure/go-ntlmssp v0.1.0 h1:DjFo6YtWzNqNvQdrwEyr/e4nhU3vRiwenz5QX7sFz+A=
github.com/Azure/go-ntlmssp v0.1.0/go.mod h1:NYqdhxd/8aAct/s4qSYZEerdPuH1liG2/X9DiVTbhpk=
github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg=
@@ -117,6 +117,10 @@ github.com/cilium/ebpf v0.19.0 h1:Ro/rE64RmFBeA9FGjcTc+KmCeY6jXmryu6FfnzPRIao=
github.com/cilium/ebpf v0.19.0/go.mod h1:fLCgMo3l8tZmAdM3B2XqdFzXBpwkcSTroaVqN08OWVY=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
+github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
+github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
+github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
+github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
@@ -480,6 +484,10 @@ github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zx
github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
+github.com/moby/moby/api v1.54.1 h1:TqVzuJkOLsgLDDwNLmYqACUuTehOHRGKiPhvH8V3Nn4=
+github.com/moby/moby/api v1.54.1/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs=
+github.com/moby/moby/client v0.4.0 h1:S+2XegzHQrrvTCvF6s5HFzcrywWQmuVnhOXe2kiWjIw=
+github.com/moby/moby/client v0.4.0/go.mod h1:QWPbvWchQbxBNdaLSpoKpCdf5E+WxFAgNHogCWDoa7g=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
@@ -488,8 +496,8 @@ github.com/moby/sys/user v0.3.0 h1:9ni5DlcW5an3SvRSx4MouotOygvzaXbaSrc/wGDFWPo=
github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
-github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
-github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
+github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
+github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
@@ -542,6 +550,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
+github.com/ory/dockertest/v4 v4.0.0 h1:i19aFsO/VXE0VrMk4ifnKW4G/KIJ93PCjLOslxXoPME=
+github.com/ory/dockertest/v4 v4.0.0/go.mod h1:b5Ofu8VIxWNhXFvQcLu17pRNQdoUBKtXBW74G4Ygzx8=
github.com/oschwald/maxminddb-golang v1.12.0 h1:9FnTOD0YOhP7DGxGsq4glzpGy5+w7pq50AS6wALUMYs=
github.com/oschwald/maxminddb-golang v1.12.0/go.mod h1:q0Nob5lTCqyQ8WT6FYgS1L7PXKVVbgiymefNwIjPzgY=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
@@ -973,11 +983,13 @@ gorm.io/driver/sqlite v1.5.7/go.mod h1:U+J8craQU6Fzkcvu8oLeAQmi50TkwPEhHDEjQZXDa
gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
gorm.io/gorm v1.25.12 h1:I0u8i2hWQItBq1WfE0o2+WuL9+8L21K9e2HHSTE/0f8=
gorm.io/gorm v1.25.12/go.mod h1:xh7N7RHfYlNc5EmcI/El95gXusucDrQnHXe0+CgWcLQ=
-gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
-gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
+gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
+gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA=
gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
+pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
+pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=
diff --git a/sharedsock/sock_linux_test.go b/sharedsock/sock_linux_test.go
index a22af461a..0ed15e282 100644
--- a/sharedsock/sock_linux_test.go
+++ b/sharedsock/sock_linux_test.go
@@ -1,3 +1,5 @@
+//go:build privileged
+
package sharedsock
import (
From 4400372f37ac9cf1ecb70cd3d31f7aa0bc445ad2 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Mon, 29 Jun 2026 01:50:17 +0900
Subject: [PATCH 10/20] [client] Forward non-address DNS record types through
route forwarders (#6455)
---
client/internal/dns/resutil/resolve.go | 230 ++++++++++++++-
client/internal/dns/resutil/resolve_test.go | 159 +++++++++++
client/internal/dnsfwd/forwarder.go | 70 ++++-
client/internal/dnsfwd/forwarder_test.go | 267 ++++++++++++++++--
.../routemanager/dnsinterceptor/handler.go | 24 +-
5 files changed, 703 insertions(+), 47 deletions(-)
diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go
index a2599aee7..931938755 100644
--- a/client/internal/dns/resutil/resolve.go
+++ b/client/internal/dns/resutil/resolve.go
@@ -8,6 +8,7 @@ import (
"errors"
"net"
"net/netip"
+ "slices"
"strings"
"github.com/miekg/dns"
@@ -167,7 +168,10 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina
case dns.TypeA:
alternativeNetwork = "ip6"
default:
- return dns.RcodeNameError
+ // Non-address types reach LookupIP only unexpectedly; without an
+ // address pair to probe we cannot prove the name is absent, so answer
+ // NODATA rather than a poisoning NXDOMAIN.
+ return dns.RcodeSuccess
}
if _, err := r.LookupNetIP(ctx, alternativeNetwork, domain); err != nil {
@@ -184,6 +188,230 @@ func getRcodeForNotFound(ctx context.Context, r resolver, domain string, origina
return dns.RcodeSuccess
}
+// RecordResolver is the host resolver surface used to forward non-address
+// record queries. net.DefaultResolver satisfies it.
+type RecordResolver interface {
+ LookupMX(ctx context.Context, name string) ([]*net.MX, error)
+ LookupTXT(ctx context.Context, name string) ([]string, error)
+ LookupNS(ctx context.Context, name string) ([]*net.NS, error)
+ LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error)
+ LookupCNAME(ctx context.Context, host string) (string, error)
+ LookupAddr(ctx context.Context, addr string) ([]string, error)
+}
+
+// LookupRecords resolves a non-address DNS record type through the host
+// resolver and returns the resource records and the DNS rcode. Types the host
+// resolver cannot answer (anything not covered by the net.Resolver Lookup*
+// methods) yield NODATA so that a routed name is never poisoned with NXDOMAIN
+// for an unsupported type.
+func LookupRecords(ctx context.Context, r RecordResolver, name string, qtype uint16, ttl uint32) ([]dns.RR, int) {
+ fqdn := dns.Fqdn(name)
+
+ switch qtype {
+ case dns.TypeMX:
+ return lookupMX(ctx, r, name, fqdn, ttl)
+ case dns.TypeTXT:
+ return lookupTXT(ctx, r, name, fqdn, ttl)
+ case dns.TypeNS:
+ return lookupNS(ctx, r, name, fqdn, ttl)
+ case dns.TypeSRV:
+ return lookupSRV(ctx, r, name, fqdn, ttl)
+ case dns.TypeCNAME:
+ return lookupCNAME(ctx, r, name, fqdn, ttl)
+ case dns.TypePTR:
+ return lookupPTR(ctx, r, name, fqdn, ttl)
+ default:
+ return nil, dns.RcodeSuccess
+ }
+}
+
+func recordHeader(fqdn string, rrtype uint16, ttl uint32) dns.RR_Header {
+ return dns.RR_Header{Name: fqdn, Rrtype: rrtype, Class: dns.ClassINET, Ttl: ttl}
+}
+
+func lookupMX(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ recs, err := r.LookupMX(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, mx := range recs {
+ rrs = append(rrs, &dns.MX{
+ Hdr: recordHeader(fqdn, dns.TypeMX, ttl),
+ Preference: mx.Pref,
+ Mx: dns.Fqdn(mx.Host),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupTXT(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ recs, err := r.LookupTXT(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, txt := range recs {
+ rrs = append(rrs, &dns.TXT{
+ Hdr: recordHeader(fqdn, dns.TypeTXT, ttl),
+ Txt: chunkTXT(txt),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupNS(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ recs, err := r.LookupNS(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, ns := range recs {
+ rrs = append(rrs, &dns.NS{
+ Hdr: recordHeader(fqdn, dns.TypeNS, ttl),
+ Ns: dns.Fqdn(ns.Host),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupSRV(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ _, recs, err := r.LookupSRV(ctx, "", "", name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(recs))
+ for _, srv := range recs {
+ rrs = append(rrs, &dns.SRV{
+ Hdr: recordHeader(fqdn, dns.TypeSRV, ttl),
+ Priority: srv.Priority,
+ Weight: srv.Weight,
+ Port: srv.Port,
+ Target: dns.Fqdn(srv.Target),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+func lookupCNAME(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ cname, err := r.LookupCNAME(ctx, name)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ // LookupCNAME returns the queried name itself when the name resolves but
+ // has no CNAME record; that is a NODATA result, not a CNAME.
+ if strings.EqualFold(dns.Fqdn(cname), fqdn) {
+ return nil, dns.RcodeSuccess
+ }
+ return []dns.RR{&dns.CNAME{
+ Hdr: recordHeader(fqdn, dns.TypeCNAME, ttl),
+ Target: dns.Fqdn(cname),
+ }}, dns.RcodeSuccess
+}
+
+func lookupPTR(ctx context.Context, r RecordResolver, name, fqdn string, ttl uint32) ([]dns.RR, int) {
+ addr, ok := ptrQueryAddr(name)
+ if !ok {
+ return nil, dns.RcodeSuccess
+ }
+ names, err := r.LookupAddr(ctx, addr)
+ if err != nil {
+ return nil, rcodeForRecordError(err)
+ }
+ rrs := make([]dns.RR, 0, len(names))
+ for _, n := range names {
+ rrs = append(rrs, &dns.PTR{
+ Hdr: recordHeader(fqdn, dns.TypePTR, ttl),
+ Ptr: dns.Fqdn(n),
+ })
+ }
+ return rrs, dns.RcodeSuccess
+}
+
+// ptrQueryAddr converts a reverse-DNS query name (in-addr.arpa or ip6.arpa)
+// into the address string expected by net.Resolver.LookupAddr. It reports false
+// when the name is not a well-formed reverse name.
+func ptrQueryAddr(qname string) (string, bool) {
+ name := strings.TrimSuffix(strings.ToLower(dns.Fqdn(qname)), ".")
+
+ switch {
+ case strings.HasSuffix(name, ".in-addr.arpa"):
+ return parseInAddrArpa(strings.TrimSuffix(name, ".in-addr.arpa"))
+ case strings.HasSuffix(name, ".ip6.arpa"):
+ return parseIP6Arpa(strings.TrimSuffix(name, ".ip6.arpa"))
+ default:
+ return "", false
+ }
+}
+
+// parseInAddrArpa turns the label portion of an in-addr.arpa name into an IPv4
+// address string, reporting false when it is not a well-formed reverse name.
+func parseInAddrArpa(labelPart string) (string, bool) {
+ labels := strings.Split(labelPart, ".")
+ if len(labels) != 4 {
+ return "", false
+ }
+ slices.Reverse(labels)
+ addr, err := netip.ParseAddr(strings.Join(labels, "."))
+ if err != nil || !addr.Is4() {
+ return "", false
+ }
+ return addr.String(), true
+}
+
+// parseIP6Arpa turns the nibble portion of an ip6.arpa name into an IPv6
+// address string, reporting false when it is not a well-formed reverse name.
+func parseIP6Arpa(nibblePart string) (string, bool) {
+ nibbles := strings.Split(nibblePart, ".")
+ if len(nibbles) != 32 {
+ return "", false
+ }
+ slices.Reverse(nibbles)
+ var sb strings.Builder
+ for i, n := range nibbles {
+ if i > 0 && i%4 == 0 {
+ sb.WriteByte(':')
+ }
+ sb.WriteString(n)
+ }
+ addr, err := netip.ParseAddr(sb.String())
+ if err != nil || !addr.Is6() {
+ return "", false
+ }
+ return addr.String(), true
+}
+
+// rcodeForRecordError maps a non-address lookup error to a DNS rcode. A
+// not-found result becomes NODATA rather than NXDOMAIN: net.DNSError.IsNotFound
+// does not distinguish a missing name from a name that exists only with records
+// of other types, so the name cannot be proven absent and must not be poisoned.
+func rcodeForRecordError(err error) int {
+ var dnsErr *net.DNSError
+ if errors.As(err, &dnsErr) && dnsErr.IsNotFound {
+ return dns.RcodeSuccess
+ }
+ return dns.RcodeServerFailure
+}
+
+// chunkTXT splits a TXT string into character-strings no longer than 255 bytes
+// so the record can be packed. The chunks form one TXT resource record.
+func chunkTXT(s string) []string {
+ const maxLen = 255
+ if len(s) <= maxLen {
+ return []string{s}
+ }
+
+ var chunks []string
+ for len(s) > maxLen {
+ chunks = append(chunks, s[:maxLen])
+ s = s[maxLen:]
+ }
+ if len(s) > 0 {
+ chunks = append(chunks, s)
+ }
+ return chunks
+}
+
// FormatAnswers formats DNS resource records for logging.
func FormatAnswers(answers []dns.RR) string {
if len(answers) == 0 {
diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go
index e6a8cc6a5..f51092a83 100644
--- a/client/internal/dns/resutil/resolve_test.go
+++ b/client/internal/dns/resutil/resolve_test.go
@@ -5,6 +5,7 @@ import (
"errors"
"net"
"net/netip"
+ "strings"
"testing"
"github.com/miekg/dns"
@@ -121,6 +122,164 @@ func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) {
assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL")
}
+func TestPtrQueryAddr(t *testing.T) {
+ tests := []struct {
+ name string
+ qname string
+ want string
+ wantOK bool
+ }{
+ {name: "ipv4", qname: "4.3.2.1.in-addr.arpa.", want: "1.2.3.4", wantOK: true},
+ {name: "ipv4 no trailing dot", qname: "1.0.0.127.in-addr.arpa", want: "127.0.0.1", wantOK: true},
+ {
+ name: "ipv6",
+ qname: "1.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.8.b.d.0.1.0.0.2.ip6.arpa.",
+ want: "2001:db8::1",
+ wantOK: true,
+ },
+ {name: "ipv4 wrong label count", qname: "2.1.in-addr.arpa.", wantOK: false},
+ {name: "ipv6 wrong nibble count", qname: "1.0.ip6.arpa.", wantOK: false},
+ {name: "not a reverse name", qname: "example.com.", wantOK: false},
+ {name: "ipv4 bad octet", qname: "4.3.2.999.in-addr.arpa.", wantOK: false},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, ok := ptrQueryAddr(tt.qname)
+ assert.Equal(t, tt.wantOK, ok, "parse success mismatch")
+ if tt.wantOK {
+ assert.Equal(t, tt.want, got, "parsed address mismatch")
+ }
+ })
+ }
+}
+
+type mockRecordResolver struct {
+ mx []*net.MX
+ txt []string
+ ns []*net.NS
+ srv []*net.SRV
+ cname string
+ ptr []string
+ err error
+}
+
+func (m *mockRecordResolver) LookupMX(context.Context, string) ([]*net.MX, error) {
+ return m.mx, m.err
+}
+func (m *mockRecordResolver) LookupTXT(context.Context, string) ([]string, error) {
+ return m.txt, m.err
+}
+func (m *mockRecordResolver) LookupNS(context.Context, string) ([]*net.NS, error) {
+ return m.ns, m.err
+}
+func (m *mockRecordResolver) LookupSRV(context.Context, string, string, string) (string, []*net.SRV, error) {
+ return "", m.srv, m.err
+}
+func (m *mockRecordResolver) LookupCNAME(context.Context, string) (string, error) {
+ return m.cname, m.err
+}
+func (m *mockRecordResolver) LookupAddr(context.Context, string) ([]string, error) {
+ return m.ptr, m.err
+}
+
+func TestLookupRecords(t *testing.T) {
+ notFound := &net.DNSError{IsNotFound: true, Name: "example.com."}
+
+ t.Run("MX success", func(t *testing.T) {
+ r := &mockRecordResolver{mx: []*net.MX{{Host: "mail.example.com.", Pref: 10}}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "mail.example.com.", rrs[0].(*dns.MX).Mx)
+ })
+
+ t.Run("TXT short string is one character-string", func(t *testing.T) {
+ r := &mockRecordResolver{txt: []string{"v=spf1 -all"}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, []string{"v=spf1 -all"}, rrs[0].(*dns.TXT).Txt)
+ })
+
+ t.Run("TXT chunks long strings", func(t *testing.T) {
+ long := strings.Repeat("a", 300)
+ r := &mockRecordResolver{txt: []string{long}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeTXT, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ txt := rrs[0].(*dns.TXT).Txt
+ require.Len(t, txt, 2, "300-byte string should split into two character-strings")
+ assert.Equal(t, 255, len(txt[0]))
+ assert.Equal(t, 45, len(txt[1]))
+ })
+
+ t.Run("NS success", func(t *testing.T) {
+ r := &mockRecordResolver{ns: []*net.NS{{Host: "ns1.example.com."}}}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeNS, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "ns1.example.com.", rrs[0].(*dns.NS).Ns)
+ })
+
+ t.Run("SRV success", func(t *testing.T) {
+ r := &mockRecordResolver{srv: []*net.SRV{{Target: "sip.example.com.", Port: 5060}}}
+ rrs, rcode := LookupRecords(context.Background(), r, "_sip._tcp.example.com.", dns.TypeSRV, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, uint16(5060), rrs[0].(*dns.SRV).Port)
+ })
+
+ t.Run("CNAME success", func(t *testing.T) {
+ r := &mockRecordResolver{cname: "target.example.com."}
+ rrs, rcode := LookupRecords(context.Background(), r, "www.example.com.", dns.TypeCNAME, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "target.example.com.", rrs[0].(*dns.CNAME).Target)
+ })
+
+ t.Run("CNAME equal to name is NODATA", func(t *testing.T) {
+ r := &mockRecordResolver{cname: "example.com."}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCNAME, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ assert.Empty(t, rrs, "self-referential CNAME is NODATA")
+ })
+
+ t.Run("PTR success", func(t *testing.T) {
+ r := &mockRecordResolver{ptr: []string{"host.example.com."}}
+ rrs, rcode := LookupRecords(context.Background(), r, "4.3.2.1.in-addr.arpa.", dns.TypePTR, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ require.Len(t, rrs, 1)
+ assert.Equal(t, "host.example.com.", rrs[0].(*dns.PTR).Ptr)
+ })
+
+ t.Run("PTR malformed name is NODATA", func(t *testing.T) {
+ r := &mockRecordResolver{}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypePTR, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ assert.Empty(t, rrs)
+ })
+
+ t.Run("not found is NODATA never NXDOMAIN", func(t *testing.T) {
+ r := &mockRecordResolver{err: notFound}
+ _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode, "missing record must not poison the name")
+ })
+
+ t.Run("server failure maps to SERVFAIL", func(t *testing.T) {
+ r := &mockRecordResolver{err: &net.DNSError{Err: "server misbehaving", IsTemporary: true}}
+ _, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeMX, 300)
+ assert.Equal(t, dns.RcodeServerFailure, rcode)
+ })
+
+ t.Run("unsupported type is NODATA", func(t *testing.T) {
+ r := &mockRecordResolver{}
+ rrs, rcode := LookupRecords(context.Background(), r, "example.com.", dns.TypeCAA, 300)
+ assert.Equal(t, dns.RcodeSuccess, rcode)
+ assert.Empty(t, rrs)
+ })
+}
+
func TestStripOPT(t *testing.T) {
rm := &dns.Msg{
Extra: []dns.RR{
diff --git a/client/internal/dnsfwd/forwarder.go b/client/internal/dnsfwd/forwarder.go
index c15a8520f..b7e5a10e3 100644
--- a/client/internal/dnsfwd/forwarder.go
+++ b/client/internal/dnsfwd/forwarder.go
@@ -37,6 +37,12 @@ const (
type resolver interface {
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
+ LookupMX(ctx context.Context, name string) ([]*net.MX, error)
+ LookupTXT(ctx context.Context, name string) ([]string, error)
+ LookupNS(ctx context.Context, name string) ([]*net.NS, error)
+ LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error)
+ LookupCNAME(ctx context.Context, host string) (string, error)
+ LookupAddr(ctx context.Context, addr string) ([]string, error)
}
type firewaller interface {
@@ -210,12 +216,6 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
qname, dns.TypeToString[question.Qtype], dns.ClassToString[question.Qclass])
resp := query.SetReply(query)
- network := resutil.NetworkForQtype(question.Qtype)
- if network == "" {
- resp.Rcode = dns.RcodeNotImplemented
- f.writeResponse(logger, w, resp, qname, startTime)
- return
- }
mostSpecificResId, matchingEntries := f.getMatchingEntries(strings.TrimSuffix(qname, "."))
if mostSpecificResId == "" {
@@ -227,9 +227,46 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
ctx, cancel := context.WithTimeout(context.Background(), upstreamTimeout)
defer cancel()
+ reqHasEdns := query.IsEdns0() != nil
+
+ switch question.Qtype {
+ case dns.TypeA, dns.TypeAAAA:
+ f.handleAddressQuery(ctx, logger, w, resp, mostSpecificResId, matchingEntries, reqHasEdns, startTime)
+ case dns.TypeMX, dns.TypeTXT, dns.TypeNS, dns.TypeSRV, dns.TypeCNAME, dns.TypePTR:
+ f.handleRecordQuery(ctx, logger, w, resp, startTime)
+ default:
+ // The domain is routed here, so any other type is answered NODATA
+ // (NOERROR, empty answer) rather than falling back to a resolver that
+ // would poison the name with NXDOMAIN. The Extended DNS Error lets a
+ // client tell this capability-driven NODATA apart from an
+ // authoritative one. The OPT pseudo-record must not appear unless the
+ // query advertised EDNS0.
+ if reqHasEdns {
+ attachEDE(resp, dns.ExtendedErrorCodeNotSupported, "netbird forwarder: unsupported query type")
+ }
+ f.writeResponse(logger, w, resp, qname, startTime)
+ }
+}
+
+// handleAddressQuery resolves A/AAAA queries, programs the firewall sets and
+// resolved-IP state, and caches the answer for resilience on upstream failure.
+func (f *DNSForwarder) handleAddressQuery(
+ ctx context.Context,
+ logger *log.Entry,
+ w dns.ResponseWriter,
+ resp *dns.Msg,
+ mostSpecificResId route.ResID,
+ matchingEntries []*ForwarderEntry,
+ reqHasEdns bool,
+ startTime time.Time,
+) {
+ question := resp.Question[0]
+ qname := strings.ToLower(question.Name)
+
+ network := resutil.NetworkForQtype(question.Qtype)
result := resutil.LookupIP(ctx, f.resolver, network, qname, question.Qtype)
if result.Err != nil {
- f.handleDNSError(ctx, logger, w, question, resp, qname, result, query.IsEdns0() != nil, startTime)
+ f.handleDNSError(ctx, logger, w, question, resp, qname, result, reqHasEdns, startTime)
return
}
@@ -240,6 +277,25 @@ func (f *DNSForwarder) handleDNSQuery(logger *log.Entry, w dns.ResponseWriter, q
f.writeResponse(logger, w, resp, qname, startTime)
}
+// handleRecordQuery resolves non-address record types (MX, TXT, NS, SRV,
+// CNAME, PTR) through the host resolver. Missing records are answered NODATA so
+// the routed name is never poisoned with NXDOMAIN.
+func (f *DNSForwarder) handleRecordQuery(
+ ctx context.Context,
+ logger *log.Entry,
+ w dns.ResponseWriter,
+ resp *dns.Msg,
+ startTime time.Time,
+) {
+ question := resp.Question[0]
+ qname := strings.ToLower(question.Name)
+
+ records, rcode := resutil.LookupRecords(ctx, f.resolver, qname, question.Qtype, f.ttl)
+ resp.Rcode = rcode
+ resp.Answer = append(resp.Answer, records...)
+ f.writeResponse(logger, w, resp, qname, startTime)
+}
+
func (f *DNSForwarder) writeResponse(logger *log.Entry, w dns.ResponseWriter, resp *dns.Msg, qname string, startTime time.Time) {
if err := w.WriteMsg(resp); err != nil {
logger.Errorf("failed to write DNS response: %v", err)
diff --git a/client/internal/dnsfwd/forwarder_test.go b/client/internal/dnsfwd/forwarder_test.go
index 046595473..c69a9166e 100644
--- a/client/internal/dnsfwd/forwarder_test.go
+++ b/client/internal/dnsfwd/forwarder_test.go
@@ -133,6 +133,41 @@ func (m *MockResolver) LookupNetIP(ctx context.Context, network, host string) ([
return args.Get(0).([]netip.Addr), args.Error(1)
}
+func (m *MockResolver) LookupMX(ctx context.Context, name string) ([]*net.MX, error) {
+ args := m.Called(ctx, name)
+ recs, _ := args.Get(0).([]*net.MX)
+ return recs, args.Error(1)
+}
+
+func (m *MockResolver) LookupTXT(ctx context.Context, name string) ([]string, error) {
+ args := m.Called(ctx, name)
+ recs, _ := args.Get(0).([]string)
+ return recs, args.Error(1)
+}
+
+func (m *MockResolver) LookupNS(ctx context.Context, name string) ([]*net.NS, error) {
+ args := m.Called(ctx, name)
+ recs, _ := args.Get(0).([]*net.NS)
+ return recs, args.Error(1)
+}
+
+func (m *MockResolver) LookupSRV(ctx context.Context, service, proto, name string) (string, []*net.SRV, error) {
+ args := m.Called(ctx, service, proto, name)
+ recs, _ := args.Get(1).([]*net.SRV)
+ return args.String(0), recs, args.Error(2)
+}
+
+func (m *MockResolver) LookupCNAME(ctx context.Context, host string) (string, error) {
+ args := m.Called(ctx, host)
+ return args.String(0), args.Error(1)
+}
+
+func (m *MockResolver) LookupAddr(ctx context.Context, addr string) ([]string, error) {
+ args := m.Called(ctx, addr)
+ recs, _ := args.Get(0).([]string)
+ return recs, args.Error(1)
+}
+
func TestDNSForwarder_SubdomainAccessLogic(t *testing.T) {
tests := []struct {
name string
@@ -545,12 +580,15 @@ func TestDNSForwarder_MultipleIPsInSingleUpdate(t *testing.T) {
}
func TestDNSForwarder_ResponseCodes(t *testing.T) {
+ // A type with no net.Resolver Lookup method (CAA) must answer NODATA
+ // (NOERROR, empty) rather than NXDOMAIN/NOTIMP to avoid poisoning the name.
tests := []struct {
name string
queryType uint16
queryDomain string
configured string
expectedCode int
+ expectEDE bool
description string
}{
{
@@ -562,28 +600,13 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
description: "RFC compliant REFUSED for unauthorized queries",
},
{
- name: "unsupported query type returns NOTIMP",
- queryType: dns.TypeMX,
+ name: "unsupported query type returns NODATA",
+ queryType: dns.TypeCAA,
queryDomain: "example.com",
configured: "example.com",
- expectedCode: dns.RcodeNotImplemented,
- description: "RFC compliant NOTIMP for unsupported types",
- },
- {
- name: "CNAME query returns NOTIMP",
- queryType: dns.TypeCNAME,
- queryDomain: "example.com",
- configured: "example.com",
- expectedCode: dns.RcodeNotImplemented,
- description: "CNAME queries not supported",
- },
- {
- name: "TXT query returns NOTIMP",
- queryType: dns.TypeTXT,
- queryDomain: "example.com",
- configured: "example.com",
- expectedCode: dns.RcodeNotImplemented,
- description: "TXT queries not supported",
+ expectedCode: dns.RcodeSuccess,
+ expectEDE: true,
+ description: "Unsupported types answer NODATA, not NXDOMAIN/NOTIMP",
},
}
@@ -599,6 +622,7 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
query := &dns.Msg{}
query.SetQuestion(dns.Fqdn(tt.queryDomain), tt.queryType)
+ query.SetEdns0(dns.DefaultMsgSize, false)
// Capture the written response
var writtenResp *dns.Msg
@@ -614,10 +638,213 @@ func TestDNSForwarder_ResponseCodes(t *testing.T) {
// Check the response written to the writer
require.NotNil(t, writtenResp, "Expected response to be written")
assert.Equal(t, tt.expectedCode, writtenResp.Rcode, tt.description)
+ assert.Empty(t, writtenResp.Answer, "Non-address response should carry no answers")
+
+ if tt.expectEDE {
+ require.NotNil(t, writtenResp.IsEdns0(), "EDNS0 client should get an OPT in the reply")
+ assert.True(t, hasEDE(writtenResp, dns.ExtendedErrorCodeNotSupported),
+ "unsupported type NODATA should carry EDE Not Supported")
+ }
})
}
}
+func hasEDE(m *dns.Msg, code uint16) bool {
+ opt := m.IsEdns0()
+ if opt == nil {
+ return false
+ }
+ for _, o := range opt.Option {
+ if ede, ok := o.(*dns.EDNS0_EDE); ok && ede.InfoCode == code {
+ return true
+ }
+ }
+ return false
+}
+
+func TestDNSForwarder_RecordQueries(t *testing.T) {
+ notFound := &net.DNSError{IsNotFound: true, Name: "example.com"}
+
+ t.Run("MX records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupMX", mock.Anything, "example.com.").
+ Return([]*net.MX{{Host: "mail.example.com.", Pref: 10}}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ mx, ok := resp.Answer[0].(*dns.MX)
+ require.True(t, ok, "answer should be an MX record")
+ assert.Equal(t, uint16(10), mx.Preference)
+ assert.Equal(t, "mail.example.com.", mx.Mx)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("missing MX is NODATA not NXDOMAIN", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ // A not-found cannot prove the name is absent (it may exist with only
+ // other record types), so it must answer NODATA, never NXDOMAIN.
+ mockResolver.On("LookupMX", mock.Anything, "example.com.").
+ Return(nil, notFound).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeMX)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode, "missing record must be NODATA")
+ assert.Empty(t, resp.Answer)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("NS records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupNS", mock.Anything, "example.com.").
+ Return([]*net.NS{{Host: "ns1.example.com."}}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ ns, ok := resp.Answer[0].(*dns.NS)
+ require.True(t, ok, "answer should be an NS record")
+ assert.Equal(t, "ns1.example.com.", ns.Ns)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("missing NS is NODATA", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupNS", mock.Anything, "example.com.").
+ Return(nil, notFound).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeNS)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ assert.Empty(t, resp.Answer)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("SRV records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com")
+
+ mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com.").
+ Return("", []*net.SRV{{Target: "sip.example.com.", Port: 5060, Priority: 10, Weight: 5}}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ srv, ok := resp.Answer[0].(*dns.SRV)
+ require.True(t, ok, "answer should be an SRV record")
+ assert.Equal(t, "sip.example.com.", srv.Target)
+ assert.Equal(t, uint16(5060), srv.Port)
+ assert.Equal(t, uint16(10), srv.Priority)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("missing SRV is NODATA", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "_sip._tcp.example.com")
+
+ mockResolver.On("LookupSRV", mock.Anything, "", "", "_sip._tcp.example.com.").
+ Return("", nil, notFound).Once()
+
+ resp := runRecordQuery(t, forwarder, "_sip._tcp.example.com", dns.TypeSRV)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ assert.Empty(t, resp.Answer)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("TXT records are forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ mockResolver.On("LookupTXT", mock.Anything, "example.com.").
+ Return([]string{"v=spf1 -all"}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeTXT)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ txt, ok := resp.Answer[0].(*dns.TXT)
+ require.True(t, ok, "answer should be a TXT record")
+ assert.Equal(t, []string{"v=spf1 -all"}, txt.Txt)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("CNAME record is forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "www.example.com")
+
+ mockResolver.On("LookupCNAME", mock.Anything, "www.example.com.").
+ Return("target.example.com.", nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "www.example.com", dns.TypeCNAME)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ cname, ok := resp.Answer[0].(*dns.CNAME)
+ require.True(t, ok, "answer should be a CNAME record")
+ assert.Equal(t, "target.example.com.", cname.Target)
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("CNAME equal to the name is NODATA", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "example.com")
+
+ // No CNAME exists: LookupCNAME echoes the queried name back.
+ mockResolver.On("LookupCNAME", mock.Anything, "example.com.").
+ Return("example.com.", nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "example.com", dns.TypeCNAME)
+ assert.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ assert.Empty(t, resp.Answer, "self-referential CNAME means no CNAME record")
+ mockResolver.AssertExpectations(t)
+ })
+
+ t.Run("PTR record is forwarded", func(t *testing.T) {
+ mockResolver := &MockResolver{}
+ forwarder := newRecordTestForwarder(t, mockResolver, "*.in-addr.arpa")
+
+ // The reverse name is parsed back to the address LookupAddr expects.
+ mockResolver.On("LookupAddr", mock.Anything, "1.2.3.4").
+ Return([]string{"host.example.com."}, nil).Once()
+
+ resp := runRecordQuery(t, forwarder, "4.3.2.1.in-addr.arpa", dns.TypePTR)
+ require.Equal(t, dns.RcodeSuccess, resp.Rcode)
+ require.Len(t, resp.Answer, 1)
+ ptr, ok := resp.Answer[0].(*dns.PTR)
+ require.True(t, ok, "answer should be a PTR record")
+ assert.Equal(t, "host.example.com.", ptr.Ptr)
+ mockResolver.AssertExpectations(t)
+ })
+}
+
+func newRecordTestForwarder(t *testing.T, r resolver, configured string) *DNSForwarder {
+ t.Helper()
+ forwarder := NewDNSForwarder(netip.MustParseAddrPort("127.0.0.1:0"), 300, nil, &peer.Status{}, nil)
+ forwarder.resolver = r
+
+ d, err := domain.FromString(configured)
+ require.NoError(t, err)
+ forwarder.UpdateDomains([]*ForwarderEntry{{Domain: d, ResID: "test-res"}})
+ return forwarder
+}
+
+func runRecordQuery(t *testing.T, forwarder *DNSForwarder, qname string, qtype uint16) *dns.Msg {
+ t.Helper()
+ query := &dns.Msg{}
+ query.SetQuestion(dns.Fqdn(qname), qtype)
+
+ mockWriter := &test.MockResponseWriter{}
+ forwarder.handleDNSQuery(log.NewEntry(log.StandardLogger()), mockWriter, query, time.Now())
+
+ resp := mockWriter.GetLastResponse()
+ require.NotNil(t, resp, "expected response to be written")
+ return resp
+}
+
func TestDNSForwarder_UpstreamFailureEDE(t *testing.T) {
tests := []struct {
name string
diff --git a/client/internal/routemanager/dnsinterceptor/handler.go b/client/internal/routemanager/dnsinterceptor/handler.go
index 22f3355c8..b784cc274 100644
--- a/client/internal/routemanager/dnsinterceptor/handler.go
+++ b/client/internal/routemanager/dnsinterceptor/handler.go
@@ -226,12 +226,11 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
return
}
- // pass if non A/AAAA query
- if r.Question[0].Qtype != dns.TypeA && r.Question[0].Qtype != dns.TypeAAAA {
- d.continueToNextHandler(w, r, logger, "non A/AAAA query")
- return
- }
-
+ // All query types for an intercepted domain are forwarded to the peer's
+ // DNS forwarder, which owns the name. Falling through to the system
+ // resolver would let it answer NXDOMAIN for a name it isn't authoritative
+ // for, poisoning the whole name (including the A/AAAA records the route
+ // does serve). The forwarder answers NODATA for types it cannot resolve.
d.mu.RLock()
peerKey := d.currentPeerKey
d.mu.RUnlock()
@@ -293,19 +292,6 @@ func (d *DnsInterceptor) writeDNSError(w dns.ResponseWriter, r *dns.Msg, logger
}
}
-// continueToNextHandler signals the handler chain to try the next handler
-func (d *DnsInterceptor) continueToNextHandler(w dns.ResponseWriter, r *dns.Msg, logger *log.Entry, reason string) {
- logger.Tracef("continuing to next handler for domain=%s reason=%s", r.Question[0].Name, reason)
-
- resp := new(dns.Msg)
- resp.SetRcode(r, dns.RcodeNameError)
- // Set Zero bit to signal handler chain to continue
- resp.MsgHdr.Zero = true
- if err := w.WriteMsg(resp); err != nil {
- logger.Errorf("failed writing DNS continue response: %v", err)
- }
-}
-
func (d *DnsInterceptor) getUpstreamIP(peerKey string) (netip.Addr, error) {
peerAllowedIP, exists := d.peerStore.AllowedIP(peerKey)
if !exists {
From 1409a1325a805d70f456244fde999bbb09bb06b6 Mon Sep 17 00:00:00 2001
From: Maycon Santos
Date: Mon, 29 Jun 2026 09:19:01 +0200
Subject: [PATCH 11/20] [misc] Update careers page link (#6538)
---
README.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/README.md b/README.md
index c9a51b6f1..40c6b9ed5 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@
- 🚀 We are hiring! Join us at careers.netbird.io
+ 🚀 We are hiring! Join us at https://netbird.io/careers
From 5711f0e38c69ddbbfb5604a44a5098a6a04d7dcb Mon Sep 17 00:00:00 2001
From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
Date: Mon, 29 Jun 2026 11:02:02 +0200
Subject: [PATCH 12/20] [client] add per-phase timing metrics for sync
processing (#6533)
* Adds metrics sync phases time split to monitor costs
* Address review fixes
* Increment README.md with description on usage with debug bundles
---
client/internal/engine.go | 119 +++++---
client/internal/metrics/influxdb.go | 24 ++
client/internal/metrics/infra/README.md | 69 ++++-
.../dashboards/json/netbird-sync-phases.json | 259 ++++++++++++++++++
client/internal/metrics/infra/ingest/main.go | 13 +
client/internal/metrics/metrics.go | 15 +
client/internal/metrics/push_test.go | 3 +
7 files changed, 468 insertions(+), 34 deletions(-)
create mode 100644 client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json
diff --git a/client/internal/engine.go b/client/internal/engine.go
index e7f1c0501..f7c7e1862 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -895,6 +895,16 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate
e.updateManager.SetVersion(autoUpdateSettings.Version, autoUpdateSettings.AlwaysUpdate)
}
+// phase times a sync sub-phase: it returns a function that records the elapsed
+// duration when called. Starting the timer at the call site keeps inter-phase
+// glue code out of the measurement.
+func (e *Engine) phase(name string) func() {
+ start := time.Now()
+ return func() {
+ e.clientMetrics.RecordSyncPhase(e.ctx, name, time.Since(start))
+ }
+}
+
func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
started := time.Now()
defer func() {
@@ -914,7 +924,10 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
}
- if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil {
+ done := e.phase("netbird_config")
+ err := e.updateNetbirdConfig(update.GetNetbirdConfig())
+ done()
+ if err != nil {
return err
}
@@ -928,11 +941,16 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
- if err := e.updateChecksIfNew(update.Checks); err != nil {
+ done = e.phase("checks")
+ err = e.updateChecksIfNew(update.Checks)
+ done()
+ if err != nil {
return err
}
+ done = e.phase("persist")
e.persistSyncResponse(update)
+ done()
// only apply new changes and ignore old ones
if err := e.updateNetworkMap(nm); err != nil {
@@ -1371,13 +1389,16 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
dnsConfig := toDNSConfig(protoDNSConfig, e.wgInterface.Address())
+ done := e.phase("dns_server")
if err := e.dnsServer.UpdateDNSServer(serial, dnsConfig); err != nil {
log.Errorf("failed to update dns server, err: %v", err)
}
+ done()
e.routeManager.SetDNSForwarderPort(dnsConfig.ForwarderPort)
// apply routes first, route related actions might depend on routing being enabled
+ done = e.phase("routes_classify")
routes := toRoutes(networkMap.GetRoutes())
serverRoutes, clientRoutes := e.routeManager.ClassifyRoutes(routes)
@@ -1386,29 +1407,60 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
e.connMgr.UpdateRouteHAMap(clientRoutes)
log.Debugf("updated lazy connection manager with %d HA groups", len(clientRoutes))
}
+ done()
+ done = e.phase("routes_apply")
dnsRouteFeatureFlag := toDNSFeatureFlag(networkMap)
if err := e.routeManager.UpdateRoutes(serial, serverRoutes, clientRoutes, dnsRouteFeatureFlag); err != nil {
log.Errorf("failed to update routes: %v", err)
}
+ done()
+ done = e.phase("filtering")
if e.acl != nil {
e.acl.ApplyFiltering(networkMap, dnsRouteFeatureFlag)
}
+ done()
+ done = e.phase("dns_forwarder")
fwdEntries := toRouteDomains(e.config.WgPrivateKey.PublicKey().String(), routes)
e.updateDNSForwarder(dnsRouteFeatureFlag, fwdEntries)
+ done()
// Ingress forward rules
+ done = e.phase("forward_rules")
forwardingRules, err := e.updateForwardRules(networkMap.GetForwardingRules())
if err != nil {
log.Errorf("failed to update forward rules, err: %v", err)
}
+ done()
log.Debugf("got peers update from Management Service, total peers to connect to = %d", len(networkMap.GetRemotePeers()))
+ done = e.phase("offline_peers")
e.updateOfflinePeers(networkMap.GetOfflinePeers())
+ done()
+ remotePeers, err := e.reconcilePeers(networkMap)
+ if err != nil {
+ return err
+ }
+
+ // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
+ done = e.phase("lazy_exclude")
+ excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
+ e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
+ done()
+
+ e.networkSerial = serial
+
+ return nil
+}
+
+// reconcilePeers applies the remote peer list from the network map (removing,
+// modifying and adding peers, then updating SSH config) and returns the remote
+// peers with our own peer filtered out, for use by later sync steps.
+func (e *Engine) reconcilePeers(networkMap *mgmProto.NetworkMap) ([]*mgmProto.RemotePeerConfig, error) {
// Filter out own peer from the remote peers list
localPubKey := e.config.WgPrivateKey.PublicKey().String()
remotePeers := make([]*mgmProto.RemotePeerConfig, 0, len(networkMap.GetRemotePeers()))
@@ -1423,42 +1475,43 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
err := e.removeAllPeers()
e.statusRecorder.FinishPeerListModifications()
if err != nil {
- return err
+ return nil, err
}
- } else {
- err := e.removePeers(remotePeers)
- if err != nil {
- return err
- }
-
- err = e.modifyPeers(remotePeers)
- if err != nil {
- return err
- }
-
- err = e.addNewPeers(remotePeers)
- if err != nil {
- return err
- }
-
- e.statusRecorder.FinishPeerListModifications()
-
- e.updatePeerSSHHostKeys(remotePeers)
-
- if err := e.updateSSHClientConfig(remotePeers); err != nil {
- log.Warnf("failed to update SSH client config: %v", err)
- }
-
- e.updateSSHServerAuth(networkMap.GetSshAuth())
+ return remotePeers, nil
}
- // must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
- excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
- e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
+ done := e.phase("removed_peers")
+ err := e.removePeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
- e.networkSerial = serial
+ done = e.phase("modified_peers")
+ err = e.modifyPeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
- return nil
+ done = e.phase("added_peers")
+ err = e.addNewPeers(remotePeers)
+ done()
+ if err != nil {
+ return nil, err
+ }
+
+ e.statusRecorder.FinishPeerListModifications()
+
+ e.updatePeerSSHHostKeys(remotePeers)
+
+ if err := e.updateSSHClientConfig(remotePeers); err != nil {
+ log.Warnf("failed to update SSH client config: %v", err)
+ }
+
+ e.updateSSHServerAuth(networkMap.GetSshAuth())
+
+ return remotePeers, nil
}
func toDNSFeatureFlag(networkMap *mgmProto.NetworkMap) bool {
diff --git a/client/internal/metrics/influxdb.go b/client/internal/metrics/influxdb.go
index 531f6a986..4ba14bf44 100644
--- a/client/internal/metrics/influxdb.go
+++ b/client/internal/metrics/influxdb.go
@@ -120,6 +120,30 @@ func (m *influxDBMetrics) RecordSyncDuration(_ context.Context, agentInfo AgentI
m.trimLocked()
}
+func (m *influxDBMetrics) RecordSyncPhase(_ context.Context, agentInfo AgentInfo, phase string, duration time.Duration) {
+ tags := fmt.Sprintf("deployment_type=%s,version=%s,os=%s,arch=%s,peer_id=%s,phase=%s",
+ agentInfo.DeploymentType.String(),
+ agentInfo.Version,
+ agentInfo.OS,
+ agentInfo.Arch,
+ agentInfo.peerID,
+ phase,
+ )
+
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ m.samples = append(m.samples, influxSample{
+ measurement: "netbird_sync_phase",
+ tags: tags,
+ fields: map[string]float64{
+ "duration_seconds": duration.Seconds(),
+ },
+ timestamp: time.Now(),
+ })
+ m.trimLocked()
+}
+
func (m *influxDBMetrics) RecordLoginDuration(_ context.Context, agentInfo AgentInfo, duration time.Duration, success bool) {
result := "success"
if !success {
diff --git a/client/internal/metrics/infra/README.md b/client/internal/metrics/infra/README.md
index 5a93dbd87..7941a30cf 100644
--- a/client/internal/metrics/infra/README.md
+++ b/client/internal/metrics/infra/README.md
@@ -78,6 +78,25 @@ Tags:
- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
- `arch`: CPU architecture (amd64, arm64, etc.)
+### Sync Phase Timing
+
+Measurement: `netbird_sync_phase`
+
+Breaks down where time goes inside a single sync, so the total `netbird_sync` duration can be attributed to the sub-step that dominates.
+
+| Field | Description |
+|-------|-------------|
+| `duration_seconds` | Time spent in one sub-phase of sync processing |
+
+Tags:
+- `phase`: the sub-phase — `netbird_config`, `checks`, `persist`, `dns_server`, `routes_classify`, `routes_apply`, `filtering`, `dns_forwarder`, `forward_rules`, `offline_peers`, `removed_peers`, `modified_peers`, `added_peers`, `lazy_exclude`
+- `deployment_type`: "cloud" | "selfhosted" | "unknown"
+- `version`: NetBird version string
+- `os`: Operating system (linux, darwin, windows, android, ios, etc.)
+- `arch`: CPU architecture (amd64, arm64, etc.)
+
+**Note:** this is wall-time per phase — it includes both CPU work and time spent waiting on locks. A slow phase points to *where* the time goes, not *why*; pair it with lock-wait metrics to tell contention apart from real work.
+
### Login Duration
Measurement: `netbird_login`
@@ -191,4 +210,52 @@ docker compose exec influxdb influx query \
# Check ingest server health
curl http://localhost:8087/health
-```
\ No newline at end of file
+```
+
+## Analyzing a Debug Bundle
+
+Metrics collection is always on, so every debug bundle ships a `metrics.txt` in InfluxDB line protocol — a timestamped time series of all recorded events (sync durations, sync phases, connection stages, login). You can replay it into the local stack and graph it, without a running client.
+
+The bundle's `metrics.txt` is a rolling window (capped at 5 days / ~20k samples, see [Buffer Limits](#buffer-limits)). For a connection incident the relevant window is short (connection setup is seconds), so a bundle captured during the issue is enough.
+
+### 1. Start the stack
+
+```bash
+# From this directory (client/internal/metrics/infra)
+INFLUXDB_ADMIN_TOKEN=admin123 INFLUXDB_ADMIN_PASSWORD=admin123 GRAFANA_ADMIN_PASSWORD=admin123 \
+ docker compose up -d
+```
+
+(`admin123` are throwaway local credentials — fine for offline analysis.)
+
+### 2. Clear any previous data
+
+So you only see this bundle:
+
+```bash
+docker exec influxdb influx delete --org netbird --bucket metrics --token admin123 \
+ --start 1970-01-01T00:00:00Z --stop 2100-01-01T00:00:00Z
+```
+
+### 3. Import the bundle's metrics.txt
+
+InfluxDB is not exposed on the host, so import inside the container:
+
+```bash
+docker cp /path/to/bundle/metrics.txt influxdb:/tmp/m.txt
+docker exec influxdb influx write --org netbird --bucket metrics --precision ns \
+ --token admin123 --file /tmp/m.txt
+```
+
+Re-importing the same file is idempotent (same measurement+tags+timestamp overwrites).
+
+### 4. View the dashboards
+
+Grafana on http://localhost:3001 (login `admin` / `admin123`), datasource pre-provisioned:
+
+- **Where sync time goes:** http://localhost:3001/d/netbird-sync-phases/netbird-sync-phases-where-time-goes
+- **General client metrics:** http://localhost:3001/d/netbird-influxdb-metrics
+
+**Set the time range** to cover the bundle's timestamps (e.g. "Last 7 days" or an absolute range matching when the bundle was taken) — with the default short range the panels look empty.
+
+Bundles are distinguishable by the `version` tag; add a tag at import time (e.g. `sed 's/^netbird_\([a-z_]*\),/netbird_\1,bundle=mycase,/' metrics.txt`) if you want to compare several side by side.
\ No newline at end of file
diff --git a/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json
new file mode 100644
index 000000000..69dbac0ae
--- /dev/null
+++ b/client/internal/metrics/infra/grafana/provisioning/dashboards/json/netbird-sync-phases.json
@@ -0,0 +1,259 @@
+{
+ "annotations": {
+ "list": []
+ },
+ "editable": true,
+ "fiscalYearStartMonth": 0,
+ "graphTooltip": 1,
+ "links": [],
+ "refresh": "",
+ "schemaVersion": 39,
+ "tags": [
+ "netbird",
+ "sync"
+ ],
+ "templating": {
+ "list": [
+ {
+ "current": {
+ "text": "All",
+ "value": "$__all"
+ },
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "definition": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")",
+ "includeAll": true,
+ "label": "version",
+ "multi": true,
+ "name": "version",
+ "query": "import \"influxdata/influxdb/schema\"\nschema.tagValues(bucket: \"metrics\", tag: \"version\")",
+ "refresh": 2,
+ "type": "query",
+ "allValue": ".*"
+ }
+ ]
+ },
+ "time": {
+ "from": "now-2d",
+ "to": "now"
+ },
+ "timepicker": {},
+ "timezone": "",
+ "title": "NetBird Sync Phases (where time goes)",
+ "uid": "netbird-sync-phases",
+ "version": 1,
+ "panels": [
+ {
+ "id": 1,
+ "title": "Time per phase over time (stacked, ms)",
+ "type": "timeseries",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 10,
+ "w": 24,
+ "x": 0,
+ "y": 0
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "custom": {
+ "drawStyle": "bars",
+ "stacking": {
+ "mode": "normal",
+ "group": "A"
+ },
+ "fillOpacity": 80,
+ "lineWidth": 0
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "calcs": [
+ "max",
+ "mean"
+ ]
+ },
+ "tooltip": {
+ "mode": "multi",
+ "sort": "desc"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"phase\"])\n |> group(columns: [\"phase\"])"
+ }
+ ]
+ },
+ {
+ "id": 2,
+ "title": "p95 per phase (ms)",
+ "type": "bargauge",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 11,
+ "w": 12,
+ "x": 0,
+ "y": 10
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "color": {
+ "mode": "continuous-GrYlRd"
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "displayMode": "gradient",
+ "orientation": "horizontal",
+ "reduceOptions": {
+ "calcs": [
+ "lastNotNull"
+ ],
+ "fields": "",
+ "values": false
+ },
+ "showUnfilled": true
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> sort(columns: [\"_value\"], desc: true)"
+ }
+ ]
+ },
+ {
+ "id": 3,
+ "title": "Per-phase stats (ms): mean / p95 / max",
+ "type": "table",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 11,
+ "w": 12,
+ "x": 12,
+ "y": 10
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms"
+ },
+ "overrides": []
+ },
+ "options": {
+ "showHeader": true,
+ "sortBy": [
+ {
+ "displayName": "max",
+ "desc": true
+ }
+ ]
+ },
+ "transformations": [
+ {
+ "id": "merge",
+ "options": {}
+ }
+ ],
+ "targets": [
+ {
+ "refId": "mean",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> mean()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"mean\"})"
+ },
+ {
+ "refId": "p95",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> quantile(q: 0.95)\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"p95\"})"
+ },
+ {
+ "refId": "max",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync_phase\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> group(columns: [\"phase\"])\n |> max()\n |> group()\n |> keep(columns: [\"phase\", \"_value\"])\n |> rename(columns: {_value: \"max\"})"
+ }
+ ]
+ },
+ {
+ "id": 4,
+ "title": "Total sync duration (netbird_sync, ms) \u2014 reference",
+ "type": "timeseries",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "gridPos": {
+ "h": 8,
+ "w": 24,
+ "x": 0,
+ "y": 21
+ },
+ "fieldConfig": {
+ "defaults": {
+ "unit": "ms",
+ "custom": {
+ "drawStyle": "points",
+ "pointSize": 5
+ }
+ },
+ "overrides": []
+ },
+ "options": {
+ "legend": {
+ "displayMode": "table",
+ "placement": "right",
+ "calcs": [
+ "max",
+ "mean"
+ ]
+ },
+ "tooltip": {
+ "mode": "single"
+ }
+ },
+ "targets": [
+ {
+ "refId": "A",
+ "datasource": {
+ "type": "influxdb",
+ "uid": "influxdb"
+ },
+ "query": "from(bucket: \"metrics\")\n |> range(start: v.timeRangeStart, stop: v.timeRangeStop)\n |> filter(fn: (r) => r._measurement == \"netbird_sync\" and r._field == \"duration_seconds\")\n |> filter(fn: (r) => r.version =~ /${version:regex}/)\n |> map(fn: (r) => ({ r with _value: r._value * 1000.0 }))\n |> keep(columns: [\"_time\", \"_value\", \"version\"])\n |> group(columns: [\"version\"])"
+ }
+ ]
+ }
+ ]
+}
\ No newline at end of file
diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go
index a5031a873..623a17e4d 100644
--- a/client/internal/metrics/infra/ingest/main.go
+++ b/client/internal/metrics/infra/ingest/main.go
@@ -59,6 +59,19 @@ var allowedMeasurements = map[string]measurementSpec{
"peer_id": true,
},
},
+ "netbird_sync_phase": {
+ allowedFields: map[string]bool{
+ "duration_seconds": true,
+ },
+ allowedTags: map[string]bool{
+ "deployment_type": true,
+ "version": true,
+ "os": true,
+ "arch": true,
+ "peer_id": true,
+ "phase": true,
+ },
+ },
"netbird_login": {
allowedFields: map[string]bool{
"duration_seconds": true,
diff --git a/client/internal/metrics/metrics.go b/client/internal/metrics/metrics.go
index 4ebb43496..f18082995 100644
--- a/client/internal/metrics/metrics.go
+++ b/client/internal/metrics/metrics.go
@@ -56,6 +56,9 @@ type metricsImplementation interface {
// RecordSyncDuration records how long it took to process a sync message
RecordSyncDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration)
+ // RecordSyncPhase records how long a single sub-phase of sync processing took
+ RecordSyncPhase(ctx context.Context, agentInfo AgentInfo, phase string, duration time.Duration)
+
// RecordLoginDuration records how long the login to management took
RecordLoginDuration(ctx context.Context, agentInfo AgentInfo, duration time.Duration, success bool)
@@ -127,6 +130,18 @@ func (c *ClientMetrics) RecordSyncDuration(ctx context.Context, duration time.Du
c.impl.RecordSyncDuration(ctx, agentInfo, duration)
}
+// RecordSyncPhase records the duration of a single sub-phase of sync processing
+func (c *ClientMetrics) RecordSyncPhase(ctx context.Context, phase string, duration time.Duration) {
+ if c == nil {
+ return
+ }
+ c.mu.RLock()
+ agentInfo := c.agentInfo
+ c.mu.RUnlock()
+
+ c.impl.RecordSyncPhase(ctx, agentInfo, phase, duration)
+}
+
// RecordLoginDuration records how long the login to management server took
func (c *ClientMetrics) RecordLoginDuration(ctx context.Context, duration time.Duration, success bool) {
if c == nil {
diff --git a/client/internal/metrics/push_test.go b/client/internal/metrics/push_test.go
index 20a509da1..43c1b2c06 100644
--- a/client/internal/metrics/push_test.go
+++ b/client/internal/metrics/push_test.go
@@ -70,6 +70,9 @@ func (m *mockMetrics) RecordConnectionStages(_ context.Context, _ AgentInfo, _ s
func (m *mockMetrics) RecordSyncDuration(_ context.Context, _ AgentInfo, _ time.Duration) {
}
+func (m *mockMetrics) RecordSyncPhase(_ context.Context, _ AgentInfo, _ string, _ time.Duration) {
+}
+
func (m *mockMetrics) RecordLoginDuration(_ context.Context, _ AgentInfo, _ time.Duration, _ bool) {
}
From deff8af59f13222d245abfdfc7e2e700c74dd8c7 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Mon, 29 Jun 2026 11:24:25 +0200
Subject: [PATCH 13/20] [client] Wait for signal receive watchdog to stop
before reconnect (#6574)
* [client] Wait for signal receive watchdog to stop before reconnect
The per-stream watchReceiveStream goroutine was started fire-and-forget
and never joined. On reconnect a lingering watchdog could still flip
shared client state (receiveStalled, the disconnect notifier) on the
freshly established stream, since cancelStream only cancels its own
stream context.
Track the watchdog with a WaitGroup and wait for it to exit (after
cancelling its stream) before the operation returns, so each reconnect
starts with no stale watchdog.
* [client] Bind signal receive probe to the stream context
The watchdog probe reused the generic Send, which derives its per-attempt
timeouts from the long-lived client context, so cancelStream could not
interrupt an in-flight probe. After joining the watchdog on reconnect,
watchdogWg.Wait() could then block for the full send-attempt chain.
Split Send into a context-aware send and pass the stream context down
through sendReceiveProbe, so cancelStream aborts any in-flight probe and
the watchdog exits promptly.
---
shared/signal/client/grpc.go | 30 ++++++++++++++++++++-------
shared/signal/client/watchdog_test.go | 2 +-
2 files changed, 24 insertions(+), 8 deletions(-)
diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go
index 611ab0c45..7e8e551b6 100644
--- a/shared/signal/client/grpc.go
+++ b/shared/signal/client/grpc.go
@@ -85,6 +85,7 @@ type GrpcClient struct {
// receive backpressure as a dead stream: reconnecting cannot help, since the
// new stream feeds the same worker, and only triggers a reconnect storm.
receiveHandoffBlocked atomic.Bool
+ watchdogWg sync.WaitGroup
}
// NewClient creates a new Signal client
@@ -200,10 +201,18 @@ func (c *GrpcClient) Receive(ctx context.Context, msgHandler func(msg *proto.Mes
// Guard the receive direction: the transport can stay healthy while the
// server stops delivering messages. The watchdog reconnects via cancelStream.
c.markReceived()
- go c.watchReceiveStream(streamCtx, cancelStream)
+ c.watchdogWg.Add(1)
+ go func() {
+ defer c.watchdogWg.Done()
+ c.watchReceiveStream(streamCtx, cancelStream)
+ }()
// start receiving messages from the Signal stream (from other peers through signal)
err = c.receive(stream)
+
+ cancelStream()
+ c.watchdogWg.Wait()
+
if err != nil {
// Check the parent context, not streamCtx: a watchdog-triggered
// cancelStream must reconnect, only a parent cancel is shutdown.
@@ -400,7 +409,12 @@ func (c *GrpcClient) encryptMessage(msg *proto.Message) (*proto.EncryptedMessage
// Send sends a message to the remote Peer through the Signal Exchange.
func (c *GrpcClient) Send(msg *proto.Message) error {
+ return c.send(c.ctx, msg)
+}
+// send delivers a message deriving per-attempt timeouts from parentCtx, so a
+// caller can abort an in-flight send by cancelling that context.
+func (c *GrpcClient) send(parentCtx context.Context, msg *proto.Message) error {
if !c.Ready() {
return fmt.Errorf("no connection to signal")
}
@@ -416,7 +430,7 @@ func (c *GrpcClient) Send(msg *proto.Message) error {
if attempt > 1 {
attemptTimeout = time.Duration(attempt) * 5 * time.Second
}
- ctx, cancel := context.WithTimeout(c.ctx, attemptTimeout)
+ ctx, cancel := context.WithTimeout(parentCtx, attemptTimeout)
_, err = c.realClient.Send(ctx, encryptedMessage)
@@ -486,7 +500,7 @@ func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream contex
}
if probeSentAt.IsZero() {
- if err := c.sendReceiveProbe(); err != nil {
+ if err := c.sendReceiveProbe(ctx); err != nil {
log.Debugf("failed to send signal receive probe: %v", err)
}
probeSentAt = time.Now()
@@ -495,11 +509,13 @@ func (c *GrpcClient) watchReceiveStream(ctx context.Context, cancelStream contex
}
}
-// sendReceiveProbe sends a self-addressed heartbeat. The Signal server routes it
-// back to this client, exercising the exact receive path the watchdog guards.
-func (c *GrpcClient) sendReceiveProbe() error {
+// sendReceiveProbe sends a self-addressed heartbeat bound to ctx, so cancelStream
+// aborts an in-flight probe instead of leaving the watchdog blocked on send timeouts.
+// The Signal server routes it back to this client, exercising the exact receive
+// path the watchdog guards.
+func (c *GrpcClient) sendReceiveProbe(ctx context.Context) error {
self := c.key.PublicKey().String()
- return c.Send(&proto.Message{
+ return c.send(ctx, &proto.Message{
Key: self,
RemoteKey: self,
Body: &proto.Body{Type: proto.Body_HEARTBEAT},
diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go
index bc6b5520b..eeb9aec30 100644
--- a/shared/signal/client/watchdog_test.go
+++ b/shared/signal/client/watchdog_test.go
@@ -74,7 +74,7 @@ func TestReceiveProbeRoundTrips(t *testing.T) {
t.Fatal("signal stream did not connect within timeout")
}
- require.NoError(t, client.sendReceiveProbe())
+ require.NoError(t, client.sendReceiveProbe(ctx))
select {
case <-received:
From 0b594c639a75dc96af7893e85d87729966eec681 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Mon, 29 Jun 2026 11:28:58 +0200
Subject: [PATCH 14/20] [client] report management unhealthy while Sync stream
is failing (#6575)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix(mgm): report management unhealthy while Sync stream is failing
The health probe (IsHealthy) only checked the gRPC transport and a
GetServerKey call. GetServerKey succeeds even when the peer cannot sync
(e.g. the server returns "settings not found"), so the probe kept marking
management Connected while the Sync stream failed in a tight retry loop —
pinning the status to "Connected" forever despite no sync ever succeeding.
Track the last Sync stream error and have IsHealthy consult it, so a
healthy transport is no longer enough to report the connection healthy.
* fix(mgm): record disconnected state when sync stream setup fails
The connectToSyncStream failure path in handleSyncStream returned early
without updating syncStreamErr, so the client could still report healthy
even when stream setup failed. Mirror the receiveUpdatesEvents error path
by calling notifyDisconnected and setSyncStreamDisconnected.
---
shared/management/client/grpc.go | 37 ++++++++++++++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/shared/management/client/grpc.go b/shared/management/client/grpc.go
index 016cde68a..6f5172376 100644
--- a/shared/management/client/grpc.go
+++ b/shared/management/client/grpc.go
@@ -55,6 +55,14 @@ type GrpcClient struct {
connStateCallback ConnStateNotifier
connStateCallbackLock sync.RWMutex
serverURL string
+
+ // syncStreamErr holds the last Sync stream error, or nil while the stream
+ // is established and healthy. GetServerKey succeeds even when the peer
+ // cannot sync (e.g. the server returns "settings not found"), so the
+ // health probe must consult this to avoid reporting a healthy management
+ // connection while the Sync stream keeps failing.
+ syncStreamMu sync.RWMutex
+ syncStreamErr error
}
type ExposeRequest struct {
@@ -364,6 +372,8 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
stream, err := c.connectToSyncStream(ctx, serverPubKey, sysInfo)
if err != nil {
log.Debugf("failed to open Management Service stream: %s", err)
+ c.notifyDisconnected(err)
+ c.setSyncStreamDisconnected(err)
if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied {
return backoff.Permanent(err) // unrecoverable error, propagate to the upper layer
}
@@ -372,11 +382,13 @@ func (c *GrpcClient) handleSyncStream(ctx context.Context, serverPubKey wgtypes.
log.Infof("connected to the Management Service stream")
c.notifyConnected()
+ c.setSyncStreamConnected()
// blocking until error
err = c.receiveUpdatesEvents(stream, serverPubKey, msgHandler)
if err != nil {
c.notifyDisconnected(err)
+ c.setSyncStreamDisconnected(err)
if ctx.Err() != nil {
log.Debugf("management connection context has been canceled, this usually indicates shutdown")
return nil
@@ -530,6 +542,13 @@ func (c *GrpcClient) IsHealthy() bool {
log.Warnf("health check returned: %s", err)
return false
}
+
+ if syncErr := c.syncStreamError(); syncErr != nil {
+ c.notifyDisconnected(syncErr)
+ log.Warnf("management transport is up but the Sync stream is unhealthy: %s", syncErr)
+ return false
+ }
+
c.notifyConnected()
return true
}
@@ -771,6 +790,24 @@ func (c *GrpcClient) SyncMeta(sysInfo *system.Info) error {
return err
}
+func (c *GrpcClient) setSyncStreamConnected() {
+ c.syncStreamMu.Lock()
+ defer c.syncStreamMu.Unlock()
+ c.syncStreamErr = nil
+}
+
+func (c *GrpcClient) setSyncStreamDisconnected(err error) {
+ c.syncStreamMu.Lock()
+ defer c.syncStreamMu.Unlock()
+ c.syncStreamErr = err
+}
+
+func (c *GrpcClient) syncStreamError() error {
+ c.syncStreamMu.RLock()
+ defer c.syncStreamMu.RUnlock()
+ return c.syncStreamErr
+}
+
func (c *GrpcClient) notifyDisconnected(err error) {
c.connStateCallbackLock.RLock()
defer c.connStateCallbackLock.RUnlock()
From b434cda0627a01538bf977f89b8e4834d95d2fed Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Mon, 29 Jun 2026 19:16:47 +0900
Subject: [PATCH 15/20] [client] Refresh signal receive liveness when worker
handoff drains (#6594)
---
shared/signal/client/grpc.go | 3 ++
shared/signal/client/watchdog_test.go | 70 +++++++++++++++++++++++++++
2 files changed, 73 insertions(+)
diff --git a/shared/signal/client/grpc.go b/shared/signal/client/grpc.go
index 7e8e551b6..a07867263 100644
--- a/shared/signal/client/grpc.go
+++ b/shared/signal/client/grpc.go
@@ -557,6 +557,9 @@ func (c *GrpcClient) receive(stream proto.SignalExchange_ConnectStreamClient) er
if err := c.decryptionWorker.AddMsg(c.ctx, msg); err != nil {
log.Errorf("failed to add message to decryption worker: %v", err)
}
+ // Refresh liveness before clearing the flag so the window between here and
+ // the next Recv does not read a stale timestamp as a dead stream.
+ c.markReceived()
c.receiveHandoffBlocked.Store(false)
}
}
diff --git a/shared/signal/client/watchdog_test.go b/shared/signal/client/watchdog_test.go
index eeb9aec30..a8bbafa29 100644
--- a/shared/signal/client/watchdog_test.go
+++ b/shared/signal/client/watchdog_test.go
@@ -2,6 +2,7 @@ package client
import (
"context"
+ "io"
"net"
"testing"
"time"
@@ -106,3 +107,72 @@ func TestReceiveAliveTreatsHandoffBlockAsLiveness(t *testing.T) {
c.markReceived()
require.True(t, c.receiveAlive(), "a freshly received frame must keep the stream alive")
}
+
+// fakeRecvStream feeds the receive loop frames from a channel and reports EOF
+// once the channel is closed. Only Recv is exercised by the loop.
+type fakeRecvStream struct {
+ sigProto.SignalExchange_ConnectStreamClient
+ frames chan *sigProto.EncryptedMessage
+}
+
+func (s *fakeRecvStream) Recv() (*sigProto.EncryptedMessage, error) {
+ msg, ok := <-s.frames
+ if !ok {
+ return nil, io.EOF
+ }
+ return msg, nil
+}
+
+// TestReceiveLoopRefreshesLivenessAfterBlockedHandoff drives the real receive
+// loop into a handoff that blocks past the inactivity threshold, then checks the
+// window after the handoff drains but before the next Recv. The loop must have
+// refreshed the timestamp on unblocking, otherwise that window reads the stale
+// pre-handoff timestamp as a dead stream and the watchdog tears down a healthy
+// connection.
+func TestReceiveLoopRefreshesLivenessAfterBlockedHandoff(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ t.Cleanup(cancel)
+ c := &GrpcClient{ctx: ctx}
+
+ handling := make(chan struct{}, 8)
+ gate := make(chan struct{})
+ decrypt := func(*sigProto.EncryptedMessage) (*sigProto.Message, error) { return &sigProto.Message{}, nil }
+ handler := func(*sigProto.Message) error {
+ handling <- struct{}{}
+ <-gate
+ return nil
+ }
+ c.decryptionWorker = NewWorker(decrypt, handler)
+ workerCtx, workerCancel := context.WithCancel(context.Background())
+ go c.decryptionWorker.Work(workerCtx)
+ t.Cleanup(workerCancel)
+
+ frames := make(chan *sigProto.EncryptedMessage)
+ t.Cleanup(func() { close(frames) })
+ go func() { _ = c.receive(&fakeRecvStream{frames: frames}) }()
+
+ // First frame: the worker drains it and parks in the blocking handler.
+ frames <- &sigProto.EncryptedMessage{}
+ <-handling
+ // Second frame fills the worker's single-slot pool.
+ frames <- &sigProto.EncryptedMessage{}
+ // Third frame: the pool is full, so the loop parks on the handoff.
+ frames <- &sigProto.EncryptedMessage{}
+
+ require.Eventually(t, c.receiveHandoffBlocked.Load, time.Second, time.Millisecond,
+ "receive loop should park on the worker handoff")
+
+ // Simulate the handoff having blocked past the inactivity threshold.
+ c.lastReceived.Store(time.Now().Add(-2 * receiveInactivityThreshold).UnixNano())
+ require.True(t, c.receiveAlive(), "a loop parked on the handoff must stay alive")
+
+ // Drain the worker so the handoff returns and the loop resumes reading.
+ close(gate)
+
+ // Once the handoff clears, the loop is parked on the next Recv with no frame
+ // pending. The stream must still read as alive in that window.
+ require.Eventually(t, func() bool { return !c.receiveHandoffBlocked.Load() }, time.Second, time.Millisecond,
+ "handoff should drain once the worker is released")
+ require.True(t, c.receiveAlive(),
+ "the loop must refresh liveness when the handoff drains, before the next Recv")
+}
From 3f1fb3b52d010d07247c46bf2a9fbf483563b0a9 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Mon, 29 Jun 2026 19:51:25 +0200
Subject: [PATCH 16/20] [ingest] raise duration validation limit to 24 hours
(#6598)
Peer connection timing fields (signaling_to_connection_seconds) can
legitimately exceed 5 minutes during long reconnections; the previous
300 s cap caused valid data points to be rejected.
---
client/internal/metrics/infra/ingest/main.go | 2 +-
client/internal/metrics/infra/ingest/main_test.go | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/client/internal/metrics/infra/ingest/main.go b/client/internal/metrics/infra/ingest/main.go
index 623a17e4d..91405b85f 100644
--- a/client/internal/metrics/infra/ingest/main.go
+++ b/client/internal/metrics/infra/ingest/main.go
@@ -19,7 +19,7 @@ const (
defaultListenAddr = ":8087"
defaultInfluxDBURL = "http://influxdb:8086/api/v2/write?org=netbird&bucket=metrics&precision=ns"
maxBodySize = 50 * 1024 * 1024 // 50 MB max request body
- maxDurationSeconds = 300.0 // reject any duration field > 5 minutes
+ maxDurationSeconds = 86400.0 // reject any duration field > 24 hours
peerIDLength = 16 // truncated SHA-256: 8 bytes = 16 hex chars
maxTagValueLength = 64 // reject tag values longer than this
)
diff --git a/client/internal/metrics/infra/ingest/main_test.go b/client/internal/metrics/infra/ingest/main_test.go
index bacaa4588..96287813e 100644
--- a/client/internal/metrics/infra/ingest/main_test.go
+++ b/client/internal/metrics/infra/ingest/main_test.go
@@ -53,14 +53,14 @@ func TestValidateLine_NegativeValue(t *testing.T) {
}
func TestValidateLine_DurationTooLarge(t *testing.T) {
- line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=999 1234567890`
+ line := `netbird_sync,deployment_type=cloud,version=1.0.0,os=linux,arch=amd64,peer_id=abc duration_seconds=100000 1234567890`
err := validateLine(line)
require.Error(t, err)
assert.Contains(t, err.Error(), "too large")
}
func TestValidateLine_TotalSecondsTooLarge(t *testing.T) {
- line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=500 1234567890`
+ line := `netbird_peer_connection,deployment_type=cloud,connection_type=ice,attempt_type=initial,version=1.0.0,os=linux,arch=amd64,peer_id=abc,connection_pair_id=pair total_seconds=100000 1234567890`
err := validateLine(line)
require.Error(t, err)
assert.Contains(t, err.Error(), "too large")
From 04c3d19032dd1f3361168e0d1fc706b1e6cbfb42 Mon Sep 17 00:00:00 2001
From: Zoltan Papp
Date: Mon, 29 Jun 2026 19:51:50 +0200
Subject: [PATCH 17/20] [client] Skip firewall ruleset rebuild when config is
unchanged (#6508)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* [client] Skip firewall ruleset rebuild when config is unchanged
ApplyFiltering rebuilt every peer and route ACL and flushed the firewall
on every sync, with no guard for an unchanged configuration. Management
re-sends the same network map far more often than it actually changes
(account-wide updates, peer meta churn), so on busy accounts this is the
dominant client-side cost of redundant syncs — especially with a large
route set and a userspace firewall.
Hash the inputs ApplyFiltering consumes (peer rules, route rules, the
empty flag and the dns-route feature flag) and skip the rebuild + flush
when the hash matches the last successfully applied update. Mirrors the
guard the DNS server already uses (previousConfigHash). The hash is only
recorded after apply and flush both succeed, so a failed update is not
skipped on the next (possibly identical) sync and gets a chance to
reconcile the firewall state.
* [client] Include config hash in ACL skip debug log
* [client] Include RoutesFirewallRulesIsEmpty in firewall config hash
* [client] Add benchmarks for firewall config hash computation
---
client/internal/acl/manager.go | 74 ++++++++++++--
client/internal/acl/manager_test.go | 147 ++++++++++++++++++++++++++++
2 files changed, 212 insertions(+), 9 deletions(-)
diff --git a/client/internal/acl/manager.go b/client/internal/acl/manager.go
index c54a3e897..d9b179457 100644
--- a/client/internal/acl/manager.go
+++ b/client/internal/acl/manager.go
@@ -11,6 +11,7 @@ import (
"time"
"github.com/hashicorp/go-multierror"
+ "github.com/mitchellh/hashstructure/v2"
log "github.com/sirupsen/logrus"
nberrors "github.com/netbirdio/netbird/client/errors"
@@ -30,11 +31,13 @@ type Manager interface {
// DefaultManager uses firewall manager to handle
type DefaultManager struct {
- firewall firewall.Manager
- ipsetCounter int
- peerRulesPairs map[id.RuleID][]firewall.Rule
- routeRules map[id.RuleID]struct{}
- mutex sync.Mutex
+ firewall firewall.Manager
+ ipsetCounter int
+ peerRulesPairs map[id.RuleID][]firewall.Rule
+ routeRules map[id.RuleID]struct{}
+ previousConfigHash uint64
+ hasAppliedConfig bool
+ mutex sync.Mutex
}
func NewDefaultManager(fm firewall.Manager) *DefaultManager {
@@ -57,6 +60,23 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
return
}
+ // Skip the full rebuild + flush when the inputs that drive the firewall
+ // state are byte-for-byte identical to the last successfully applied
+ // update. Management re-sends the same network map far more often than it
+ // actually changes (account-wide updates, peer meta churn), and rebuilding
+ // every peer/route ACL and flushing the firewall on every such sync is the
+ // dominant client-side cost when nothing changed. Mirrors the same guard the
+ // DNS server already uses (previousConfigHash). Only the fields ApplyFiltering
+ // consumes participate in the hash, so an unrelated map change cannot mask a
+ // real ACL change.
+ hash, err := d.firewallConfigHash(networkMap, dnsRouteFeatureFlag)
+ if err != nil {
+ log.Errorf("unable to hash firewall configuration, applying unconditionally: %v", err)
+ } else if d.hasAppliedConfig && d.previousConfigHash == hash {
+ log.Debugf("not applying the firewall configuration update as there is nothing new (hash: %d)", hash)
+ return
+ }
+
start := time.Now()
defer func() {
total := 0
@@ -70,13 +90,49 @@ func (d *DefaultManager) ApplyFiltering(networkMap *mgmProto.NetworkMap, dnsRout
d.applyPeerACLs(networkMap)
- if err := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag); err != nil {
- log.Errorf("Failed to apply route ACLs: %v", err)
+ routeErr := d.applyRouteACLs(networkMap.RoutesFirewallRules, dnsRouteFeatureFlag)
+ if routeErr != nil {
+ log.Errorf("Failed to apply route ACLs: %v", routeErr)
}
- if err := d.firewall.Flush(); err != nil {
- log.Error("failed to flush firewall rules: ", err)
+ flushErr := d.firewall.Flush()
+ if flushErr != nil {
+ log.Error("failed to flush firewall rules: ", flushErr)
}
+
+ // Only remember the hash once the firewall actually reflects this config.
+ // If applying or flushing failed, leave the previous hash untouched so the
+ // next (possibly identical) update is not skipped and gets a chance to
+ // reconcile the firewall state.
+ if err == nil && routeErr == nil && flushErr == nil {
+ d.previousConfigHash = hash
+ d.hasAppliedConfig = true
+ } else {
+ d.hasAppliedConfig = false
+ }
+}
+
+// firewallConfigHash hashes exactly the inputs ApplyFiltering uses to build the
+// firewall state, so an identical hash means an identical resulting ruleset.
+func (d *DefaultManager) firewallConfigHash(networkMap *mgmProto.NetworkMap, dnsRouteFeatureFlag bool) (uint64, error) {
+ return hashstructure.Hash(struct {
+ PeerRules []*mgmProto.FirewallRule
+ PeerRulesIsEmpty bool
+ RouteRules []*mgmProto.RouteFirewallRule
+ RouteRulesIsEmpty bool
+ DNSRouteFeatureFlag bool
+ }{
+ PeerRules: networkMap.GetFirewallRules(),
+ PeerRulesIsEmpty: networkMap.GetFirewallRulesIsEmpty(),
+ RouteRules: networkMap.GetRoutesFirewallRules(),
+ RouteRulesIsEmpty: networkMap.GetRoutesFirewallRulesIsEmpty(),
+ DNSRouteFeatureFlag: dnsRouteFeatureFlag,
+ }, hashstructure.FormatV2, &hashstructure.HashOptions{
+ ZeroNil: true,
+ IgnoreZeroValue: true,
+ SlicesAsSets: true,
+ UseStringer: true,
+ })
}
func (d *DefaultManager) applyPeerACLs(networkMap *mgmProto.NetworkMap) {
diff --git a/client/internal/acl/manager_test.go b/client/internal/acl/manager_test.go
index 408ed992f..968654ae9 100644
--- a/client/internal/acl/manager_test.go
+++ b/client/internal/acl/manager_test.go
@@ -1,6 +1,7 @@
package acl
import (
+ "fmt"
"net/netip"
"testing"
@@ -485,3 +486,149 @@ func TestPortInfoEmpty(t *testing.T) {
})
}
}
+
+// TestApplyFilteringSkipsUnchangedConfig verifies that an identical network map
+// re-applied is recognized as a no-op (hash unchanged), while a real change to
+// any firewall-relevant input forces a re-apply (hash changes). This is the
+// guard that prevents a full ruleset rebuild + flush on every redundant sync.
+func TestApplyFilteringSkipsUnchangedConfig(t *testing.T) {
+ t.Setenv("NB_WG_KERNEL_DISABLED", "true")
+ t.Setenv(firewall.EnvForceUserspaceFirewall, "true")
+
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+
+ ifaceMock := mocks.NewMockIFaceMapper(ctrl)
+ ifaceMock.EXPECT().IsUserspaceBind().Return(true).AnyTimes()
+ ifaceMock.EXPECT().SetFilter(gomock.Any())
+ network := netip.MustParsePrefix("172.0.0.1/32")
+ ifaceMock.EXPECT().Name().Return("lo").AnyTimes()
+ ifaceMock.EXPECT().Address().Return(wgaddr.Address{
+ IP: network.Addr(),
+ Network: network,
+ }).AnyTimes()
+ ifaceMock.EXPECT().GetWGDevice().Return(nil).AnyTimes()
+
+ fw, err := firewall.NewFirewall(ifaceMock, nil, flowLogger, false, iface.DefaultMTU)
+ require.NoError(t, err)
+ defer func() {
+ require.NoError(t, fw.Close(nil))
+ }()
+
+ acl := NewDefaultManager(fw)
+
+ networkMap := &mgmProto.NetworkMap{
+ FirewallRules: []*mgmProto.FirewallRule{
+ {
+ PeerIP: "10.93.0.1",
+ Direction: mgmProto.RuleDirection_IN,
+ Action: mgmProto.RuleAction_ACCEPT,
+ Protocol: mgmProto.RuleProtocol_TCP,
+ Port: "22",
+ },
+ },
+ FirewallRulesIsEmpty: false,
+ }
+
+ acl.ApplyFiltering(networkMap, false)
+ require.True(t, acl.hasAppliedConfig, "config should be marked applied after first apply")
+ firstHash := acl.previousConfigHash
+ require.NotZero(t, firstHash)
+
+ // Re-applying the identical map must not change the recorded hash: the
+ // expensive rebuild path was skipped.
+ acl.ApplyFiltering(networkMap, false)
+ assert.Equal(t, firstHash, acl.previousConfigHash,
+ "identical re-apply must be a no-op (hash unchanged)")
+
+ // A real change must produce a different hash and re-apply.
+ networkMap.FirewallRules[0].Action = mgmProto.RuleAction_DROP
+ acl.ApplyFiltering(networkMap, false)
+ assert.NotEqual(t, firstHash, acl.previousConfigHash,
+ "changing a rule's action must force a re-apply (hash changed)")
+
+ // The dnsRouteFeatureFlag also participates in the hash.
+ changedHash := acl.previousConfigHash
+ acl.ApplyFiltering(networkMap, true)
+ assert.NotEqual(t, changedHash, acl.previousConfigHash,
+ "flipping dnsRouteFeatureFlag must force a re-apply (hash changed)")
+}
+
+func buildNetworkMap(peerRules, routeRules int) *mgmProto.NetworkMap {
+ nm := &mgmProto.NetworkMap{
+ FirewallRulesIsEmpty: peerRules == 0,
+ RoutesFirewallRulesIsEmpty: routeRules == 0,
+ }
+ for i := range peerRules {
+ nm.FirewallRules = append(nm.FirewallRules, &mgmProto.FirewallRule{
+ PeerIP: fmt.Sprintf("10.%d.%d.%d", i>>16&0xff, i>>8&0xff, i&0xff),
+ Direction: mgmProto.RuleDirection_IN,
+ Action: mgmProto.RuleAction_ACCEPT,
+ Protocol: mgmProto.RuleProtocol_TCP,
+ Port: fmt.Sprintf("%d", 1024+i%64511),
+ })
+ }
+ for i := range routeRules {
+ nm.RoutesFirewallRules = append(nm.RoutesFirewallRules, &mgmProto.RouteFirewallRule{
+ Destination: fmt.Sprintf("192.168.%d.0/24", i%256),
+ SourceRanges: []string{fmt.Sprintf("10.0.%d.0/24", i%256)},
+ Action: mgmProto.RuleAction_ACCEPT,
+ Protocol: mgmProto.RuleProtocol_ALL,
+ })
+ }
+ return nm
+}
+
+func BenchmarkFirewallConfigHash_Small(b *testing.B) {
+ d := &DefaultManager{}
+ nm := buildNetworkMap(10, 5)
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = d.firewallConfigHash(nm, false)
+ }
+}
+
+func BenchmarkFirewallConfigHash_Medium(b *testing.B) {
+ d := &DefaultManager{}
+ nm := buildNetworkMap(100, 50)
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = d.firewallConfigHash(nm, false)
+ }
+}
+
+func BenchmarkFirewallConfigHash_Large(b *testing.B) {
+ d := &DefaultManager{}
+ nm := buildNetworkMap(1000, 200)
+ b.ResetTimer()
+ for b.Loop() {
+ _, _ = d.firewallConfigHash(nm, false)
+ }
+}
+
+// TestFirewallConfigHashDeterministic verifies the hash is stable for equal
+// inputs and order-independent for the rule slices (management does not
+// guarantee rule order).
+func TestFirewallConfigHashDeterministic(t *testing.T) {
+ d := &DefaultManager{}
+
+ nm1 := &mgmProto.NetworkMap{
+ FirewallRules: []*mgmProto.FirewallRule{
+ {PeerIP: "10.0.0.1", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_ACCEPT, Protocol: mgmProto.RuleProtocol_TCP, Port: "22"},
+ {PeerIP: "10.0.0.2", Direction: mgmProto.RuleDirection_IN, Action: mgmProto.RuleAction_DROP, Protocol: mgmProto.RuleProtocol_TCP, Port: "80"},
+ },
+ }
+ // Same rules, reversed order.
+ nm2 := &mgmProto.NetworkMap{
+ FirewallRules: []*mgmProto.FirewallRule{
+ nm1.FirewallRules[1],
+ nm1.FirewallRules[0],
+ },
+ }
+
+ h1, err := d.firewallConfigHash(nm1, false)
+ require.NoError(t, err)
+ h2, err := d.firewallConfigHash(nm2, false)
+ require.NoError(t, err)
+ assert.Equal(t, h1, h2, "hash must be order-independent for rule slices")
+}
From 3de889d529a366441788886cfc46970096fd28fd Mon Sep 17 00:00:00 2001
From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com>
Date: Tue, 30 Jun 2026 08:18:51 +0200
Subject: [PATCH 18/20] [client] bound system info / posture-check gathering
with a timeout to prevent sync-loop freeze (#6512)
* Wraps syestem info / posture checks into a goroutine with timeout
e.checks = checks is set before doing the SyncMeta,
so if it fails next time isCheckEquals compares true and bypasses
the update. This is to avoid another repeating the 15 seconds hang.
The checks will be synced on reconnect or posture checks changes
push from mgmt.
* Propagate context to OS calls that can leverage its cancellation / timeout
* Distinguish timeout from cancellation in logs
* Dont log twice
* Block on timeout failure and reapply the exclude_ips
* Refactor for complexity
---
client/internal/engine.go | 59 ++++++++++++++++-------------------
client/system/info.go | 44 +++++++++++++++++++++++++-
client/system/info_android.go | 2 +-
client/system/info_darwin.go | 2 +-
client/system/info_ios.go | 2 +-
client/system/info_js.go | 2 +-
client/system/info_test.go | 15 +++++++++
client/system/process.go | 21 +++++++++----
client/system/process_test.go | 31 ++++++++++++++++--
9 files changed, 133 insertions(+), 45 deletions(-)
diff --git a/client/internal/engine.go b/client/internal/engine.go
index f7c7e1862..de151592d 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -82,6 +82,12 @@ const (
PeerConnectionTimeoutMax = 45000 // ms
PeerConnectionTimeoutMin = 30000 // ms
disableAutoUpdate = "disabled"
+
+ // systemInfoTimeout bounds how long the sync loop waits for system info / posture
+ // check gathering. The gathering runs uncancellable system calls (process scan,
+ // exec, os.Stat); without this bound a single stuck call freezes handleSync, and
+ // thus syncMsgMux, for as long as the call hangs (observed multi-minute freezes).
+ systemInfoTimeout = 15 * time.Second
)
var ErrResetConnection = fmt.Errorf("reset connection")
@@ -1084,11 +1090,22 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
}
e.checks = checks
- info, err := system.GetInfoWithChecks(e.ctx, checks, e.overlayAddresses()...)
- if err != nil {
- log.Warnf("failed to get system info with checks: %v", err)
- info = system.GetInfo(e.ctx)
+ info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, checks, e.overlayAddresses()...)
+ if !ok {
+ // Gathering timed out; skip the meta sync this cycle rather than blocking the
+ // sync loop (and syncMsgMux) on a stuck system call. A later sync will retry.
+ return nil
}
+ e.applyInfoFlags(info)
+
+ if err := e.mgmClient.SyncMeta(info); err != nil {
+ return fmt.Errorf("could not sync meta: error %s", err)
+ }
+ return nil
+}
+
+// applyInfoFlags sets the engine's config-derived feature flags on the gathered system info.
+func (e *Engine) applyInfoFlags(info *system.Info) {
info.SetFlags(
e.config.RosenpassEnabled,
e.config.RosenpassPermissive,
@@ -1107,12 +1124,6 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
e.config.EnableSSHRemotePortForwarding,
e.config.DisableSSHAuth,
)
-
- if err := e.mgmClient.SyncMeta(info); err != nil {
- log.Errorf("could not sync meta: error %s", err)
- return err
- }
- return nil
}
// overlayAddresses returns our own WireGuard overlay address (v4 and v6) so it
@@ -1272,31 +1283,15 @@ func (e *Engine) receiveManagementEvents() {
e.shutdownWg.Add(1)
go func() {
defer e.shutdownWg.Done()
- info, err := system.GetInfoWithChecks(e.ctx, e.checks, e.overlayAddresses()...)
- if err != nil {
- log.Warnf("failed to get system info with checks: %v", err)
+ info, ok := system.GetInfoWithChecksTimeout(e.ctx, systemInfoTimeout, e.checks, e.overlayAddresses()...)
+ if !ok {
+ // Gathering timed out; connect the stream with base info so management
+ // connectivity still comes up rather than blocking here.
info = system.GetInfo(e.ctx)
}
- info.SetFlags(
- e.config.RosenpassEnabled,
- e.config.RosenpassPermissive,
- &e.config.ServerSSHAllowed,
- e.config.DisableClientRoutes,
- e.config.DisableServerRoutes,
- e.config.DisableDNS,
- e.config.DisableFirewall,
- e.config.BlockLANAccess,
- e.config.BlockInbound,
- e.config.DisableIPv6,
- e.config.LazyConnectionEnabled,
- e.config.EnableSSHRoot,
- e.config.EnableSSHSFTP,
- e.config.EnableSSHLocalPortForwarding,
- e.config.EnableSSHRemotePortForwarding,
- e.config.DisableSSHAuth,
- )
+ e.applyInfoFlags(info)
- err = e.mgmClient.Sync(e.ctx, info, e.handleSync)
+ err := e.mgmClient.Sync(e.ctx, info, e.handleSync)
if err != nil {
// happens if management is unavailable for a long time.
// We want to cancel the operation of the whole client
diff --git a/client/system/info.go b/client/system/info.go
index 27588859e..496b478a3 100644
--- a/client/system/info.go
+++ b/client/system/info.go
@@ -2,9 +2,11 @@ package system
import (
"context"
+ "errors"
"net/netip"
"slices"
"strings"
+ "time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/metadata"
@@ -174,7 +176,7 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs .
processCheckPaths = append(processCheckPaths, check.GetFiles()...)
}
- files, err := checkFileAndProcess(processCheckPaths)
+ files, err := checkFileAndProcess(ctx, processCheckPaths)
if err != nil {
return nil, err
}
@@ -187,3 +189,43 @@ func GetInfoWithChecks(ctx context.Context, checks []*proto.Checks, excludeIPs .
log.Debugf("all system information gathered successfully")
return info, nil
}
+
+// GetInfoWithChecksTimeout is GetInfoWithChecks bounded by timeout. Posture-check gathering
+// runs uncancellable system calls (process enumeration, os.Stat), so calling it inline can
+// block the caller for as long as such a call hangs. It runs in a goroutine instead: if it
+// does not return within timeout the caller gets (nil, false) and should proceed with
+// degraded behavior rather than block. On a gathering error it falls back to base GetInfo.
+//
+// The buffered channel lets the abandoned goroutine finish and exit once its blocking call
+// returns, so it does not leak beyond the duration of that call.
+func GetInfoWithChecksTimeout(ctx context.Context, timeout time.Duration, checks []*proto.Checks, excludeIPs ...netip.Addr) (*Info, bool) {
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ infoCh := make(chan *Info, 1)
+ go func() {
+ info, err := GetInfoWithChecks(ctx, checks, excludeIPs...)
+ if err != nil {
+ if ctx.Err() != nil {
+ return
+ }
+ log.Warnf("failed to get system info with checks: %v", err)
+ info = GetInfo(ctx)
+ info.removeAddresses(excludeIPs...)
+ }
+ infoCh <- info
+ }()
+
+ select {
+ case info := <-infoCh:
+ return info, true
+ case <-ctx.Done():
+ if errors.Is(ctx.Err(), context.DeadlineExceeded) {
+ log.Warnf("gathering system info with checks timed out after %s", timeout)
+ } else {
+ // Parent context canceled (e.g. shutdown), not a timeout.
+ log.Warnf("gathering system info with checks canceled: %v", ctx.Err())
+ }
+ return nil, false
+ }
+}
diff --git a/client/system/info_android.go b/client/system/info_android.go
index 794ff15ed..3c71573bb 100644
--- a/client/system/info_android.go
+++ b/client/system/info_android.go
@@ -50,7 +50,7 @@ func GetInfo(ctx context.Context) *Info {
}
// checkFileAndProcess checks if the file path exists and if a process is running at that path.
-func checkFileAndProcess(paths []string) ([]File, error) {
+func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) {
return []File{}, nil
}
diff --git a/client/system/info_darwin.go b/client/system/info_darwin.go
index 4a31920ec..e7bf367f6 100644
--- a/client/system/info_darwin.go
+++ b/client/system/info_darwin.go
@@ -32,7 +32,7 @@ func GetInfo(ctx context.Context) *Info {
sysName := string(bytes.Split(utsname.Sysname[:], []byte{0})[0])
machine := string(bytes.Split(utsname.Machine[:], []byte{0})[0])
release := string(bytes.Split(utsname.Release[:], []byte{0})[0])
- swVersion, err := exec.Command("sw_vers", "-productVersion").Output()
+ swVersion, err := exec.CommandContext(ctx, "sw_vers", "-productVersion").Output()
if err != nil {
log.Warnf("got an error while retrieving macOS version with sw_vers, error: %s. Using darwin version instead.\n", err)
swVersion = []byte(release)
diff --git a/client/system/info_ios.go b/client/system/info_ios.go
index ad42b1edf..1b0c084b3 100644
--- a/client/system/info_ios.go
+++ b/client/system/info_ios.go
@@ -105,7 +105,7 @@ func isDuplicated(addresses []NetworkAddress, addr NetworkAddress) bool {
}
// checkFileAndProcess checks if the file path exists and if a process is running at that path.
-func checkFileAndProcess(paths []string) ([]File, error) {
+func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) {
return []File{}, nil
}
diff --git a/client/system/info_js.go b/client/system/info_js.go
index 994d439a7..f32532881 100644
--- a/client/system/info_js.go
+++ b/client/system/info_js.go
@@ -103,7 +103,7 @@ func collectLocationInfo(info *Info) {
}
}
-func checkFileAndProcess(_ []string) ([]File, error) {
+func checkFileAndProcess(_ context.Context, _ []string) ([]File, error) {
return []File{}, nil
}
diff --git a/client/system/info_test.go b/client/system/info_test.go
index dcda18e61..a7fa02197 100644
--- a/client/system/info_test.go
+++ b/client/system/info_test.go
@@ -4,6 +4,7 @@ import (
"context"
"net/netip"
"testing"
+ "time"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/metadata"
@@ -35,6 +36,20 @@ func Test_CustomHostname(t *testing.T) {
assert.Equal(t, want, got.Hostname)
}
+func TestGetInfoWithChecksTimeout_Success(t *testing.T) {
+ info, ok := GetInfoWithChecksTimeout(context.Background(), 30*time.Second, nil)
+ assert.True(t, ok, "expected gathering to complete within the timeout")
+ assert.NotNil(t, info)
+}
+
+func TestGetInfoWithChecksTimeout_Timeout(t *testing.T) {
+ // A 1ns budget expires before the (real) system-info gathering can finish, so the
+ // caller must get (nil, false) instead of blocking on the in-flight goroutine.
+ info, ok := GetInfoWithChecksTimeout(context.Background(), time.Nanosecond, nil)
+ assert.False(t, ok, "expected timeout to be reported")
+ assert.Nil(t, info)
+}
+
func Test_NetAddresses(t *testing.T) {
addr, err := networkAddresses()
if err != nil {
diff --git a/client/system/process.go b/client/system/process.go
index 87e21eb9d..07f69a212 100644
--- a/client/system/process.go
+++ b/client/system/process.go
@@ -3,24 +3,30 @@
package system
import (
+ "context"
"os"
"slices"
"github.com/shirou/gopsutil/v3/process"
)
-// getRunningProcesses returns a list of running process paths.
-func getRunningProcesses() ([]string, error) {
- processIDs, err := process.Pids()
+// getRunningProcesses returns a list of running process paths. The context bounds the work:
+// the per-PID loop bails as soon as ctx is done, and the gopsutil calls honor it where they
+// can, so a stuck enumeration cannot run unbounded.
+func getRunningProcesses(ctx context.Context) ([]string, error) {
+ processIDs, err := process.PidsWithContext(ctx)
if err != nil {
return nil, err
}
processMap := make(map[string]bool)
for _, pID := range processIDs {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
p := &process.Process{Pid: pID}
- path, _ := p.Exe()
+ path, _ := p.ExeWithContext(ctx)
if path != "" {
processMap[path] = false
}
@@ -35,18 +41,21 @@ func getRunningProcesses() ([]string, error) {
}
// checkFileAndProcess checks if the file path exists and if a process is running at that path.
-func checkFileAndProcess(paths []string) ([]File, error) {
+func checkFileAndProcess(ctx context.Context, paths []string) ([]File, error) {
files := make([]File, len(paths))
if len(paths) == 0 {
return files, nil
}
- runningProcesses, err := getRunningProcesses()
+ runningProcesses, err := getRunningProcesses(ctx)
if err != nil {
return nil, err
}
for i, path := range paths {
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
file := File{Path: path}
_, err := os.Stat(path)
diff --git a/client/system/process_test.go b/client/system/process_test.go
index 505808a9e..44a1c8ba0 100644
--- a/client/system/process_test.go
+++ b/client/system/process_test.go
@@ -1,6 +1,7 @@
package system
import (
+ "context"
"testing"
"github.com/shirou/gopsutil/v3/process"
@@ -9,7 +10,7 @@ import (
func Benchmark_getRunningProcesses(b *testing.B) {
b.Run("getRunningProcesses new", func(b *testing.B) {
for i := 0; i < b.N; i++ {
- ps, err := getRunningProcesses()
+ ps, err := getRunningProcesses(context.Background())
if err != nil {
b.Fatalf("unexpected error: %v", err)
}
@@ -29,12 +30,38 @@ func Benchmark_getRunningProcesses(b *testing.B) {
}
}
})
- s, _ := getRunningProcesses()
+ s, _ := getRunningProcesses(context.Background())
b.Logf("getRunningProcesses returned %d processes", len(s))
s, _ = getRunningProcessesOld()
b.Logf("getRunningProcessesOld returned %d processes", len(s))
}
+func TestCheckFileAndProcess_ContextCanceled(t *testing.T) {
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ // With a canceled context and non-empty paths the gathering must bail with an error
+ // instead of running the (potentially blocking) process scan / stat loop.
+ if _, err := checkFileAndProcess(ctx, []string{"/does/not/exist"}); err == nil {
+ t.Fatal("expected error on canceled context, got nil")
+ }
+}
+
+func TestCheckFileAndProcess_EmptyPaths(t *testing.T) {
+ // No check paths means no work to do: it must return immediately with no error,
+ // even on a canceled context (nothing to scan or stat).
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+
+ files, err := checkFileAndProcess(ctx, nil)
+ if err != nil {
+ t.Fatalf("unexpected error for empty paths: %v", err)
+ }
+ if len(files) != 0 {
+ t.Fatalf("expected no files, got %d", len(files))
+ }
+}
+
func getRunningProcessesOld() ([]string, error) {
processes, err := process.Processes()
if err != nil {
From 5b5f11740acdc82d8c7b80b314194bbb7420988a Mon Sep 17 00:00:00 2001
From: Bethuel Mmbaga
Date: Tue, 30 Jun 2026 11:34:23 +0300
Subject: [PATCH 19/20] [misc] Require on-premise EULA acceptance in enterprise
scripts (#6596)
---
.../getting-started-enterprise.sh | 47 +++++++++++++++++++
infrastructure_files/migrate-to-enterprise.sh | 46 ++++++++++++++++++
2 files changed, 93 insertions(+)
diff --git a/infrastructure_files/getting-started-enterprise.sh b/infrastructure_files/getting-started-enterprise.sh
index 5d2341cbe..135440180 100755
--- a/infrastructure_files/getting-started-enterprise.sh
+++ b/infrastructure_files/getting-started-enterprise.sh
@@ -9,6 +9,8 @@ set -o pipefail
SED_STRIP_PADDING='s/=//g'
+NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
+
check_docker_compose() {
if command -v docker-compose &> /dev/null; then
echo "docker-compose"
@@ -139,6 +141,43 @@ read_yes_no() {
esac
}
+# Gate the install on explicit acceptance of the NetBird On-Premise EULA.
+require_eula_acceptance() {
+ cat > /dev/stderr < /dev/stderr
+ return 0
+ fi
+
+ local ans=""
+ echo -n 'Type "accept" to agree, or anything else to abort: ' > /dev/stderr
+ read -r ans < /dev/tty
+ if [[ "$ans" != "accept" ]]; then
+ echo "" > /dev/stderr
+ echo "EULA not accepted. Aborting installation." > /dev/stderr
+ exit 1
+ fi
+ echo "" > /dev/stderr
+}
+
wait_postgres() {
set +e
echo -n "Waiting for postgres to become ready"
@@ -174,6 +213,9 @@ init_environment() {
exit 1
fi
+ require_eula_acceptance
+ NETBIRD_EULA_ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+
echo "NetBird Enterprise bootstrap"
echo ""
echo "Traffic flow:"
@@ -260,6 +302,11 @@ render_env() {
# Generated by getting-started-enterprise.sh
# Holds all configuration and secrets for the stack. Mode 600.
+# NetBird On-Premise EULA acceptance
+NETBIRD_EULA_ACCEPTED=yes
+NETBIRD_EULA_ACCEPTED_AT=${NETBIRD_EULA_ACCEPTED_AT}
+NETBIRD_EULA_URL=${NETBIRD_EULA_URL}
+
# Features (set by the script; don't edit without re-running)
NETBIRD_TRAFFIC_FLOW_ENABLED=${NETBIRD_TRAFFIC_FLOW}
diff --git a/infrastructure_files/migrate-to-enterprise.sh b/infrastructure_files/migrate-to-enterprise.sh
index e8a3ad515..8e8a41114 100755
--- a/infrastructure_files/migrate-to-enterprise.sh
+++ b/infrastructure_files/migrate-to-enterprise.sh
@@ -25,6 +25,8 @@ set -o pipefail
OVERRIDE_FILE="docker-compose.override.yml"
ENTERPRISE_CONFIG_FILE="config.yaml.enterprise"
+NETBIRD_EULA_URL="https://netbird.io/self-hosted-EULA"
+
check_docker_compose() {
if command -v docker-compose &> /dev/null; then
echo "docker-compose"
@@ -115,6 +117,43 @@ read_yes_no() {
esac
}
+# Gate the migration on explicit acceptance of the NetBird On-Premise EULA.
+require_eula_acceptance() {
+ cat > /dev/stderr < /dev/stderr
+ return 0
+ fi
+
+ local ans=""
+ echo -n 'Type "accept" to agree, or anything else to abort: ' > /dev/stderr
+ read -r ans < /dev/tty
+ if [[ "$ans" != "accept" ]]; then
+ echo "" > /dev/stderr
+ echo "EULA not accepted. Aborting migration." > /dev/stderr
+ exit 1
+ fi
+ echo "" > /dev/stderr
+}
+
# ---------------------------------------------------------------------------
# Detection — read the operator's existing compose to find service names and
# paths we need to override. Bail loudly if shape isn't recognised.
@@ -436,6 +475,9 @@ init_migration() {
echo " Network: $COMPOSE_NETWORK"
echo ""
+ require_eula_acceptance
+ NETBIRD_EULA_ACCEPTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)
+
local proceed
proceed=$(read_yes_no "Proceed with migration?" "y")
if [[ "$proceed" != "yes" ]]; then
@@ -529,6 +571,10 @@ apply_changes() {
{
echo ""
echo "# Added by migrate-to-enterprise.sh on $(date -u +%Y-%m-%dT%H:%M:%SZ)"
+ echo "# NetBird On-Premise EULA accepted at install time"
+ echo "NETBIRD_EULA_ACCEPTED=yes"
+ echo "NETBIRD_EULA_ACCEPTED_AT=${NETBIRD_EULA_ACCEPTED_AT}"
+ echo "NETBIRD_EULA_URL=${NETBIRD_EULA_URL}"
echo "NB_LICENSE_KEY=${NB_LICENSE_KEY}"
if [[ -n "${NETBIRD_LICENSE_SERVER_BASE_URL:-}" ]]; then
echo "NETBIRD_LICENSE_SERVER_BASE_URL=${NETBIRD_LICENSE_SERVER_BASE_URL}"
From 4ef65294e970b8559cc222c7f184313fae22f272 Mon Sep 17 00:00:00 2001
From: Viktor Liu <17948409+lixmal@users.noreply.github.com>
Date: Tue, 30 Jun 2026 18:22:25 +0900
Subject: [PATCH 20/20] [client] Reinject captured first packet on lazy
connection activation (#6572)
---
client/iface/wgproxy/bind/proxy.go | 5 ++
client/iface/wgproxy/ebpf/wrapper.go | 11 ++++
client/iface/wgproxy/proxy.go | 5 ++
client/iface/wgproxy/udp/proxy.go | 11 ++++
client/internal/engine_test.go | 4 ++
client/internal/iface_common.go | 1 +
.../lazyconn/activity/listener_bind.go | 5 ++
.../lazyconn/activity/listener_bind_test.go | 17 ++++---
.../lazyconn/activity/listener_udp.go | 28 +++++++---
client/internal/lazyconn/activity/manager.go | 20 +++++---
.../lazyconn/activity/manager_test.go | 19 ++++---
client/internal/lazyconn/manager/manager.go | 12 ++---
client/internal/lazyconn/wgiface.go | 1 +
client/internal/peer/conn.go | 51 +++++++++++++++++++
client/internal/peerstore/store.go | 15 +++++-
15 files changed, 169 insertions(+), 36 deletions(-)
diff --git a/client/iface/wgproxy/bind/proxy.go b/client/iface/wgproxy/bind/proxy.go
index be6f3806e..be690ed4f 100644
--- a/client/iface/wgproxy/bind/proxy.go
+++ b/client/iface/wgproxy/bind/proxy.go
@@ -136,6 +136,11 @@ func (p *ProxyBind) CloseConn() error {
return p.close()
}
+// InjectPacket is a no-op for the userspace proxy: first-packet reinjection is kernel-only.
+func (p *ProxyBind) InjectPacket(_ []byte) error {
+ return nil
+}
+
func (p *ProxyBind) close() error {
if p.remoteConn == nil {
return nil
diff --git a/client/iface/wgproxy/ebpf/wrapper.go b/client/iface/wgproxy/ebpf/wrapper.go
index 6e80945c4..a6156a661 100644
--- a/client/iface/wgproxy/ebpf/wrapper.go
+++ b/client/iface/wgproxy/ebpf/wrapper.go
@@ -219,6 +219,17 @@ func (p *ProxyWrapper) RedirectAs(endpoint *net.UDPAddr) {
p.pausedCond.L.Unlock()
}
+// InjectPacket writes b to the remote peer over the underlying transport.
+func (p *ProxyWrapper) InjectPacket(b []byte) error {
+ if p.remoteConn == nil {
+ return errors.New("proxy not started")
+ }
+ if _, err := p.remoteConn.Write(b); err != nil {
+ return err
+ }
+ return nil
+}
+
// CloseConn close the remoteConn and automatically remove the conn instance from the map
func (p *ProxyWrapper) CloseConn() error {
if p.cancel == nil {
diff --git a/client/iface/wgproxy/proxy.go b/client/iface/wgproxy/proxy.go
index 3c8dfd30e..40346bc15 100644
--- a/client/iface/wgproxy/proxy.go
+++ b/client/iface/wgproxy/proxy.go
@@ -18,4 +18,9 @@ type Proxy interface {
RedirectAs(endpoint *net.UDPAddr)
CloseConn() error
SetDisconnectListener(disconnected func())
+
+ // InjectPacket writes a raw packet directly to the remote peer over the underlying transport,
+ // bypassing WireGuard. Used to replay the captured lazyconn handshake initiation. Only the
+ // kernel-mode proxies act on it; the userspace proxy is a no-op since reinjection is kernel-only.
+ InjectPacket(b []byte) error
}
diff --git a/client/iface/wgproxy/udp/proxy.go b/client/iface/wgproxy/udp/proxy.go
index 6069d1960..783843aba 100644
--- a/client/iface/wgproxy/udp/proxy.go
+++ b/client/iface/wgproxy/udp/proxy.go
@@ -147,6 +147,17 @@ func (p *WGUDPProxy) RedirectAs(endpoint *net.UDPAddr) {
p.sendPkg = p.srcFakerConn.SendPkg
}
+// InjectPacket writes b to the remote peer over the underlying transport.
+func (p *WGUDPProxy) InjectPacket(b []byte) error {
+ if p.remoteConn == nil {
+ return errors.New("proxy not started")
+ }
+ if _, err := p.remoteConn.Write(b); err != nil {
+ return err
+ }
+ return nil
+}
+
// CloseConn close the localConn
func (p *WGUDPProxy) CloseConn() error {
if p.cancel == nil {
diff --git a/client/internal/engine_test.go b/client/internal/engine_test.go
index 1ac9ceff7..fbd47ed74 100644
--- a/client/internal/engine_test.go
+++ b/client/internal/engine_test.go
@@ -178,6 +178,10 @@ func (m *MockWGIface) LastActivities() map[string]monotime.Time {
return nil
}
+func (m *MockWGIface) MTU() uint16 {
+ return 1280
+}
+
func (m *MockWGIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
return nil
}
diff --git a/client/internal/iface_common.go b/client/internal/iface_common.go
index 2eeac1954..8ffa0b102 100644
--- a/client/internal/iface_common.go
+++ b/client/internal/iface_common.go
@@ -44,4 +44,5 @@ type wgIfaceBase interface {
FullStats() (*configurer.Stats, error)
LastActivities() map[string]monotime.Time
SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error
+ MTU() uint16
}
diff --git a/client/internal/lazyconn/activity/listener_bind.go b/client/internal/lazyconn/activity/listener_bind.go
index 666c3bc28..72a0cfc76 100644
--- a/client/internal/lazyconn/activity/listener_bind.go
+++ b/client/internal/lazyconn/activity/listener_bind.go
@@ -124,6 +124,11 @@ func (d *BindListener) ReadPackets() {
d.done.Done()
}
+// CapturedPacket is unused in userspace bind mode: first-packet reinjection is kernel-only.
+func (d *BindListener) CapturedPacket() []byte {
+ return nil
+}
+
// Close stops the listener and cleans up resources.
func (d *BindListener) Close() {
d.peerCfg.Log.Infof("closing activity listener (LazyConn)")
diff --git a/client/internal/lazyconn/activity/listener_bind_test.go b/client/internal/lazyconn/activity/listener_bind_test.go
index 1baaae6be..7026a9c97 100644
--- a/client/internal/lazyconn/activity/listener_bind_test.go
+++ b/client/internal/lazyconn/activity/listener_bind_test.go
@@ -45,10 +45,6 @@ type MockWGIfaceBind struct {
endpointMgr *mockEndpointManager
}
-func (m *MockWGIfaceBind) RemovePeer(string) error {
- return nil
-}
-
func (m *MockWGIfaceBind) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
return nil
}
@@ -68,6 +64,10 @@ func (m *MockWGIfaceBind) GetBind() device.EndpointManager {
return m.endpointMgr
}
+func (m *MockWGIfaceBind) MTU() uint16 {
+ return 1280
+}
+
func TestBindListener_Creation(t *testing.T) {
mockEndpointMgr := newMockEndpointManager()
mockIface := &MockWGIfaceBind{endpointMgr: mockEndpointMgr}
@@ -207,8 +207,9 @@ func TestManager_BindMode(t *testing.T) {
require.NoError(t, err)
select {
- case peerConnID := <-mgr.OnActivityChan:
- assert.Equal(t, cfg.PeerConnID, peerConnID, "Received peer connection ID should match")
+ case ev := <-mgr.OnActivityChan:
+ assert.Equal(t, cfg.PeerConnID, ev.PeerConnID, "Received peer connection ID should match")
+ assert.Nil(t, ev.FirstPacket, "Bind mode does not capture packets: reinjection is kernel-only")
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for activity notification")
}
@@ -266,8 +267,8 @@ func TestManager_BindMode_MultiplePeers(t *testing.T) {
receivedPeers := make(map[peerid.ConnID]bool)
for i := 0; i < 2; i++ {
select {
- case peerConnID := <-mgr.OnActivityChan:
- receivedPeers[peerConnID] = true
+ case ev := <-mgr.OnActivityChan:
+ receivedPeers[ev.PeerConnID] = true
case <-time.After(2 * time.Second):
t.Fatal("timeout waiting for activity notifications")
}
diff --git a/client/internal/lazyconn/activity/listener_udp.go b/client/internal/lazyconn/activity/listener_udp.go
index e0b09be6c..4b7e0ddf7 100644
--- a/client/internal/lazyconn/activity/listener_udp.go
+++ b/client/internal/lazyconn/activity/listener_udp.go
@@ -3,11 +3,13 @@ package activity
import (
"fmt"
"net"
+ "slices"
"sync"
"sync/atomic"
log "github.com/sirupsen/logrus"
+ "github.com/netbirdio/netbird/client/iface/bufsize"
"github.com/netbirdio/netbird/client/internal/lazyconn"
)
@@ -20,6 +22,8 @@ type UDPListener struct {
done sync.Mutex
isClosed atomic.Bool
+
+ capturedPacket []byte
}
// NewUDPListener creates a listener that detects activity via UDP socket reads.
@@ -46,9 +50,13 @@ func NewUDPListener(wgIface WgInterface, cfg lazyconn.PeerConfig) (*UDPListener,
}
// ReadPackets blocks reading from the UDP socket until activity is detected or the listener is closed.
+// The first packet that triggers activity is captured so it can be reinjected through the real
+// transport once it is established. Without this, kernel WireGuard's handshake initiation would be
+// dropped and WG would only retry after REKEY_TIMEOUT.
func (d *UDPListener) ReadPackets() {
for {
- n, remoteAddr, err := d.conn.ReadFromUDP(make([]byte, 1))
+ buf := make([]byte, int(d.wgIface.MTU())+bufsize.WGBufferOverhead)
+ n, remoteAddr, err := d.conn.ReadFromUDP(buf)
if err != nil {
if d.isClosed.Load() {
d.peerCfg.Log.Infof("exit from activity listener")
@@ -62,20 +70,24 @@ func (d *UDPListener) ReadPackets() {
d.peerCfg.Log.Warnf("received %d bytes from %s, too short", n, remoteAddr)
continue
}
- d.peerCfg.Log.Infof("activity detected")
+ d.capturedPacket = slices.Clone(buf[:n])
+ d.peerCfg.Log.Infof("activity detected, captured %d bytes for reinjection", n)
break
}
- d.peerCfg.Log.Debugf("removing lazy endpoint: %s", d.endpoint.String())
- if err := d.wgIface.RemovePeer(d.peerCfg.PublicKey); err != nil {
- d.peerCfg.Log.Errorf("failed to remove endpoint: %s", err)
- }
-
- // Ignore close error as it may return "use of closed network connection" if already closed.
+ // Leave the peer in place. ConfigureWGEndpoint will UpdatePeer with the real endpoint;
+ // removing the peer here wipes kernel WG's staged queue and drops the user packet that
+ // triggered activation.
_ = d.conn.Close()
d.done.Unlock()
}
+// CapturedPacket returns the first packet that triggered activity, or nil if none was captured.
+// Safe to call after ReadPackets returns.
+func (d *UDPListener) CapturedPacket() []byte {
+ return d.capturedPacket
+}
+
// Close stops the listener and cleans up resources.
func (d *UDPListener) Close() {
d.peerCfg.Log.Infof("closing activity listener: %s", d.conn.LocalAddr().String())
diff --git a/client/internal/lazyconn/activity/manager.go b/client/internal/lazyconn/activity/manager.go
index cccc0669f..9de8c0fa7 100644
--- a/client/internal/lazyconn/activity/manager.go
+++ b/client/internal/lazyconn/activity/manager.go
@@ -19,17 +19,25 @@ import (
type listener interface {
ReadPackets()
Close()
+ CapturedPacket() []byte
+}
+
+// Event reports activity on a managed peer. FirstPacket is the bytes that triggered activation,
+// captured for reinjection through the real transport.
+type Event struct {
+ PeerConnID peerid.ConnID
+ FirstPacket []byte
}
type WgInterface interface {
- RemovePeer(peerKey string) error
UpdatePeer(peerKey string, allowedIps []netip.Prefix, keepAlive time.Duration, endpoint *net.UDPAddr, preSharedKey *wgtypes.Key) error
IsUserspaceBind() bool
Address() wgaddr.Address
+ MTU() uint16
}
type Manager struct {
- OnActivityChan chan peerid.ConnID
+ OnActivityChan chan Event
wgIface WgInterface
@@ -41,7 +49,7 @@ type Manager struct {
func NewManager(wgIface WgInterface) *Manager {
m := &Manager{
- OnActivityChan: make(chan peerid.ConnID, 1),
+ OnActivityChan: make(chan Event, 1),
wgIface: wgIface,
peers: make(map[peerid.ConnID]listener),
done: make(chan struct{}),
@@ -116,12 +124,12 @@ func (m *Manager) waitForTraffic(l listener, peerConnID peerid.ConnID) {
delete(m.peers, peerConnID)
m.mu.Unlock()
- m.notify(peerConnID)
+ m.notify(Event{PeerConnID: peerConnID, FirstPacket: l.CapturedPacket()})
}
-func (m *Manager) notify(peerConnID peerid.ConnID) {
+func (m *Manager) notify(ev Event) {
select {
case <-m.done:
- case m.OnActivityChan <- peerConnID:
+ case m.OnActivityChan <- ev:
}
}
diff --git a/client/internal/lazyconn/activity/manager_test.go b/client/internal/lazyconn/activity/manager_test.go
index 0768d9219..07dd8d84c 100644
--- a/client/internal/lazyconn/activity/manager_test.go
+++ b/client/internal/lazyconn/activity/manager_test.go
@@ -1,6 +1,7 @@
package activity
import (
+ "bytes"
"net"
"net/netip"
"testing"
@@ -25,10 +26,6 @@ func (m *MocPeer) ConnID() peerid.ConnID {
type MocWGIface struct {
}
-func (m MocWGIface) RemovePeer(string) error {
- return nil
-}
-
func (m MocWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
return nil
}
@@ -44,6 +41,10 @@ func (m MocWGIface) Address() wgaddr.Address {
}
}
+func (m MocWGIface) MTU() uint16 {
+ return 1280
+}
+
// GetPeerListener is a test helper to access listeners
func (m *Manager) GetPeerListener(peerConnID peerid.ConnID) (listener, bool) {
m.mu.Lock()
@@ -86,11 +87,15 @@ func TestManager_MonitorPeerActivity(t *testing.T) {
}
select {
- case peerConnID := <-mgr.OnActivityChan:
- if peerConnID != peerCfg1.PeerConnID {
- t.Fatalf("unexpected peerConnID: %v", peerConnID)
+ case ev := <-mgr.OnActivityChan:
+ if ev.PeerConnID != peerCfg1.PeerConnID {
+ t.Fatalf("unexpected peerConnID: %v", ev.PeerConnID)
+ }
+ if !bytes.Equal(ev.FirstPacket, []byte{0x01, 0x02, 0x03, 0x04, 0x05}) {
+ t.Fatalf("unexpected first packet: %v", ev.FirstPacket)
}
case <-time.After(1 * time.Second):
+ t.Fatal("timed out waiting for activity")
}
}
diff --git a/client/internal/lazyconn/manager/manager.go b/client/internal/lazyconn/manager/manager.go
index fc47bda39..3868e37e8 100644
--- a/client/internal/lazyconn/manager/manager.go
+++ b/client/internal/lazyconn/manager/manager.go
@@ -130,8 +130,8 @@ func (m *Manager) Start(ctx context.Context) {
select {
case <-ctx.Done():
return
- case peerConnID := <-m.activityManager.OnActivityChan:
- m.onPeerActivity(peerConnID)
+ case ev := <-m.activityManager.OnActivityChan:
+ m.onPeerActivity(ev)
case peerIDs := <-m.inactivityManager.InactivePeersChan():
m.onPeerInactivityTimedOut(peerIDs)
}
@@ -513,13 +513,13 @@ func (m *Manager) checkHaGroupActivity(haGroup route.HAUniqueID, peerID string,
return false
}
-func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) {
+func (m *Manager) onPeerActivity(ev activity.Event) {
m.managedPeersMu.Lock()
defer m.managedPeersMu.Unlock()
- mp, ok := m.managedPeersByConnID[peerConnID]
+ mp, ok := m.managedPeersByConnID[ev.PeerConnID]
if !ok {
- log.Errorf("peer not found by conn id: %v", peerConnID)
+ log.Errorf("peer not found by conn id: %v", ev.PeerConnID)
return
}
@@ -536,7 +536,7 @@ func (m *Manager) onPeerActivity(peerConnID peerid.ConnID) {
m.activateHAGroupPeers(mp.peerCfg)
- m.peerStore.PeerConnOpen(m.engineCtx, mp.peerCfg.PublicKey)
+ m.peerStore.PeerConnOpenWithFirstPacket(m.engineCtx, mp.peerCfg.PublicKey, ev.FirstPacket)
}
func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
diff --git a/client/internal/lazyconn/wgiface.go b/client/internal/lazyconn/wgiface.go
index 0626c1815..f003ab3cf 100644
--- a/client/internal/lazyconn/wgiface.go
+++ b/client/internal/lazyconn/wgiface.go
@@ -17,4 +17,5 @@ type WGIface interface {
IsUserspaceBind() bool
Address() wgaddr.Address
LastActivities() map[string]monotime.Time
+ MTU() uint16
}
diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go
index 79a513956..85e54ba5f 100644
--- a/client/internal/peer/conn.go
+++ b/client/internal/peer/conn.go
@@ -6,6 +6,7 @@ import (
"net"
"net/netip"
"runtime"
+ "slices"
"sync"
"time"
@@ -136,6 +137,39 @@ type Conn struct {
// Connection stage timestamps for metrics
metricsRecorder MetricsRecorder
metricsStages *MetricsStages
+
+ // pendingFirstPacket is the lazyconn-captured handshake init, replayed once the real
+ // transport is up.
+ pendingFirstPacket []byte
+}
+
+// injectPendingFirstPacket replays the captured handshake through the proxy if present, else
+// directly through the ICE conn. The packet is cleared only after a successful write, so a failed
+// or transport-less attempt leaves it available for a later reinjection. Caller must hold conn.mu.
+func (conn *Conn) injectPendingFirstPacket(proxy wgproxy.Proxy, directConn net.Conn) {
+ pkt := conn.pendingFirstPacket
+ if len(pkt) == 0 {
+ return
+ }
+
+ switch {
+ case proxy != nil:
+ if err := proxy.InjectPacket(pkt); err != nil {
+ conn.Log.Debugf("failed to reinject captured first packet via proxy: %v", err)
+ return
+ }
+ case directConn != nil:
+ if _, err := directConn.Write(pkt); err != nil {
+ conn.Log.Debugf("failed to reinject captured first packet via direct conn: %v", err)
+ return
+ }
+ default:
+ conn.Log.Debugf("no transport available to reinject captured first packet")
+ return
+ }
+
+ conn.pendingFirstPacket = nil
+ conn.Log.Debugf("reinjected captured first packet (%d bytes)", len(pkt))
}
// NewConn creates a new not opened Conn to the remote peer.
@@ -172,6 +206,16 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
// It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will
// be used.
func (conn *Conn) Open(engineCtx context.Context) error {
+ return conn.open(engineCtx, nil)
+}
+
+// OpenWithFirstPacket opens the connection like Open and stashes firstPacket to be replayed once
+// the real transport is established. The packet is retained only on a successful open.
+func (conn *Conn) OpenWithFirstPacket(engineCtx context.Context, firstPacket []byte) error {
+ return conn.open(engineCtx, firstPacket)
+}
+
+func (conn *Conn) open(engineCtx context.Context, firstPacket []byte) error {
conn.mu.Lock()
defer conn.mu.Unlock()
@@ -227,6 +271,9 @@ func (conn *Conn) Open(engineCtx context.Context) error {
defer conn.wg.Done()
conn.guard.Start(conn.ctx, conn.onGuardEvent)
}()
+ if len(firstPacket) > 0 {
+ conn.pendingFirstPacket = slices.Clone(firstPacket)
+ }
conn.opened = true
return nil
}
@@ -423,6 +470,8 @@ func (conn *Conn) onICEConnectionIsReady(priority conntype.ConnPriority, iceConn
conn.wgProxyRelay.RedirectAs(ep)
}
+ conn.injectPendingFirstPacket(wgProxy, iceConnInfo.RemoteConn)
+
conn.currentConnPriority = priority
conn.statusICE.SetConnected()
conn.updateIceState(iceConnInfo, updateTime)
@@ -546,6 +595,8 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
wgConfigWorkaround()
+ conn.injectPendingFirstPacket(wgProxy, nil)
+
conn.rosenpassRemoteKey = rci.rosenpassPubKey
conn.currentConnPriority = conntype.Relay
conn.statusRelay.SetConnected()
diff --git a/client/internal/peerstore/store.go b/client/internal/peerstore/store.go
index 099fe4528..112caa101 100644
--- a/client/internal/peerstore/store.go
+++ b/client/internal/peerstore/store.go
@@ -88,11 +88,24 @@ func (s *Store) PeerConnOpen(ctx context.Context, pubKey string) {
if !ok {
return
}
- // this can be blocked because of the connect open limiter semaphore
if err := p.Open(ctx); err != nil {
p.Log.Errorf("failed to open peer connection: %v", err)
}
+}
+// PeerConnOpenWithFirstPacket opens the peer connection and stashes a first packet to be
+// reinjected once the real transport is established.
+func (s *Store) PeerConnOpenWithFirstPacket(ctx context.Context, pubKey string, firstPacket []byte) {
+ s.peerConnsMu.RLock()
+ defer s.peerConnsMu.RUnlock()
+
+ p, ok := s.peerConns[pubKey]
+ if !ok {
+ return
+ }
+ if err := p.OpenWithFirstPacket(ctx, firstPacket); err != nil {
+ p.Log.Errorf("failed to open peer connection: %v", err)
+ }
}
func (s *Store) PeerConnIdle(pubKey string) {