From 79382951905d9fe399ed5b07fff1ddcda8de7ee2 Mon Sep 17 00:00:00 2001
From: Viktor Liu <viktor@netbird.io>
Date: Wed, 3 Apr 2024 11:11:46 +0200
Subject: [PATCH] Feature/exit nodes - Windows and macOS support (#1726)

---
 .github/workflows/golang-test-darwin.yml      |   3 +
 .github/workflows/golang-test-windows.yml     |   2 +-
 client/internal/engine.go                     |  26 +-
 client/internal/peer/conn.go                  |  33 ++
 client/internal/routemanager/client.go        |   4 +-
 client/internal/routemanager/manager.go       |  41 +-
 client/internal/routemanager/manager_test.go  |  30 +-
 client/internal/routemanager/mock.go          |   5 +-
 client/internal/routemanager/routemanager.go  | 119 +++++
 .../routemanager/server_nonandroid.go         |   8 +-
 .../routemanager/systemops_android.go         |  24 +-
 .../routemanager/systemops_bsd_nonios.go      |  13 -
 .../internal/routemanager/systemops_darwin.go |  61 +++
 .../routemanager/systemops_darwin_test.go     | 100 +++++
 client/internal/routemanager/systemops_ios.go |  24 +-
 .../internal/routemanager/systemops_linux.go  |  44 +-
 .../routemanager/systemops_linux_test.go      | 386 +++--------------
 .../routemanager/systemops_nonandroid.go      | 148 -------
 .../routemanager/systemops_nonandroid_test.go | 282 ------------
 .../routemanager/systemops_nonlinux.go        | 406 +++++++++++++++++-
 .../routemanager/systemops_nonlinux_test.go   | 242 ++++++++++-
 .../routemanager/systemops_unix_test.go       | 234 ++++++++++
 .../routemanager/systemops_windows.go         |  81 +++-
 .../routemanager/systemops_windows_test.go    | 289 +++++++++++++
 client/internal/routemanager/sytemops_test.go | 101 +++++
 client/internal/wgproxy/portlookup.go         |   6 +-
 client/internal/wgproxy/proxy_ebpf.go         |   6 +-
 go.mod                                        |   2 +-
 go.sum                                        |   6 +-
 util/grpc/{dialer_linux.go => dialer.go}      |  10 +-
 util/grpc/dialer_generic.go                   |   9 -
 util/net/dialer.go                            |  64 +++
 util/net/dialer_generic.go                    | 118 ++++-
 util/net/dialer_linux.go                      |  58 +--
 util/net/dialer_nonlinux.go                   |   6 +
 util/net/listener.go                          |  21 +
 util/net/listener_generic.go                  | 153 ++++++-
 util/net/listener_linux.go                    |  24 +-
 util/net/listener_mobile.go                   |  11 +
 util/net/listener_nonlinux.go                 |   6 +
 util/net/net.go                               |  11 +
 41 files changed, 2253 insertions(+), 964 deletions(-)
 create mode 100644 client/internal/routemanager/routemanager.go
 delete mode 100644 client/internal/routemanager/systemops_bsd_nonios.go
 create mode 100644 client/internal/routemanager/systemops_darwin.go
 create mode 100644 client/internal/routemanager/systemops_darwin_test.go
 delete mode 100644 client/internal/routemanager/systemops_nonandroid.go
 delete mode 100644 client/internal/routemanager/systemops_nonandroid_test.go
 create mode 100644 client/internal/routemanager/systemops_unix_test.go
 create mode 100644 client/internal/routemanager/systemops_windows_test.go
 create mode 100644 client/internal/routemanager/sytemops_test.go
 rename util/grpc/{dialer_linux.go => dialer.go} (56%)
 delete mode 100644 util/grpc/dialer_generic.go
 create mode 100644 util/net/dialer.go
 create mode 100644 util/net/dialer_nonlinux.go
 create mode 100644 util/net/listener.go
 create mode 100644 util/net/listener_mobile.go
 create mode 100644 util/net/listener_nonlinux.go

diff --git a/.github/workflows/golang-test-darwin.yml b/.github/workflows/golang-test-darwin.yml
index f8afd3d6eab..d7007c86080 100644
--- a/.github/workflows/golang-test-darwin.yml
+++ b/.github/workflows/golang-test-darwin.yml
@@ -32,6 +32,9 @@ jobs:
           restore-keys: |
             macos-go-
 
+      - name: Install libpcap
+        run: brew install libpcap
+
       - name: Install modules
         run: go mod tidy
 
diff --git a/.github/workflows/golang-test-windows.yml b/.github/workflows/golang-test-windows.yml
index 6027d36269f..2d63acbcd5a 100644
--- a/.github/workflows/golang-test-windows.yml
+++ b/.github/workflows/golang-test-windows.yml
@@ -46,7 +46,7 @@ jobs:
       - run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=C:\Users\runneradmin\AppData\Local\go-build
 
       - name: test
-        run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe test -timeout 5m -p 1 ./... > test-out.txt 2>&1"
+        run: PsExec64 -s -w ${{ github.workspace }} cmd.exe /c "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe test -timeout 10m -p 1 ./... > test-out.txt 2>&1"
       - name: test output
         if: ${{ always() }}
         run: Get-Content test-out.txt
diff --git a/client/internal/engine.go b/client/internal/engine.go
index 046a6c94450..d6238c4b3ca 100644
--- a/client/internal/engine.go
+++ b/client/internal/engine.go
@@ -93,6 +93,10 @@ type Engine struct {
 	mgmClient mgm.Client
 	// peerConns is a map that holds all the peers that are known to this peer
 	peerConns map[string]*peer.Conn
+
+	beforePeerHook peer.BeforeAddPeerHookFunc
+	afterPeerHook  peer.AfterRemovePeerHookFunc
+
 	// rpManager is a Rosenpass manager
 	rpManager *rosenpass.Manager
 
@@ -260,9 +264,14 @@ func (e *Engine) Start() error {
 	e.dnsServer = dnsServer
 
 	e.routeManager = routemanager.NewManager(e.ctx, e.config.WgPrivateKey.PublicKey().String(), e.wgInterface, e.statusRecorder, initialRoutes)
-	if err := e.routeManager.Init(); err != nil {
+	beforePeerHook, afterPeerHook, err := e.routeManager.Init()
+	if err != nil {
 		log.Errorf("Failed to initialize route manager: %s", err)
+	} else {
+		e.beforePeerHook = beforePeerHook
+		e.afterPeerHook = afterPeerHook
 	}
+
 	e.routeManager.SetRouteChangeListener(e.mobileDep.NetworkChangeListener)
 
 	err = e.wgInterfaceCreate()
@@ -808,10 +817,15 @@ func (e *Engine) addNewPeer(peerConfig *mgmProto.RemotePeerConfig) error {
 	if _, ok := e.peerConns[peerKey]; !ok {
 		conn, err := e.createPeerConn(peerKey, strings.Join(peerIPs, ","))
 		if err != nil {
-			return err
+			return fmt.Errorf("create peer connection: %w", err)
 		}
 		e.peerConns[peerKey] = conn
 
+		if e.beforePeerHook != nil && e.afterPeerHook != nil {
+			conn.AddBeforeAddPeerHook(e.beforePeerHook)
+			conn.AddAfterRemovePeerHook(e.afterPeerHook)
+		}
+
 		err = e.statusRecorder.AddPeer(peerKey, peerConfig.Fqdn)
 		if err != nil {
 			log.Warnf("error adding peer %s to status recorder, got error: %v", peerKey, err)
@@ -1105,6 +1119,10 @@ func (e *Engine) close() {
 		e.dnsServer.Stop()
 	}
 
+	if e.routeManager != nil {
+		e.routeManager.Stop()
+	}
+
 	log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
 	if e.wgInterface != nil {
 		if err := e.wgInterface.Close(); err != nil {
@@ -1119,10 +1137,6 @@ func (e *Engine) close() {
 		}
 	}
 
-	if e.routeManager != nil {
-		e.routeManager.Stop()
-	}
-
 	if e.firewall != nil {
 		err := e.firewall.Reset()
 		if err != nil {
diff --git a/client/internal/peer/conn.go b/client/internal/peer/conn.go
index ce8cc4b9779..f3d07dcad1f 100644
--- a/client/internal/peer/conn.go
+++ b/client/internal/peer/conn.go
@@ -20,6 +20,7 @@ import (
 	"github.com/netbirdio/netbird/iface/bind"
 	signal "github.com/netbirdio/netbird/signal/client"
 	sProto "github.com/netbirdio/netbird/signal/proto"
+	nbnet "github.com/netbirdio/netbird/util/net"
 	"github.com/netbirdio/netbird/version"
 )
 
@@ -100,6 +101,9 @@ type IceCredentials struct {
 	Pwd   string
 }
 
+type BeforeAddPeerHookFunc func(connID nbnet.ConnectionID, IP net.IP) error
+type AfterRemovePeerHookFunc func(connID nbnet.ConnectionID) error
+
 type Conn struct {
 	config ConnConfig
 	mu     sync.Mutex
@@ -138,6 +142,10 @@ type Conn struct {
 
 	remoteEndpoint *net.UDPAddr
 	remoteConn     *ice.Conn
+
+	connID               nbnet.ConnectionID
+	beforeAddPeerHooks   []BeforeAddPeerHookFunc
+	afterRemovePeerHooks []AfterRemovePeerHookFunc
 }
 
 // meta holds meta information about a connection
@@ -393,6 +401,14 @@ func isRelayCandidate(candidate ice.Candidate) bool {
 	return candidate.Type() == ice.CandidateTypeRelay
 }
 
+func (conn *Conn) AddBeforeAddPeerHook(hook BeforeAddPeerHookFunc) {
+	conn.beforeAddPeerHooks = append(conn.beforeAddPeerHooks, hook)
+}
+
+func (conn *Conn) AddAfterRemovePeerHook(hook AfterRemovePeerHookFunc) {
+	conn.afterRemovePeerHooks = append(conn.afterRemovePeerHooks, hook)
+}
+
 // configureConnection starts proxying traffic from/to local Wireguard and sets connection status to StatusConnected
 func (conn *Conn) configureConnection(remoteConn net.Conn, remoteWgPort int, remoteRosenpassPubKey []byte, remoteRosenpassAddr string) (net.Addr, error) {
 	conn.mu.Lock()
@@ -419,6 +435,14 @@ func (conn *Conn) configureConnection(remoteConn net.Conn, remoteWgPort int, rem
 
 	endpointUdpAddr, _ := net.ResolveUDPAddr(endpoint.Network(), endpoint.String())
 	conn.remoteEndpoint = endpointUdpAddr
+	log.Debugf("Conn resolved IP for %s: %s", endpoint, endpointUdpAddr.IP)
+
+	conn.connID = nbnet.GenerateConnID()
+	for _, hook := range conn.beforeAddPeerHooks {
+		if err := hook(conn.connID, endpointUdpAddr.IP); err != nil {
+			log.Errorf("Before add peer hook failed: %v", err)
+		}
+	}
 
 	err = conn.config.WgConfig.WgInterface.UpdatePeer(conn.config.WgConfig.RemoteKey, conn.config.WgConfig.AllowedIps, defaultWgKeepAlive, endpointUdpAddr, conn.config.WgConfig.PreSharedKey)
 	if err != nil {
@@ -510,6 +534,15 @@ func (conn *Conn) cleanup() error {
 	// todo: is it problem if we try to remove a peer what is never existed?
 	err3 = conn.config.WgConfig.WgInterface.RemovePeer(conn.config.WgConfig.RemoteKey)
 
+	if conn.connID != "" {
+		for _, hook := range conn.afterRemovePeerHooks {
+			if err := hook(conn.connID); err != nil {
+				log.Errorf("After remove peer hook failed: %v", err)
+			}
+		}
+	}
+	conn.connID = ""
+
 	if conn.notifyDisconnected != nil {
 		conn.notifyDisconnected()
 		conn.notifyDisconnected = nil
diff --git a/client/internal/routemanager/client.go b/client/internal/routemanager/client.go
index b2dff7f08cf..38cf4bf6550 100644
--- a/client/internal/routemanager/client.go
+++ b/client/internal/routemanager/client.go
@@ -193,7 +193,7 @@ func (c *clientNetwork) removeRouteFromWireguardPeer(peerKey string) error {
 
 func (c *clientNetwork) removeRouteFromPeerAndSystem() error {
 	if c.chosenRoute != nil {
-		if err := removeFromRouteTableIfNonSystem(c.network, c.wgInterface.Address().IP.String(), c.wgInterface.Name()); err != nil {
+		if err := removeVPNRoute(c.network, c.wgInterface.Name()); err != nil {
 			return fmt.Errorf("remove route %s from system, err: %v", c.network, err)
 		}
 
@@ -234,7 +234,7 @@ func (c *clientNetwork) recalculateRouteAndUpdatePeerAndSystem() error {
 		}
 	} else {
 		// otherwise add the route to the system
-		if err := addToRouteTableIfNoExists(c.network, c.wgInterface.Address().IP.String(), c.wgInterface.Name()); err != nil {
+		if err := addVPNRoute(c.network, c.wgInterface.Name()); err != nil {
 			return fmt.Errorf("route %s couldn't be added for peer %s, err: %v",
 				c.network.String(), c.wgInterface.Address().IP.String(), err)
 		}
diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go
index 6a0d954da09..36a37f02c50 100644
--- a/client/internal/routemanager/manager.go
+++ b/client/internal/routemanager/manager.go
@@ -3,7 +3,9 @@ package routemanager
 import (
 	"context"
 	"fmt"
+	"net"
 	"net/netip"
+	"net/url"
 	"runtime"
 	"sync"
 
@@ -24,7 +26,7 @@ var defaultv6 = netip.PrefixFrom(netip.IPv6Unspecified(), 0)
 
 // Manager is a route manager interface
 type Manager interface {
-	Init() error
+	Init() (peer.BeforeAddPeerHookFunc, peer.AfterRemovePeerHookFunc, error)
 	UpdateRoutes(updateSerial uint64, newRoutes []*route.Route) error
 	SetRouteChangeListener(listener listener.NetworkChangeListener)
 	InitialRouteRange() []string
@@ -65,16 +67,21 @@ func NewManager(ctx context.Context, pubKey string, wgInterface *iface.WGIface,
 }
 
 // Init sets up the routing
-func (m *DefaultManager) Init() error {
+func (m *DefaultManager) Init() (peer.BeforeAddPeerHookFunc, peer.AfterRemovePeerHookFunc, error) {
 	if err := cleanupRouting(); err != nil {
 		log.Warnf("Failed cleaning up routing: %v", err)
 	}
 
-	if err := setupRouting(); err != nil {
-		return fmt.Errorf("setup routing: %w", err)
+	mgmtAddress := m.statusRecorder.GetManagementState().URL
+	signalAddress := m.statusRecorder.GetSignalState().URL
+	ips := resolveURLsToIPs([]string{mgmtAddress, signalAddress})
+
+	beforePeerHook, afterPeerHook, err := setupRouting(ips, m.wgInterface)
+	if err != nil {
+		return nil, nil, fmt.Errorf("setup routing: %w", err)
 	}
 	log.Info("Routing setup complete")
-	return nil
+	return beforePeerHook, afterPeerHook, nil
 }
 
 func (m *DefaultManager) EnableServerRouter(firewall firewall.Manager) error {
@@ -203,16 +210,36 @@ func (m *DefaultManager) clientRoutes(initialRoutes []*route.Route) []*route.Rou
 }
 
 func isPrefixSupported(prefix netip.Prefix) bool {
-	if runtime.GOOS == "linux" {
+	switch runtime.GOOS {
+	case "linux", "windows", "darwin":
 		return true
 	}
 
 	// If prefix is too small, lets assume it is a possible default prefix which is not yet supported
 	// we skip this prefix management
-	if prefix.Bits() < minRangeBits {
+	if prefix.Bits() <= minRangeBits {
 		log.Warnf("This agent version: %s, doesn't support default routes, received %s, skipping this prefix",
 			version.NetbirdVersion(), prefix)
 		return false
 	}
 	return true
 }
+
+// resolveURLsToIPs takes a slice of URLs, resolves them to IP addresses and returns a slice of IPs.
+func resolveURLsToIPs(urls []string) []net.IP {
+	var ips []net.IP
+	for _, rawurl := range urls {
+		u, err := url.Parse(rawurl)
+		if err != nil {
+			log.Errorf("Failed to parse url %s: %v", rawurl, err)
+			continue
+		}
+		ipAddrs, err := net.LookupIP(u.Hostname())
+		if err != nil {
+			log.Errorf("Failed to resolve host %s: %v", u.Hostname(), err)
+			continue
+		}
+		ips = append(ips, ipAddrs...)
+	}
+	return ips
+}
diff --git a/client/internal/routemanager/manager_test.go b/client/internal/routemanager/manager_test.go
index 9d92bf90d2f..03e77e09bcb 100644
--- a/client/internal/routemanager/manager_test.go
+++ b/client/internal/routemanager/manager_test.go
@@ -28,14 +28,14 @@ const remotePeerKey2 = "remote1"
 
 func TestManagerUpdateRoutes(t *testing.T) {
 	testCases := []struct {
-		name                               string
-		inputInitRoutes                    []*route.Route
-		inputRoutes                        []*route.Route
-		inputSerial                        uint64
-		removeSrvRouter                    bool
-		serverRoutesExpected               int
-		clientNetworkWatchersExpected      int
-		clientNetworkWatchersExpectedLinux int
+		name                                 string
+		inputInitRoutes                      []*route.Route
+		inputRoutes                          []*route.Route
+		inputSerial                          uint64
+		removeSrvRouter                      bool
+		serverRoutesExpected                 int
+		clientNetworkWatchersExpected        int
+		clientNetworkWatchersExpectedAllowed int
 	}{
 		{
 			name:            "Should create 2 client networks",
@@ -201,9 +201,9 @@ func TestManagerUpdateRoutes(t *testing.T) {
 					Enabled:     true,
 				},
 			},
-			inputSerial:                        1,
-			clientNetworkWatchersExpected:      0,
-			clientNetworkWatchersExpectedLinux: 1,
+			inputSerial:                          1,
+			clientNetworkWatchersExpected:        0,
+			clientNetworkWatchersExpectedAllowed: 1,
 		},
 		{
 			name: "Remove 1 Client Route",
@@ -417,7 +417,9 @@ func TestManagerUpdateRoutes(t *testing.T) {
 			statusRecorder := peer.NewRecorder("https://mgm")
 			ctx := context.TODO()
 			routeManager := NewManager(ctx, localPeerKey, wgInterface, statusRecorder, nil)
-			err = routeManager.Init()
+
+			_, _, err = routeManager.Init()
+
 			require.NoError(t, err, "should init route manager")
 			defer routeManager.Stop()
 
@@ -434,8 +436,8 @@ func TestManagerUpdateRoutes(t *testing.T) {
 			require.NoError(t, err, "should update routes")
 
 			expectedWatchers := testCase.clientNetworkWatchersExpected
-			if runtime.GOOS == "linux" && testCase.clientNetworkWatchersExpectedLinux != 0 {
-				expectedWatchers = testCase.clientNetworkWatchersExpectedLinux
+			if (runtime.GOOS == "linux" || runtime.GOOS == "windows" || runtime.GOOS == "darwin") && testCase.clientNetworkWatchersExpectedAllowed != 0 {
+				expectedWatchers = testCase.clientNetworkWatchersExpectedAllowed
 			}
 			require.Len(t, routeManager.clientNetworks, expectedWatchers, "client networks size should match")
 
diff --git a/client/internal/routemanager/mock.go b/client/internal/routemanager/mock.go
index e812b3a85b6..dd2c28e5927 100644
--- a/client/internal/routemanager/mock.go
+++ b/client/internal/routemanager/mock.go
@@ -6,6 +6,7 @@ import (
 
 	firewall "github.com/netbirdio/netbird/client/firewall/manager"
 	"github.com/netbirdio/netbird/client/internal/listener"
+	"github.com/netbirdio/netbird/client/internal/peer"
 	"github.com/netbirdio/netbird/iface"
 	"github.com/netbirdio/netbird/route"
 )
@@ -16,8 +17,8 @@ type MockManager struct {
 	StopFunc         func()
 }
 
-func (m *MockManager) Init() error {
-	return nil
+func (m *MockManager) Init() (peer.BeforeAddPeerHookFunc, peer.AfterRemovePeerHookFunc, error) {
+	return nil, nil, nil
 }
 
 // InitialRouteRange mock implementation of InitialRouteRange from Manager interface
diff --git a/client/internal/routemanager/routemanager.go b/client/internal/routemanager/routemanager.go
new file mode 100644
index 00000000000..fe8d7b4ef19
--- /dev/null
+++ b/client/internal/routemanager/routemanager.go
@@ -0,0 +1,119 @@
+//go:build !android
+
+package routemanager
+
+import (
+	"fmt"
+	"net/netip"
+	"sync"
+
+	"github.com/hashicorp/go-multierror"
+	log "github.com/sirupsen/logrus"
+
+	nbnet "github.com/netbirdio/netbird/util/net"
+)
+
+type ref struct {
+	count   int
+	nexthop netip.Addr
+	intf    string
+}
+
+type RouteManager struct {
+	// refCountMap keeps track of the reference ref for prefixes
+	refCountMap map[netip.Prefix]ref
+	// prefixMap keeps track of the prefixes associated with a connection ID for removal
+	prefixMap   map[nbnet.ConnectionID][]netip.Prefix
+	addRoute    AddRouteFunc
+	removeRoute RemoveRouteFunc
+	mutex       sync.Mutex
+}
+
+type AddRouteFunc func(prefix netip.Prefix) (nexthop netip.Addr, intf string, err error)
+type RemoveRouteFunc func(prefix netip.Prefix, nexthop netip.Addr, intf string) error
+
+func NewRouteManager(addRoute AddRouteFunc, removeRoute RemoveRouteFunc) *RouteManager {
+	// TODO: read initial routing table into refCountMap
+	return &RouteManager{
+		refCountMap: map[netip.Prefix]ref{},
+		prefixMap:   map[nbnet.ConnectionID][]netip.Prefix{},
+		addRoute:    addRoute,
+		removeRoute: removeRoute,
+	}
+}
+
+func (rm *RouteManager) AddRouteRef(connID nbnet.ConnectionID, prefix netip.Prefix) error {
+	rm.mutex.Lock()
+	defer rm.mutex.Unlock()
+
+	ref := rm.refCountMap[prefix]
+	log.Debugf("Increasing route ref count %d for prefix %s", ref.count, prefix)
+
+	// Add route to the system, only if it's a new prefix
+	if ref.count == 0 {
+		log.Debugf("Adding route for prefix %s", prefix)
+		nexthop, intf, err := rm.addRoute(prefix)
+		if err != nil {
+			return fmt.Errorf("failed to add route for prefix %s: %w", prefix, err)
+		}
+		ref.nexthop = nexthop
+		ref.intf = intf
+	}
+
+	ref.count++
+	rm.refCountMap[prefix] = ref
+	rm.prefixMap[connID] = append(rm.prefixMap[connID], prefix)
+
+	return nil
+}
+
+func (rm *RouteManager) RemoveRouteRef(connID nbnet.ConnectionID) error {
+	rm.mutex.Lock()
+	defer rm.mutex.Unlock()
+
+	prefixes, ok := rm.prefixMap[connID]
+	if !ok {
+		log.Debugf("No prefixes found for connection ID %s", connID)
+		return nil
+	}
+
+	var result *multierror.Error
+	for _, prefix := range prefixes {
+		ref := rm.refCountMap[prefix]
+		log.Debugf("Decreasing route ref count %d for prefix %s", ref.count, prefix)
+		if ref.count == 1 {
+			log.Debugf("Removing route for prefix %s", prefix)
+			// TODO: don't fail if the route is not found
+			if err := rm.removeRoute(prefix, ref.nexthop, ref.intf); err != nil {
+				result = multierror.Append(result, fmt.Errorf("remove route for prefix %s: %w", prefix, err))
+				continue
+			}
+			delete(rm.refCountMap, prefix)
+		} else {
+			ref.count--
+			rm.refCountMap[prefix] = ref
+		}
+	}
+	delete(rm.prefixMap, connID)
+
+	return result.ErrorOrNil()
+}
+
+// Flush removes all references and routes from the system
+func (rm *RouteManager) Flush() error {
+	rm.mutex.Lock()
+	defer rm.mutex.Unlock()
+
+	var result *multierror.Error
+	for prefix := range rm.refCountMap {
+		log.Debugf("Removing route for prefix %s", prefix)
+		ref := rm.refCountMap[prefix]
+		if err := rm.removeRoute(prefix, ref.nexthop, ref.intf); err != nil {
+			result = multierror.Append(result, fmt.Errorf("remove route for prefix %s: %w", prefix, err))
+		}
+	}
+	rm.refCountMap = map[netip.Prefix]ref{}
+	rm.prefixMap = map[nbnet.ConnectionID][]netip.Prefix{}
+
+	return result.ErrorOrNil()
+}
diff --git a/client/internal/routemanager/server_nonandroid.go b/client/internal/routemanager/server_nonandroid.go
index 00df735fb8a..af82dc91349 100644
--- a/client/internal/routemanager/server_nonandroid.go
+++ b/client/internal/routemanager/server_nonandroid.go
@@ -155,11 +155,13 @@ func (m *defaultServerRouter) cleanUp() {
 			log.Errorf("Failed to remove cleanup route: %v", err)
 		}
 
-		state := m.statusRecorder.GetLocalPeerState()
-		state.Routes = nil
-		m.statusRecorder.UpdateLocalPeerState(state)
 	}
+
+	state := m.statusRecorder.GetLocalPeerState()
+	state.Routes = nil
+	m.statusRecorder.UpdateLocalPeerState(state)
 }
+
 func routeToRouterPair(source string, route *route.Route) (firewall.RouterPair, error) {
 	parsed, err := netip.ParsePrefix(source)
 	if err != nil {
diff --git a/client/internal/routemanager/systemops_android.go b/client/internal/routemanager/systemops_android.go
index 291826780af..34d2d270fe3 100644
--- a/client/internal/routemanager/systemops_android.go
+++ b/client/internal/routemanager/systemops_android.go
@@ -1,13 +1,33 @@
 package routemanager
 
 import (
+	"net"
 	"net/netip"
+	"runtime"
+
+	log "github.com/sirupsen/logrus"
+
+	"github.com/netbirdio/netbird/client/internal/peer"
+	"github.com/netbirdio/netbird/iface"
 )
 
-func addToRouteTableIfNoExists(prefix netip.Prefix, addr, intf string) error {
+func setupRouting([]net.IP, *iface.WGIface) (peer.BeforeAddPeerHookFunc, peer.AfterRemovePeerHookFunc, error) {
+	return nil, nil, nil
+}
+
+func cleanupRouting() error {
+	return nil
+}
+
+func enableIPForwarding() error {
+	log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
+	return nil
+}
+
+func addVPNRoute(netip.Prefix, string) error {
 	return nil
 }
 
-func removeFromRouteTableIfNonSystem(prefix netip.Prefix, addr, intf string) error {
+func removeVPNRoute(netip.Prefix, string) error {
 	return nil
 }
diff --git a/client/internal/routemanager/systemops_bsd_nonios.go b/client/internal/routemanager/systemops_bsd_nonios.go
deleted file mode 100644
index f60c7afc3a0..00000000000
--- a/client/internal/routemanager/systemops_bsd_nonios.go
+++ /dev/null
@@ -1,13 +0,0 @@
-//go:build (darwin || dragonfly || freebsd || netbsd || openbsd) && !ios
-
-package routemanager
-
-import "net/netip"
-
-func addToRouteTableIfNoExists(prefix netip.Prefix, addr string, intf string) error {
-	return genericAddToRouteTableIfNoExists(prefix, addr, intf)
-}
-
-func removeFromRouteTableIfNonSystem(prefix netip.Prefix, addr string, intf string) error {
-	return genericRemoveFromRouteTableIfNonSystem(prefix, addr, intf)
-}
diff --git a/client/internal/routemanager/systemops_darwin.go b/client/internal/routemanager/systemops_darwin.go
new file mode 100644
index 00000000000..f34964a8343
--- /dev/null
+++ b/client/internal/routemanager/systemops_darwin.go
@@ -0,0 +1,61 @@
+//go:build darwin && !ios
+
+package routemanager
+
+import (
+	"fmt"
+	"net"
+	"net/netip"
+	"os/exec"
+	"strings"
+
+	log "github.com/sirupsen/logrus"
+
+	"github.com/netbirdio/netbird/client/internal/peer"
+	"github.com/netbirdio/netbird/iface"
+)
+
+var routeManager *RouteManager
+
+func setupRouting(initAddresses []net.IP, wgIface *iface.WGIface) (peer.BeforeAddPeerHookFunc, peer.AfterRemovePeerHookFunc, error) {
+	return setupRoutingWithRouteManager(&routeManager, initAddresses, wgIface)
+}
+
+func cleanupRouting() error {
+	return cleanupRoutingWithRouteManager(routeManager)
+}
+
+func addToRouteTable(prefix netip.Prefix, nexthop netip.Addr, intf string) error {
+	return routeCmd("add", prefix, nexthop, intf)
+}
+
+func removeFromRouteTable(prefix netip.Prefix, nexthop netip.Addr, intf string) error {
+	return routeCmd("delete", prefix, nexthop, intf)
+}
+
+func routeCmd(action string, prefix netip.Prefix, nexthop netip.Addr, intf string) error {
+	inet := "-inet"
+	if prefix.Addr().Is6() {
+		inet = "-inet6"
+		// Special case for IPv6 split default route, pointing to the wg interface fails
+		// TODO: Remove once we have IPv6 support on the interface
+		if prefix.Bits() == 1 {
+			intf = "lo0"
+		}
+	}
+
+	args := []string{"-n", action, inet, prefix.String()}
+	if nexthop.IsValid() {
+		args = append(args, nexthop.Unmap().String())
+	} else if intf != "" {
+		args = append(args, "-interface", intf)
+	}
+
+	out, err := exec.Command("route", args...).CombinedOutput()
+	log.Tracef("route %s: %s", strings.Join(args, " "), out)
+
+	if err != nil {
+		return fmt.Errorf("failed to %s route for %s: %w", action, prefix, err)
+	}
+	return nil
+}
diff --git a/client/internal/routemanager/systemops_darwin_test.go b/client/internal/routemanager/systemops_darwin_test.go
new file mode 100644
index 00000000000..5c5aaa24fe1
--- /dev/null
+++ b/client/internal/routemanager/systemops_darwin_test.go
@@ -0,0 +1,100 @@
+//go:build !ios
+
+package routemanager
+
+import (
+	"fmt"
+	"net"
+	"os/exec"
+	"regexp"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+var expectedVPNint = "utun100"
+var expectedExternalInt = "lo0"
+var expectedInternalInt = "lo0"
+
+func init() {
+	testCases = append(testCases, []testCase{
+		{
+			name:              "To more specific route without custom dialer via vpn",
+			destination:       "10.10.0.2:53",
+			expectedInterface: expectedVPNint,
+			dialer:            &net.Dialer{},
+			expectedPacket:    createPacketExpectation("100.64.0.1", 12345, "10.10.0.2", 53),
+		},
+	}...)
+}
+
+func createAndSetupDummyInterface(t *testing.T, intf string, ipAddressCIDR string) string {
+	t.Helper()
+
+	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 "lo0"
+}
+
+func addDummyRoute(t *testing.T, dstCIDR string, gw net.IP, _ 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
+}
+
+func setupDummyInterfacesAndRoutes(t *testing.T) {
+	t.Helper()
+
+	defaultDummy := createAndSetupDummyInterface(t, expectedExternalInt, "192.168.0.1/24")
+	addDummyRoute(t, "0.0.0.0/0", net.IPv4(192, 168, 0, 1), defaultDummy)
+
+	otherDummy := createAndSetupDummyInterface(t, expectedInternalInt, "192.168.1.1/24")
+	addDummyRoute(t, "10.0.0.0/8", net.IPv4(192, 168, 1, 1), otherDummy)
+}
diff --git a/client/internal/routemanager/systemops_ios.go b/client/internal/routemanager/systemops_ios.go
index 291826780af..34d2d270fe3 100644
--- a/client/internal/routemanager/systemops_ios.go
+++ b/client/internal/routemanager/systemops_ios.go
@@ -1,13 +1,33 @@
 package routemanager
 
 import (
+	"net"
 	"net/netip"
+	"runtime"
+
+	log "github.com/sirupsen/logrus"
+
+	"github.com/netbirdio/netbird/client/internal/peer"
+	"github.com/netbirdio/netbird/iface"
 )
 
-func addToRouteTableIfNoExists(prefix netip.Prefix, addr, intf string) error {
+func setupRouting([]net.IP, *iface.WGIface) (peer.BeforeAddPeerHookFunc, peer.AfterRemovePeerHookFunc, error) {
+	return nil, nil, nil
+}
+
+func cleanupRouting() error {
+	return nil
+}
+
+func enableIPForwarding() error {
+	log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
+	return nil
+}
+
+func addVPNRoute(netip.Prefix, string) error {
 	return nil
 }
 
-func removeFromRouteTableIfNonSystem(prefix netip.Prefix, addr, intf string) error {
+func removeVPNRoute(netip.Prefix, string) error {
 	return nil
 }
diff --git a/client/internal/routemanager/systemops_linux.go b/client/internal/routemanager/systemops_linux.go
index 83af5008ae0..d21a3bfbfea 100644
--- a/client/internal/routemanager/systemops_linux.go
+++ b/client/internal/routemanager/systemops_linux.go
@@ -15,6 +15,8 @@ import (
 	log "github.com/sirupsen/logrus"
 	"github.com/vishvananda/netlink"
 
+	"github.com/netbirdio/netbird/client/internal/peer"
+	"github.com/netbirdio/netbird/iface"
 	nbnet "github.com/netbirdio/netbird/util/net"
 )
 
@@ -64,7 +66,7 @@ func getSetupRules() []ruleParams {
 // enabling VPN connectivity.
 //
 // The rules are inserted in reverse order, as rules are added from the bottom up in the rule list.
-func setupRouting() (err error) {
+func setupRouting([]net.IP, *iface.WGIface) (_ peer.BeforeAddPeerHookFunc, _ peer.AfterRemovePeerHookFunc, err error) {
 	if err = addRoutingTableName(); err != nil {
 		log.Errorf("Error adding routing table name: %v", err)
 	}
@@ -80,11 +82,11 @@ func setupRouting() (err error) {
 	rules := getSetupRules()
 	for _, rule := range rules {
 		if err := addRule(rule); err != nil {
-			return fmt.Errorf("%s: %w", rule.description, err)
+			return nil, nil, fmt.Errorf("%s: %w", rule.description, err)
 		}
 	}
 
-	return nil
+	return nil, nil, nil
 }
 
 // cleanupRouting performs a thorough cleanup of the routing configuration established by 'setupRouting'.
@@ -110,7 +112,7 @@ func cleanupRouting() error {
 	return result.ErrorOrNil()
 }
 
-func addToRouteTableIfNoExists(prefix netip.Prefix, _ string, intf string) error {
+func addVPNRoute(prefix netip.Prefix, intf string) error {
 	// No need to check if routes exist as main table takes precedence over the VPN table via Rule 2
 
 	// TODO remove this once we have ipv6 support
@@ -125,7 +127,7 @@ func addToRouteTableIfNoExists(prefix netip.Prefix, _ string, intf string) error
 	return nil
 }
 
-func removeFromRouteTableIfNonSystem(prefix netip.Prefix, _ string, intf string) error {
+func removeVPNRoute(prefix netip.Prefix, intf string) error {
 	// TODO remove this once we have ipv6 support
 	if prefix == defaultv4 {
 		if err := removeUnreachableRoute(&defaultv6, NetbirdVPNTableID, netlink.FAMILY_V6); err != nil {
@@ -138,10 +140,6 @@ func removeFromRouteTableIfNonSystem(prefix netip.Prefix, _ string, intf string)
 	return nil
 }
 
-func getRoutesFromTable() ([]netip.Prefix, error) {
-	return getRoutes(NetbirdVPNTableID, netlink.FAMILY_V4)
-}
-
 // addRoute adds a route to a specific routing table identified by tableID.
 func addRoute(prefix *netip.Prefix, addr, intf *string, tableID, family int) error {
 	route := &netlink.Route{
@@ -263,34 +261,6 @@ func flushRoutes(tableID, family int) error {
 	return result.ErrorOrNil()
 }
 
-// getRoutes fetches routes from a specific routing table identified by tableID.
-func getRoutes(tableID, family int) ([]netip.Prefix, error) {
-	var prefixList []netip.Prefix
-
-	routes, err := netlink.RouteListFiltered(family, &netlink.Route{Table: tableID}, netlink.RT_FILTER_TABLE)
-	if err != nil {
-		return nil, fmt.Errorf("list routes from table %d: %v", tableID, err)
-	}
-
-	for _, route := range routes {
-		if route.Dst != nil {
-			addr, ok := netip.AddrFromSlice(route.Dst.IP)
-			if !ok {
-				return nil, fmt.Errorf("parse route destination IP: %v", route.Dst.IP)
-			}
-
-			ones, _ := route.Dst.Mask.Size()
-
-			prefix := netip.PrefixFrom(addr, ones)
-			if prefix.IsValid() {
-				prefixList = append(prefixList, prefix)
-			}
-		}
-	}
-
-	return prefixList, nil
-}
-
 func enableIPForwarding() error {
 	bytes, err := os.ReadFile(ipv4ForwardingPath)
 	if err != nil {
diff --git a/client/internal/routemanager/systemops_linux_test.go b/client/internal/routemanager/systemops_linux_test.go
index 96e43d20f0b..50a02401a68 100644
--- a/client/internal/routemanager/systemops_linux_test.go
+++ b/client/internal/routemanager/systemops_linux_test.go
@@ -6,34 +6,40 @@ import (
 	"errors"
 	"fmt"
 	"net"
-	"net/netip"
 	"os"
 	"strings"
 	"syscall"
 	"testing"
-	"time"
 
-	"github.com/gopacket/gopacket"
-	"github.com/gopacket/gopacket/layers"
-	"github.com/gopacket/gopacket/pcap"
-	"github.com/miekg/dns"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
 	"github.com/vishvananda/netlink"
-	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
-
-	"github.com/netbirdio/netbird/client/internal/stdnet"
-	"github.com/netbirdio/netbird/iface"
-	nbnet "github.com/netbirdio/netbird/util/net"
 )
 
-type PacketExpectation struct {
-	SrcIP   net.IP
-	DstIP   net.IP
-	SrcPort int
-	DstPort int
-	UDP     bool
-	TCP     bool
+var expectedVPNint = "wgtest0"
+var expectedLoopbackInt = "lo"
+var expectedExternalInt = "dummyext0"
+var expectedInternalInt = "dummyint0"
+
+var errRouteNotFound = fmt.Errorf("route not found")
+
+func init() {
+	testCases = append(testCases, []testCase{
+		{
+			name:              "To more specific route without custom dialer via physical interface",
+			destination:       "10.10.0.2:53",
+			expectedInterface: expectedInternalInt,
+			dialer:            &net.Dialer{},
+			expectedPacket:    createPacketExpectation("192.168.1.1", 12345, "10.10.0.2", 53),
+		},
+		{
+			name:              "To more specific route (local) without custom dialer via physical interface",
+			destination:       "127.0.10.1:53",
+			expectedInterface: expectedLoopbackInt,
+			dialer:            &net.Dialer{},
+			expectedPacket:    createPacketExpectation("127.0.0.1", 12345, "127.0.10.1", 53),
+		},
+	}...)
 }
 
 func TestEntryExists(t *testing.T) {
@@ -92,157 +98,7 @@ func TestEntryExists(t *testing.T) {
 	}
 }
 
-func TestRoutingWithTables(t *testing.T) {
-	testCases := []struct {
-		name              string
-		destination       string
-		captureInterface  string
-		dialer            *net.Dialer
-		packetExpectation PacketExpectation
-	}{
-		{
-			name:              "To external host without fwmark via vpn",
-			destination:       "192.0.2.1:53",
-			captureInterface:  "wgtest0",
-			dialer:            &net.Dialer{},
-			packetExpectation: createPacketExpectation("100.64.0.1", 12345, "192.0.2.1", 53),
-		},
-		{
-			name:              "To external host with fwmark via physical interface",
-			destination:       "192.0.2.1:53",
-			captureInterface:  "dummyext0",
-			dialer:            nbnet.NewDialer(),
-			packetExpectation: createPacketExpectation("192.168.0.1", 12345, "192.0.2.1", 53),
-		},
-
-		{
-			name:              "To duplicate internal route with fwmark via physical interface",
-			destination:       "10.0.0.1:53",
-			captureInterface:  "dummyint0",
-			dialer:            nbnet.NewDialer(),
-			packetExpectation: createPacketExpectation("192.168.1.1", 12345, "10.0.0.1", 53),
-		},
-		{
-			name:              "To duplicate internal route without fwmark via physical interface", // local route takes precedence
-			destination:       "10.0.0.1:53",
-			captureInterface:  "dummyint0",
-			dialer:            &net.Dialer{},
-			packetExpectation: createPacketExpectation("192.168.1.1", 12345, "10.0.0.1", 53),
-		},
-
-		{
-			name:              "To unique vpn route with fwmark via physical interface",
-			destination:       "172.16.0.1:53",
-			captureInterface:  "dummyext0",
-			dialer:            nbnet.NewDialer(),
-			packetExpectation: createPacketExpectation("192.168.0.1", 12345, "172.16.0.1", 53),
-		},
-		{
-			name:              "To unique vpn route without fwmark via vpn",
-			destination:       "172.16.0.1:53",
-			captureInterface:  "wgtest0",
-			dialer:            &net.Dialer{},
-			packetExpectation: createPacketExpectation("100.64.0.1", 12345, "172.16.0.1", 53),
-		},
-
-		{
-			name:              "To more specific route without fwmark via vpn interface",
-			destination:       "10.10.0.1:53",
-			captureInterface:  "dummyint0",
-			dialer:            &net.Dialer{},
-			packetExpectation: createPacketExpectation("192.168.1.1", 12345, "10.10.0.1", 53),
-		},
-
-		{
-			name:              "To more specific route (local) without fwmark via physical interface",
-			destination:       "127.0.10.1:53",
-			captureInterface:  "lo",
-			dialer:            &net.Dialer{},
-			packetExpectation: createPacketExpectation("127.0.0.1", 12345, "127.0.10.1", 53),
-		},
-	}
-
-	for _, tc := range testCases {
-		t.Run(tc.name, func(t *testing.T) {
-			wgIface, _, _ := setupTestEnv(t)
-
-			// default route exists in main table and vpn table
-			err := addToRouteTableIfNoExists(netip.MustParsePrefix("0.0.0.0/0"), wgIface.Address().IP.String(), wgIface.Name())
-			require.NoError(t, err, "addToRouteTableIfNoExists should not return err")
-
-			// 10.0.0.0/8 route exists in main table and vpn table
-			err = addToRouteTableIfNoExists(netip.MustParsePrefix("10.0.0.0/8"), wgIface.Address().IP.String(), wgIface.Name())
-			require.NoError(t, err, "addToRouteTableIfNoExists should not return err")
-
-			// 10.10.0.0/24 more specific route exists in vpn table
-			err = addToRouteTableIfNoExists(netip.MustParsePrefix("10.10.0.0/24"), wgIface.Address().IP.String(), wgIface.Name())
-			require.NoError(t, err, "addToRouteTableIfNoExists should not return err")
-
-			// 127.0.10.0/24 more specific route exists in vpn table
-			err = addToRouteTableIfNoExists(netip.MustParsePrefix("127.0.10.0/24"), wgIface.Address().IP.String(), wgIface.Name())
-			require.NoError(t, err, "addToRouteTableIfNoExists should not return err")
-
-			// unique route in vpn table
-			err = addToRouteTableIfNoExists(netip.MustParsePrefix("172.16.0.0/16"), wgIface.Address().IP.String(), wgIface.Name())
-			require.NoError(t, err, "addToRouteTableIfNoExists should not return err")
-
-			filter := createBPFFilter(tc.destination)
-			handle := startPacketCapture(t, tc.captureInterface, filter)
-
-			sendTestPacket(t, tc.destination, tc.packetExpectation.SrcPort, tc.dialer)
-
-			packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
-			packet, err := packetSource.NextPacket()
-			require.NoError(t, err)
-
-			verifyPacket(t, packet, tc.packetExpectation)
-		})
-	}
-}
-
-func verifyPacket(t *testing.T, packet gopacket.Packet, exp PacketExpectation) {
-	t.Helper()
-
-	ipLayer := packet.Layer(layers.LayerTypeIPv4)
-	require.NotNil(t, ipLayer, "Expected IPv4 layer not found in packet")
-
-	ip, ok := ipLayer.(*layers.IPv4)
-	require.True(t, ok, "Failed to cast to IPv4 layer")
-
-	// Convert both source and destination IP addresses to 16-byte representation
-	expectedSrcIP := exp.SrcIP.To16()
-	actualSrcIP := ip.SrcIP.To16()
-	assert.Equal(t, expectedSrcIP, actualSrcIP, "Source IP mismatch")
-
-	expectedDstIP := exp.DstIP.To16()
-	actualDstIP := ip.DstIP.To16()
-	assert.Equal(t, expectedDstIP, actualDstIP, "Destination IP mismatch")
-
-	if exp.UDP {
-		udpLayer := packet.Layer(layers.LayerTypeUDP)
-		require.NotNil(t, udpLayer, "Expected UDP layer not found in packet")
-
-		udp, ok := udpLayer.(*layers.UDP)
-		require.True(t, ok, "Failed to cast to UDP layer")
-
-		assert.Equal(t, layers.UDPPort(exp.SrcPort), udp.SrcPort, "UDP source port mismatch")
-		assert.Equal(t, layers.UDPPort(exp.DstPort), udp.DstPort, "UDP destination port mismatch")
-	}
-
-	if exp.TCP {
-		tcpLayer := packet.Layer(layers.LayerTypeTCP)
-		require.NotNil(t, tcpLayer, "Expected TCP layer not found in packet")
-
-		tcp, ok := tcpLayer.(*layers.TCP)
-		require.True(t, ok, "Failed to cast to TCP layer")
-
-		assert.Equal(t, layers.TCPPort(exp.SrcPort), tcp.SrcPort, "TCP source port mismatch")
-		assert.Equal(t, layers.TCPPort(exp.DstPort), tcp.DstPort, "TCP destination port mismatch")
-	}
-
-}
-
-func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) *netlink.Dummy {
+func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) string {
 	t.Helper()
 
 	dummy := &netlink.Dummy{LinkAttrs: netlink.LinkAttrs{Name: interfaceName}}
@@ -264,35 +120,52 @@ func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR str
 		require.NoError(t, err)
 	}
 
-	return dummy
+	t.Cleanup(func() {
+		err := netlink.LinkDel(dummy)
+		assert.NoError(t, err)
+	})
+
+	return dummy.Name
 }
 
-func addDummyRoute(t *testing.T, dstCIDR string, gw net.IP, linkIndex int) {
+func addDummyRoute(t *testing.T, dstCIDR string, gw net.IP, intf string) {
 	t.Helper()
 
 	_, dstIPNet, err := net.ParseCIDR(dstCIDR)
 	require.NoError(t, err)
 
+	// Handle existing routes with metric 0
+	var originalNexthop net.IP
+	var originalLinkIndex int
 	if dstIPNet.String() == "0.0.0.0/0" {
-		gw, linkIndex, err := fetchOriginalGateway(netlink.FAMILY_V4)
-		if err != nil {
+		var err error
+		originalNexthop, originalLinkIndex, err = fetchOriginalGateway(netlink.FAMILY_V4)
+		if err != nil && !errors.Is(err, errRouteNotFound) {
 			t.Logf("Failed to fetch original gateway: %v", err)
 		}
 
-		// Handle existing routes with metric 0
-		err = netlink.RouteDel(&netlink.Route{Dst: dstIPNet, Priority: 0})
-		if err == nil {
-			t.Cleanup(func() {
-				err := netlink.RouteAdd(&netlink.Route{Dst: dstIPNet, Gw: gw, LinkIndex: linkIndex, Priority: 0})
-				if err != nil && !errors.Is(err, syscall.EEXIST) {
-					t.Fatalf("Failed to add route: %v", err)
-				}
-			})
-		} else if !errors.Is(err, syscall.ESRCH) {
-			t.Logf("Failed to delete route: %v", err)
+		if originalNexthop != nil {
+			err = netlink.RouteDel(&netlink.Route{Dst: dstIPNet, Priority: 0})
+			switch {
+			case err != nil && !errors.Is(err, syscall.ESRCH):
+				t.Logf("Failed to delete route: %v", err)
+			case err == nil:
+				t.Cleanup(func() {
+					err := netlink.RouteAdd(&netlink.Route{Dst: dstIPNet, Gw: originalNexthop, LinkIndex: originalLinkIndex, Priority: 0})
+					if err != nil && !errors.Is(err, syscall.EEXIST) {
+						t.Fatalf("Failed to add route: %v", err)
+					}
+				})
+			default:
+				t.Logf("Failed to delete route: %v", err)
+			}
 		}
 	}
 
+	link, err := netlink.LinkByName(intf)
+	require.NoError(t, err)
+	linkIndex := link.Attrs().Index
+
 	route := &netlink.Route{
 		Dst:       dstIPNet,
 		Gw:        gw,
@@ -307,9 +180,9 @@ func addDummyRoute(t *testing.T, dstCIDR string, gw net.IP, linkIndex int) {
 	if err != nil && !errors.Is(err, syscall.EEXIST) {
 		t.Fatalf("Failed to add route: %v", err)
 	}
+	require.NoError(t, err)
 }
 
-// fetchOriginalGateway returns the original gateway IP address and the interface index.
 func fetchOriginalGateway(family int) (net.IP, int, error) {
 	routes, err := netlink.RouteList(nil, family)
 	if err != nil {
@@ -317,153 +190,20 @@ func fetchOriginalGateway(family int) (net.IP, int, error) {
 	}
 
 	for _, route := range routes {
-		if route.Dst == nil {
+		if route.Dst == nil && route.Priority == 0 {
 			return route.Gw, route.LinkIndex, nil
 		}
 	}
 
-	return nil, 0, fmt.Errorf("default route not found")
+	return nil, 0, errRouteNotFound
 }
 
-func setupDummyInterfacesAndRoutes(t *testing.T) (string, string) {
+func setupDummyInterfacesAndRoutes(t *testing.T) {
 	t.Helper()
 
 	defaultDummy := createAndSetupDummyInterface(t, "dummyext0", "192.168.0.1/24")
-	addDummyRoute(t, "0.0.0.0/0", net.IPv4(192, 168, 0, 1), defaultDummy.Attrs().Index)
+	addDummyRoute(t, "0.0.0.0/0", net.IPv4(192, 168, 0, 1), defaultDummy)
 
 	otherDummy := createAndSetupDummyInterface(t, "dummyint0", "192.168.1.1/24")
-	addDummyRoute(t, "10.0.0.0/8", nil, otherDummy.Attrs().Index)
-
-	t.Cleanup(func() {
-		err := netlink.LinkDel(defaultDummy)
-		assert.NoError(t, err)
-		err = netlink.LinkDel(otherDummy)
-		assert.NoError(t, err)
-	})
-
-	return defaultDummy.Name, otherDummy.Name
-}
-
-func createWGInterface(t *testing.T, interfaceName, ipAddressCIDR string, listenPort int) *iface.WGIface {
-	t.Helper()
-
-	peerPrivateKey, err := wgtypes.GeneratePrivateKey()
-	require.NoError(t, err)
-
-	newNet, err := stdnet.NewNet(nil)
-	require.NoError(t, err)
-
-	wgInterface, err := iface.NewWGIFace(interfaceName, ipAddressCIDR, listenPort, peerPrivateKey.String(), iface.DefaultMTU, newNet, nil)
-	require.NoError(t, err, "should create testing WireGuard interface")
-
-	err = wgInterface.Create()
-	require.NoError(t, err, "should create testing WireGuard interface")
-
-	t.Cleanup(func() {
-		wgInterface.Close()
-	})
-
-	return wgInterface
-}
-
-func setupTestEnv(t *testing.T) (*iface.WGIface, string, string) {
-	t.Helper()
-
-	defaultDummy, otherDummy := setupDummyInterfacesAndRoutes(t)
-
-	wgIface := createWGInterface(t, "wgtest0", "100.64.0.1/24", 51820)
-	t.Cleanup(func() {
-		assert.NoError(t, wgIface.Close())
-	})
-
-	err := setupRouting()
-	require.NoError(t, err, "setupRouting should not return err")
-	t.Cleanup(func() {
-		assert.NoError(t, cleanupRouting())
-	})
-
-	return wgIface, defaultDummy, otherDummy
-}
-
-func startPacketCapture(t *testing.T, intf, filter string) *pcap.Handle {
-	t.Helper()
-
-	inactive, err := pcap.NewInactiveHandle(intf)
-	require.NoError(t, err, "Failed to create inactive pcap handle")
-	defer inactive.CleanUp()
-
-	err = inactive.SetSnapLen(1600)
-	require.NoError(t, err, "Failed to set snap length on inactive handle")
-
-	err = inactive.SetTimeout(time.Second * 10)
-	require.NoError(t, err, "Failed to set timeout on inactive handle")
-
-	err = inactive.SetImmediateMode(true)
-	require.NoError(t, err, "Failed to set immediate mode on inactive handle")
-
-	handle, err := inactive.Activate()
-	require.NoError(t, err, "Failed to activate pcap handle")
-	t.Cleanup(handle.Close)
-
-	err = handle.SetBPFFilter(filter)
-	require.NoError(t, err, "Failed to set BPF filter")
-
-	return handle
-}
-
-func sendTestPacket(t *testing.T, destination string, sourcePort int, dialer *net.Dialer) {
-	t.Helper()
-
-	if dialer == nil {
-		dialer = &net.Dialer{}
-	}
-
-	if sourcePort != 0 {
-		localUDPAddr := &net.UDPAddr{
-			IP:   net.IPv4zero,
-			Port: sourcePort,
-		}
-		dialer.LocalAddr = localUDPAddr
-	}
-
-	msg := new(dns.Msg)
-	msg.Id = dns.Id()
-	msg.RecursionDesired = true
-	msg.Question = []dns.Question{
-		{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET},
-	}
-
-	conn, err := dialer.Dial("udp", destination)
-	require.NoError(t, err, "Failed to dial UDP")
-	defer conn.Close()
-
-	data, err := msg.Pack()
-	require.NoError(t, err, "Failed to pack DNS message")
-
-	_, err = conn.Write(data)
-	if err != nil {
-		if strings.Contains(err.Error(), "required key not available") {
-			t.Logf("Ignoring WireGuard key error: %v", err)
-			return
-		}
-		t.Fatalf("Failed to send DNS query: %v", err)
-	}
-}
-
-func createBPFFilter(destination string) string {
-	host, port, err := net.SplitHostPort(destination)
-	if err != nil {
-		return fmt.Sprintf("udp and dst host %s and dst port %s", host, port)
-	}
-	return "udp"
-}
-
-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,
-	}
+	addDummyRoute(t, "10.0.0.0/8", net.IPv4(192, 168, 1, 1), otherDummy)
 }
diff --git a/client/internal/routemanager/systemops_nonandroid.go b/client/internal/routemanager/systemops_nonandroid.go
deleted file mode 100644
index 65f670ace17..00000000000
--- a/client/internal/routemanager/systemops_nonandroid.go
+++ /dev/null
@@ -1,148 +0,0 @@
-//go:build !android
-
-//nolint:unused
-package routemanager
-
-import (
-	"errors"
-	"fmt"
-	"net"
-	"net/netip"
-	"os/exec"
-	"runtime"
-
-	"github.com/libp2p/go-netroute"
-	log "github.com/sirupsen/logrus"
-)
-
-var errRouteNotFound = fmt.Errorf("route not found")
-
-func genericAddRouteForCurrentDefaultGateway(prefix netip.Prefix) error {
-	defaultGateway, err := getExistingRIBRouteGateway(defaultv4)
-	if err != nil && !errors.Is(err, errRouteNotFound) {
-		return fmt.Errorf("get existing route gateway: %s", err)
-	}
-
-	addr := netip.MustParseAddr(defaultGateway.String())
-
-	if !prefix.Contains(addr) {
-		log.Debugf("Skipping adding a new route for gateway %s because it is not in the network %s", addr, prefix)
-		return nil
-	}
-
-	gatewayPrefix := netip.PrefixFrom(addr, 32)
-
-	ok, err := existsInRouteTable(gatewayPrefix)
-	if err != nil {
-		return fmt.Errorf("unable to check if there is an existing route for gateway %s. error: %s", gatewayPrefix, err)
-	}
-
-	if ok {
-		log.Debugf("Skipping adding a new route for gateway %s because it already exists", gatewayPrefix)
-		return nil
-	}
-
-	gatewayHop, err := getExistingRIBRouteGateway(gatewayPrefix)
-	if err != nil && !errors.Is(err, errRouteNotFound) {
-		return fmt.Errorf("unable to get the next hop for the default gateway address. error: %s", err)
-	}
-	log.Debugf("Adding a new route for gateway %s with next hop %s", gatewayPrefix, gatewayHop)
-	return genericAddToRouteTable(gatewayPrefix, gatewayHop.String(), "")
-}
-
-func genericAddToRouteTableIfNoExists(prefix netip.Prefix, addr string, intf string) error {
-	ok, err := existsInRouteTable(prefix)
-	if err != nil {
-		return fmt.Errorf("exists in route table: %w", err)
-	}
-	if ok {
-		log.Warnf("Skipping adding a new route for network %s because it already exists", prefix)
-		return nil
-	}
-
-	ok, err = isSubRange(prefix)
-	if err != nil {
-		return fmt.Errorf("sub range: %w", err)
-	}
-
-	if ok {
-		err := genericAddRouteForCurrentDefaultGateway(prefix)
-		if err != nil {
-			log.Warnf("Unable to add route for current default gateway route. Will proceed without it. error: %s", err)
-		}
-	}
-
-	return genericAddToRouteTable(prefix, addr, intf)
-}
-
-func genericRemoveFromRouteTableIfNonSystem(prefix netip.Prefix, addr string, intf string) error {
-	return genericRemoveFromRouteTable(prefix, addr, intf)
-}
-
-func genericAddToRouteTable(prefix netip.Prefix, addr, _ string) error {
-	cmd := exec.Command("route", "add", prefix.String(), addr)
-	out, err := cmd.Output()
-	if err != nil {
-		return fmt.Errorf("add route: %w", err)
-	}
-	log.Debugf(string(out))
-	return nil
-}
-
-func genericRemoveFromRouteTable(prefix netip.Prefix, addr, _ string) error {
-	args := []string{"delete", prefix.String()}
-	if runtime.GOOS == "darwin" {
-		args = append(args, addr)
-	}
-	cmd := exec.Command("route", args...)
-	out, err := cmd.Output()
-	if err != nil {
-		return fmt.Errorf("remove route: %w", err)
-	}
-	log.Debugf(string(out))
-	return nil
-}
-
-func getExistingRIBRouteGateway(prefix netip.Prefix) (net.IP, error) {
-	r, err := netroute.New()
-	if err != nil {
-		return nil, fmt.Errorf("new netroute: %w", err)
-	}
-	_, gateway, preferredSrc, err := r.Route(prefix.Addr().AsSlice())
-	if err != nil {
-		log.Errorf("Getting routes returned an error: %v", err)
-		return nil, errRouteNotFound
-	}
-
-	if gateway == nil {
-		return preferredSrc, nil
-	}
-
-	return gateway, nil
-}
-
-func existsInRouteTable(prefix netip.Prefix) (bool, error) {
-	routes, err := getRoutesFromTable()
-	if err != nil {
-		return false, fmt.Errorf("get routes from table: %w", err)
-	}
-	for _, tableRoute := range routes {
-		if tableRoute == prefix {
-			return true, nil
-		}
-	}
-	return false, nil
-}
-
-func isSubRange(prefix netip.Prefix) (bool, error) {
-	routes, err := getRoutesFromTable()
-	if err != nil {
-		return false, fmt.Errorf("get routes from table: %w", err)
-	}
-	for _, tableRoute := range routes {
-		if isPrefixSupported(tableRoute) && tableRoute.Contains(prefix.Addr()) && tableRoute.Bits() < prefix.Bits() {
-			return true, nil
-		}
-	}
-	return false, nil
-}
diff --git a/client/internal/routemanager/systemops_nonandroid_test.go b/client/internal/routemanager/systemops_nonandroid_test.go
deleted file mode 100644
index aae5e5faa16..00000000000
--- a/client/internal/routemanager/systemops_nonandroid_test.go
+++ /dev/null
@@ -1,282 +0,0 @@
-//go:build !android
-
-package routemanager
-
-import (
-	"bytes"
-	"fmt"
-	"net"
-	"net/netip"
-	"os"
-	"os/exec"
-	"runtime"
-	"strings"
-	"testing"
-
-	"github.com/pion/transport/v3/stdnet"
-	log "github.com/sirupsen/logrus"
-	"github.com/stretchr/testify/assert"
-	"github.com/stretchr/testify/require"
-	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
-
-	"github.com/netbirdio/netbird/iface"
-)
-
-func assertWGOutInterface(t *testing.T, prefix netip.Prefix, wgIface *iface.WGIface, invert bool) {
-	t.Helper()
-
-	if runtime.GOOS == "linux" {
-		outIntf, err := getOutgoingInterfaceLinux(prefix.Addr().String())
-		require.NoError(t, err, "getOutgoingInterfaceLinux should not return error")
-		if invert {
-			require.NotEqual(t, wgIface.Name(), outIntf, "outgoing interface should not be the wireguard interface")
-		} else {
-			require.Equal(t, wgIface.Name(), outIntf, "outgoing interface should be the wireguard interface")
-		}
-		return
-	}
-
-	prefixGateway, err := getExistingRIBRouteGateway(prefix)
-	require.NoError(t, err, "getExistingRIBRouteGateway should not return err")
-	if invert {
-		assert.NotEqual(t, wgIface.Address().IP.String(), prefixGateway.String(), "route should not point to wireguard interface IP")
-	} else {
-		assert.Equal(t, wgIface.Address().IP.String(), prefixGateway.String(), "route should point to wireguard interface IP")
-	}
-}
-
-func getOutgoingInterfaceLinux(destination string) (string, error) {
-	cmd := exec.Command("ip", "route", "get", destination)
-	output, err := cmd.Output()
-	if err != nil {
-		return "", fmt.Errorf("executing ip route get: %w", err)
-	}
-
-	return parseOutgoingInterface(string(output)), nil
-}
-
-func parseOutgoingInterface(routeGetOutput string) string {
-	fields := strings.Fields(routeGetOutput)
-	for i, field := range fields {
-		if field == "dev" && i+1 < len(fields) {
-			return fields[i+1]
-		}
-	}
-	return ""
-}
-
-func TestAddRemoveRoutes(t *testing.T) {
-	testCases := []struct {
-		name                   string
-		prefix                 netip.Prefix
-		shouldRouteToWireguard bool
-		shouldBeRemoved        bool
-	}{
-		{
-			name:                   "Should Add And Remove Route 100.66.120.0/24",
-			prefix:                 netip.MustParsePrefix("100.66.120.0/24"),
-			shouldRouteToWireguard: true,
-			shouldBeRemoved:        true,
-		},
-		{
-			name:                   "Should Not Add Or Remove Route 127.0.0.1/32",
-			prefix:                 netip.MustParsePrefix("127.0.0.1/32"),
-			shouldRouteToWireguard: false,
-			shouldBeRemoved:        false,
-		},
-	}
-
-	for n, testCase := range testCases {
-		t.Run(testCase.name, func(t *testing.T) {
-			peerPrivateKey, _ := wgtypes.GeneratePrivateKey()
-			newNet, err := stdnet.NewNet()
-			if err != nil {
-				t.Fatal(err)
-			}
-			wgInterface, err := iface.NewWGIFace(fmt.Sprintf("utun53%d", n), "100.65.75.2/24", 33100, peerPrivateKey.String(), iface.DefaultMTU, newNet, nil)
-			require.NoError(t, err, "should create testing WGIface interface")
-			defer wgInterface.Close()
-
-			err = wgInterface.Create()
-			require.NoError(t, err, "should create testing wireguard interface")
-
-			require.NoError(t, setupRouting())
-			t.Cleanup(func() {
-				assert.NoError(t, cleanupRouting())
-			})
-
-			err = addToRouteTableIfNoExists(testCase.prefix, wgInterface.Address().IP.String(), wgInterface.Name())
-			require.NoError(t, err, "addToRouteTableIfNoExists should not return err")
-
-			if testCase.shouldRouteToWireguard {
-				assertWGOutInterface(t, testCase.prefix, wgInterface, false)
-			} else {
-				assertWGOutInterface(t, testCase.prefix, wgInterface, true)
-			}
-			exists, err := existsInRouteTable(testCase.prefix)
-			require.NoError(t, err, "existsInRouteTable should not return err")
-			if exists && testCase.shouldRouteToWireguard {
-				err = removeFromRouteTableIfNonSystem(testCase.prefix, wgInterface.Address().IP.String(), wgInterface.Name())
-				require.NoError(t, err, "removeFromRouteTableIfNonSystem should not return err")
-
-				prefixGateway, err := getExistingRIBRouteGateway(testCase.prefix)
-				require.NoError(t, err, "getExistingRIBRouteGateway should not return err")
-
-				internetGateway, err := getExistingRIBRouteGateway(netip.MustParsePrefix("0.0.0.0/0"))
-				require.NoError(t, err)
-
-				if testCase.shouldBeRemoved {
-					require.Equal(t, internetGateway, prefixGateway, "route should be pointing to default internet gateway")
-				} else {
-					require.NotEqual(t, internetGateway, prefixGateway, "route should be pointing to a different gateway than the internet gateway")
-				}
-			}
-		})
-	}
-}
-
-func TestGetExistingRIBRouteGateway(t *testing.T) {
-	gateway, err := getExistingRIBRouteGateway(netip.MustParsePrefix("0.0.0.0/0"))
-	if err != nil {
-		t.Fatal("shouldn't return error when fetching the gateway: ", err)
-	}
-	if gateway == nil {
-		t.Fatal("should return a gateway")
-	}
-	addresses, err := net.InterfaceAddrs()
-	if err != nil {
-		t.Fatal("shouldn't return error when fetching interface addresses: ", err)
-	}
-
-	var testingIP string
-	var testingPrefix netip.Prefix
-	for _, address := range addresses {
-		if address.Network() != "ip+net" {
-			continue
-		}
-		prefix := netip.MustParsePrefix(address.String())
-		if !prefix.Addr().IsLoopback() && prefix.Addr().Is4() {
-			testingIP = prefix.Addr().String()
-			testingPrefix = prefix.Masked()
-			break
-		}
-	}
-
-	localIP, err := getExistingRIBRouteGateway(testingPrefix)
-	if err != nil {
-		t.Fatal("shouldn't return error: ", err)
-	}
-	if localIP == nil {
-		t.Fatal("should return a gateway for local network")
-	}
-	if localIP.String() == gateway.String() {
-		t.Fatal("local ip should not match with gateway IP")
-	}
-	if localIP.String() != testingIP {
-		t.Fatalf("local ip should match with testing IP: want %s got %s", testingIP, localIP.String())
-	}
-}
-
-func TestAddExistAndRemoveRouteNonAndroid(t *testing.T) {
-	defaultGateway, err := getExistingRIBRouteGateway(netip.MustParsePrefix("0.0.0.0/0"))
-	t.Log("defaultGateway: ", defaultGateway)
-	if err != nil {
-		t.Fatal("shouldn't return error when fetching the gateway: ", err)
-	}
-	testCases := []struct {
-		name              string
-		prefix            netip.Prefix
-		preExistingPrefix netip.Prefix
-		shouldAddRoute    bool
-	}{
-		{
-			name:           "Should Add And Remove random Route",
-			prefix:         netip.MustParsePrefix("99.99.99.99/32"),
-			shouldAddRoute: true,
-		},
-		{
-			name:           "Should Not Add Route if overlaps with default gateway",
-			prefix:         netip.MustParsePrefix(defaultGateway.String() + "/31"),
-			shouldAddRoute: false,
-		},
-		{
-			name:              "Should Add Route if bigger network exists",
-			prefix:            netip.MustParsePrefix("100.100.100.0/24"),
-			preExistingPrefix: netip.MustParsePrefix("100.100.0.0/16"),
-			shouldAddRoute:    true,
-		},
-		{
-			name:              "Should Add Route if smaller network exists",
-			prefix:            netip.MustParsePrefix("100.100.0.0/16"),
-			preExistingPrefix: netip.MustParsePrefix("100.100.100.0/24"),
-			shouldAddRoute:    true,
-		},
-		{
-			name:              "Should Not Add Route if same network exists",
-			prefix:            netip.MustParsePrefix("100.100.0.0/16"),
-			preExistingPrefix: netip.MustParsePrefix("100.100.0.0/16"),
-			shouldAddRoute:    false,
-		},
-	}
-
-	for n, testCase := range testCases {
-		var buf bytes.Buffer
-		log.SetOutput(&buf)
-		defer func() {
-			log.SetOutput(os.Stderr)
-		}()
-		t.Run(testCase.name, func(t *testing.T) {
-			peerPrivateKey, _ := wgtypes.GeneratePrivateKey()
-			newNet, err := stdnet.NewNet()
-			if err != nil {
-				t.Fatal(err)
-			}
-			wgInterface, err := iface.NewWGIFace(fmt.Sprintf("utun53%d", n), "100.65.75.2/24", 33100, peerPrivateKey.String(), iface.DefaultMTU, newNet, nil)
-			require.NoError(t, err, "should create testing WGIface interface")
-			defer wgInterface.Close()
-
-			err = wgInterface.Create()
-			require.NoError(t, err, "should create testing wireguard interface")
-
-			require.NoError(t, setupRouting())
-			t.Cleanup(func() {
-				assert.NoError(t, cleanupRouting())
-			})
-
-			MockAddr := wgInterface.Address().IP.String()
-
-			// Prepare the environment
-			if testCase.preExistingPrefix.IsValid() {
-				err := addToRouteTableIfNoExists(testCase.preExistingPrefix, MockAddr, wgInterface.Name())
-				require.NoError(t, err, "should not return err when adding pre-existing route")
-			}
-
-			// Add the route
-			err = addToRouteTableIfNoExists(testCase.prefix, MockAddr, wgInterface.Name())
-			require.NoError(t, err, "should not return err when adding route")
-
-			if testCase.shouldAddRoute {
-				// test if route exists after adding
-				ok, err := existsInRouteTable(testCase.prefix)
-				require.NoError(t, err, "should not return err")
-				require.True(t, ok, "route should exist")
-
-				// remove route again if added
-				err = removeFromRouteTableIfNonSystem(testCase.prefix, MockAddr, wgInterface.Name())
-				require.NoError(t, err, "should not return err")
-			}
-
-			// route should either not have been added or should have been removed
-			// In case of already existing route, it should not have been added (but still exist)
-			ok, err := existsInRouteTable(testCase.prefix)
-			t.Log("Buffer string: ", buf.String())
-			require.NoError(t, err, "should not return err")
-
-			// Linux uses a separate routing table, so the route can exist in both tables.
-			// The main routing table takes precedence over the wireguard routing table.
-			if !strings.Contains(buf.String(), "because it already exists") && runtime.GOOS != "linux" {
-				require.False(t, ok, "route should not exist")
-			}
-		})
-	}
-}
diff --git a/client/internal/routemanager/systemops_nonlinux.go b/client/internal/routemanager/systemops_nonlinux.go
index d793f0fbde0..4bc186f215e 100644
--- a/client/internal/routemanager/systemops_nonlinux.go
+++ b/client/internal/routemanager/systemops_nonlinux.go
@@ -1,22 +1,416 @@
-//go:build !linux || android
+//go:build !linux && !ios
 
 package routemanager
 
 import (
+	"context"
+	"errors"
+	"fmt"
+	"net"
+	"net/netip"
 	"runtime"
 
+	"github.com/hashicorp/go-multierror"
+	"github.com/libp2p/go-netroute"
 	log "github.com/sirupsen/logrus"
+
+	"github.com/netbirdio/netbird/client/internal/peer"
+	"github.com/netbirdio/netbird/iface"
+	nbnet "github.com/netbirdio/netbird/util/net"
 )
 
-func setupRouting() error {
+var splitDefaultv4_1 = netip.PrefixFrom(netip.IPv4Unspecified(), 1)
+var splitDefaultv4_2 = netip.PrefixFrom(netip.AddrFrom4([4]byte{128}), 1)
+var splitDefaultv6_1 = netip.PrefixFrom(netip.IPv6Unspecified(), 1)
+var splitDefaultv6_2 = netip.PrefixFrom(netip.AddrFrom16([16]byte{0x80}), 1)
+
+var errRouteNotFound = fmt.Errorf("route not found")
+
+func enableIPForwarding() error {
+	log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
 	return nil
 }
 
-func cleanupRouting() error {
-	return nil
+// TODO: fix: for default our wg address now appears as the default gw
+func addRouteForCurrentDefaultGateway(prefix netip.Prefix) error {
+	addr := netip.IPv4Unspecified()
+	if prefix.Addr().Is6() {
+		addr = netip.IPv6Unspecified()
+	}
+
+	defaultGateway, _, err := getNextHop(addr)
+	if err != nil && !errors.Is(err, errRouteNotFound) {
+		return fmt.Errorf("get existing route gateway: %s", err)
+	}
+
+	if !prefix.Contains(defaultGateway) {
+		log.Debugf("Skipping adding a new route for gateway %s because it is not in the network %s", defaultGateway, prefix)
+		return nil
+	}
+
+	gatewayPrefix := netip.PrefixFrom(defaultGateway, 32)
+	if defaultGateway.Is6() {
+		gatewayPrefix = netip.PrefixFrom(defaultGateway, 128)
+	}
+
+	ok, err := existsInRouteTable(gatewayPrefix)
+	if err != nil {
+		return fmt.Errorf("unable to check if there is an existing route for gateway %s. error: %s", gatewayPrefix, err)
+	}
+
+	if ok {
+		log.Debugf("Skipping adding a new route for gateway %s because it already exists", gatewayPrefix)
+		return nil
+	}
+
+	var exitIntf string
+	gatewayHop, intf, err := getNextHop(defaultGateway)
+	if err != nil && !errors.Is(err, errRouteNotFound) {
+		return fmt.Errorf("unable to get the next hop for the default gateway address. error: %s", err)
+	}
+	if intf != nil {
+		exitIntf = intf.Name
+	}
+
+	log.Debugf("Adding a new route for gateway %s with next hop %s", gatewayPrefix, gatewayHop)
+	return addToRouteTable(gatewayPrefix, gatewayHop, exitIntf)
 }
 
-func enableIPForwarding() error {
-	log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
+func getNextHop(ip netip.Addr) (netip.Addr, *net.Interface, error) {
+	r, err := netroute.New()
+	if err != nil {
+		return netip.Addr{}, nil, fmt.Errorf("new netroute: %w", err)
+	}
+	intf, gateway, preferredSrc, err := r.Route(ip.AsSlice())
+	if err != nil {
+		log.Errorf("Getting routes returned an error: %v", err)
+		return netip.Addr{}, nil, errRouteNotFound
+	}
+
+	log.Debugf("Route for %s: interface %v, nexthop %v, preferred source %v", ip, intf, gateway, preferredSrc)
+	if gateway == nil {
+		if preferredSrc == nil {
+			return netip.Addr{}, nil, errRouteNotFound
+		}
+		log.Debugf("No next hop found for ip %s, using preferred source %s", ip, preferredSrc)
+
+		addr, ok := netip.AddrFromSlice(preferredSrc)
+		if !ok {
+			return netip.Addr{}, nil, fmt.Errorf("failed to parse IP address: %s", preferredSrc)
+		}
+		return addr.Unmap(), intf, nil
+	}
+
+	addr, ok := netip.AddrFromSlice(gateway)
+	if !ok {
+		return netip.Addr{}, nil, fmt.Errorf("failed to parse IP address: %s", gateway)
+	}
+
+	return addr.Unmap(), intf, nil
+}
+
+func existsInRouteTable(prefix netip.Prefix) (bool, error) {
+	routes, err := getRoutesFromTable()
+	if err != nil {
+		return false, fmt.Errorf("get routes from table: %w", err)
+	}
+	for _, tableRoute := range routes {
+		if tableRoute == prefix {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+func isSubRange(prefix netip.Prefix) (bool, error) {
+	routes, err := getRoutesFromTable()
+	if err != nil {
+		return false, fmt.Errorf("get routes from table: %w", err)
+	}
+	for _, tableRoute := range routes {
+		if tableRoute.Bits() > minRangeBits && tableRoute.Contains(prefix.Addr()) && tableRoute.Bits() < prefix.Bits() {
+			return true, nil
+		}
+	}
+	return false, nil
+}
+
+// getRouteToNonVPNIntf returns the next hop and interface for the given prefix.
+// If the next hop or interface is pointing to the VPN interface, it will return an error
+func addRouteToNonVPNIntf(
+	prefix netip.Prefix,
+	vpnIntf *iface.WGIface,
+	initialNextHop netip.Addr,
+	initialIntf *net.Interface,
+) (netip.Addr, string, error) {
+	addr := prefix.Addr()
+	switch {
+	case addr.IsLoopback():
+		return netip.Addr{}, "", fmt.Errorf("adding route for loopback address %s is not allowed", prefix)
+	case addr.IsLinkLocalUnicast():
+		return netip.Addr{}, "", fmt.Errorf("adding route for link-local unicast address %s is not allowed", prefix)
+	case addr.IsLinkLocalMulticast():
+		return netip.Addr{}, "", fmt.Errorf("adding route for link-local multicast address %s is not allowed", prefix)
+	case addr.IsInterfaceLocalMulticast():
+		return netip.Addr{}, "", fmt.Errorf("adding route for interface-local multicast address %s is not allowed", prefix)
+	case addr.IsUnspecified():
+		return netip.Addr{}, "", fmt.Errorf("adding route for unspecified address %s is not allowed", prefix)
+	case addr.IsMulticast():
+		return netip.Addr{}, "", fmt.Errorf("adding route for multicast address %s is not allowed", prefix)
+	}
+
+	// Determine the exit interface and next hop for the prefix, so we can add a specific route
+	nexthop, intf, err := getNextHop(addr)
+	if err != nil {
+		return netip.Addr{}, "", fmt.Errorf("get next hop: %s", err)
+	}
+
+	log.Debugf("Found next hop %s for prefix %s with interface %v", nexthop, prefix, intf)
+	exitNextHop := nexthop
+	var exitIntf string
+	if intf != nil {
+		exitIntf = intf.Name
+	}
+
+	vpnAddr, ok := netip.AddrFromSlice(vpnIntf.Address().IP)
+	if !ok {
+		return netip.Addr{}, "", fmt.Errorf("failed to convert vpn address to netip.Addr")
+	}
+
+	// if next hop is the VPN address or the interface is the VPN interface, we should use the initial values
+	if exitNextHop == vpnAddr || exitIntf == vpnIntf.Name() {
+		log.Debugf("Route for prefix %s is pointing to the VPN interface", prefix)
+		exitNextHop = initialNextHop
+		if initialIntf != nil {
+			exitIntf = initialIntf.Name
+		}
+	}
+
+	log.Debugf("Adding a new route for prefix %s with next hop %s", prefix, exitNextHop)
+	if err := addToRouteTable(prefix, exitNextHop, exitIntf); err != nil {
+		return netip.Addr{}, "", fmt.Errorf("add route to table: %w", err)
+	}
+
+	return exitNextHop, exitIntf, nil
+}
+
+// addVPNRoute adds a new route to the vpn interface, it splits the default prefix
+// in two /1 prefixes to avoid replacing the existing default route
+func addVPNRoute(prefix netip.Prefix, intf string) error {
+	if prefix == defaultv4 {
+		if err := addToRouteTable(splitDefaultv4_1, netip.Addr{}, intf); err != nil {
+			return err
+		}
+		if err := addToRouteTable(splitDefaultv4_2, netip.Addr{}, intf); err != nil {
+			if err2 := removeFromRouteTable(splitDefaultv4_1, netip.Addr{}, intf); err2 != nil {
+				log.Warnf("Failed to rollback route addition: %s", err2)
+			}
+			return err
+		}
+
+		// TODO: remove once IPv6 is supported on the interface
+		if err := addToRouteTable(splitDefaultv6_1, netip.Addr{}, intf); err != nil {
+			return fmt.Errorf("add unreachable route split 1: %w", err)
+		}
+		if err := addToRouteTable(splitDefaultv6_2, netip.Addr{}, intf); err != nil {
+			if err2 := removeFromRouteTable(splitDefaultv6_1, netip.Addr{}, intf); err2 != nil {
+				log.Warnf("Failed to rollback route addition: %s", err2)
+			}
+			return fmt.Errorf("add unreachable route split 2: %w", err)
+		}
+
+		return nil
+	} else if prefix == defaultv6 {
+		if err := addToRouteTable(splitDefaultv6_1, netip.Addr{}, intf); err != nil {
+			return fmt.Errorf("add unreachable route split 1: %w", err)
+		}
+		if err := addToRouteTable(splitDefaultv6_2, netip.Addr{}, intf); err != nil {
+			if err2 := removeFromRouteTable(splitDefaultv6_1, netip.Addr{}, intf); err2 != nil {
+				log.Warnf("Failed to rollback route addition: %s", err2)
+			}
+			return fmt.Errorf("add unreachable route split 2: %w", err)
+		}
+
+		return nil
+	}
+
+	return addNonExistingRoute(prefix, intf)
+}
+
+// addNonExistingRoute adds a new route to the vpn interface if it doesn't exist in the current routing table
+func addNonExistingRoute(prefix netip.Prefix, intf string) error {
+	ok, err := existsInRouteTable(prefix)
+	if err != nil {
+		return fmt.Errorf("exists in route table: %w", err)
+	}
+	if ok {
+		log.Warnf("Skipping adding a new route for network %s because it already exists", prefix)
+		return nil
+	}
+
+	ok, err = isSubRange(prefix)
+	if err != nil {
+		return fmt.Errorf("sub range: %w", err)
+	}
+
+	if ok {
+		err := addRouteForCurrentDefaultGateway(prefix)
+		if err != nil {
+			log.Warnf("Unable to add route for current default gateway route. Will proceed without it. error: %s", err)
+		}
+	}
+
+	return addToRouteTable(prefix, netip.Addr{}, intf)
+}
+
+// removeVPNRoute removes the route from the vpn interface. If a default prefix is given,
+// it will remove the split /1 prefixes
+func removeVPNRoute(prefix netip.Prefix, intf string) error {
+	if prefix == defaultv4 {
+		var result *multierror.Error
+		if err := removeFromRouteTable(splitDefaultv4_1, netip.Addr{}, intf); err != nil {
+			result = multierror.Append(result, err)
+		}
+		if err := removeFromRouteTable(splitDefaultv4_2, netip.Addr{}, intf); err != nil {
+			result = multierror.Append(result, err)
+		}
+
+		// TODO: remove once IPv6 is supported on the interface
+		if err := removeFromRouteTable(splitDefaultv6_1, netip.Addr{}, intf); err != nil {
+			result = multierror.Append(result, err)
+		}
+		if err := removeFromRouteTable(splitDefaultv6_2, netip.Addr{}, intf); err != nil {
+			result = multierror.Append(result, err)
+		}
+
+		return result.ErrorOrNil()
+	} else if prefix == defaultv6 {
+		var result *multierror.Error
+		if err := removeFromRouteTable(splitDefaultv6_1, netip.Addr{}, intf); err != nil {
+			result = multierror.Append(result, err)
+		}
+		if err := removeFromRouteTable(splitDefaultv6_2, netip.Addr{}, intf); err != nil {
+			result = multierror.Append(result, err)
+		}
+
+		return result.ErrorOrNil()
+	}
+
+	return removeFromRouteTable(prefix, netip.Addr{}, intf)
+}
+
+func getPrefixFromIP(ip net.IP) (*netip.Prefix, error) {
+	addr, ok := netip.AddrFromSlice(ip)
+	if !ok {
+		return nil, fmt.Errorf("parse IP address: %s", ip)
+	}
+	addr = addr.Unmap()
+
+	var prefixLength int
+	switch {
+	case addr.Is4():
+		prefixLength = 32
+	case addr.Is6():
+		prefixLength = 128
+	default:
+		return nil, fmt.Errorf("invalid IP address: %s", addr)
+	}
+
+	prefix := netip.PrefixFrom(addr, prefixLength)
+	return &prefix, nil
+}
+
+func setupRoutingWithRouteManager(routeManager **RouteManager, initAddresses []net.IP, wgIface *iface.WGIface) (peer.BeforeAddPeerHookFunc, peer.AfterRemovePeerHookFunc, error) {
+	initialNextHopV4, initialIntfV4, err := getNextHop(netip.IPv4Unspecified())
+	if err != nil {
+		log.Errorf("Unable to get initial v4 default next hop: %v", err)
+	}
+	initialNextHopV6, initialIntfV6, err := getNextHop(netip.IPv6Unspecified())
+	if err != nil {
+		log.Errorf("Unable to get initial v6 default next hop: %v", err)
+	}
+
+	*routeManager = NewRouteManager(
+		func(prefix netip.Prefix) (netip.Addr, string, error) {
+			addr := prefix.Addr()
+			nexthop, intf := initialNextHopV4, initialIntfV4
+			if addr.Is6() {
+				nexthop, intf = initialNextHopV6, initialIntfV6
+			}
+			return addRouteToNonVPNIntf(prefix, wgIface, nexthop, intf)
+		},
+		removeFromRouteTable,
+	)
+
+	return setupHooks(*routeManager, initAddresses)
+}
+
+func cleanupRoutingWithRouteManager(routeManager *RouteManager) error {
+	if routeManager == nil {
+		return nil
+	}
+
+	// TODO: Remove hooks selectively
+	nbnet.RemoveDialerHooks()
+	nbnet.RemoveListenerHooks()
+
+	if err := routeManager.Flush(); err != nil {
+		return fmt.Errorf("flush route manager: %w", err)
+	}
+
 	return nil
 }
+
+func setupHooks(routeManager *RouteManager, initAddresses []net.IP) (peer.BeforeAddPeerHookFunc, peer.AfterRemovePeerHookFunc, error) {
+	beforeHook := func(connID nbnet.ConnectionID, ip net.IP) error {
+		prefix, err := getPrefixFromIP(ip)
+		if err != nil {
+			return fmt.Errorf("convert ip to prefix: %w", err)
+		}
+
+		if err := routeManager.AddRouteRef(connID, *prefix); err != nil {
+			return fmt.Errorf("adding route reference: %v", err)
+		}
+
+		return nil
+	}
+	afterHook := func(connID nbnet.ConnectionID) error {
+		if err := routeManager.RemoveRouteRef(connID); err != nil {
+			return fmt.Errorf("remove route reference: %w", err)
+		}
+
+		return nil
+	}
+
+	for _, ip := range initAddresses {
+		if err := beforeHook("init", ip); err != nil {
+			log.Errorf("Failed to add route reference: %v", err)
+		}
+	}
+
+	nbnet.AddDialerHook(func(ctx context.Context, connID nbnet.ConnectionID, resolvedIPs []net.IPAddr) error {
+		if ctx.Err() != nil {
+			return ctx.Err()
+		}
+
+		var result *multierror.Error
+		for _, ip := range resolvedIPs {
+			result = multierror.Append(result, beforeHook(connID, ip.IP))
+		}
+		return result.ErrorOrNil()
+	})
+
+	nbnet.AddDialerCloseHook(func(connID nbnet.ConnectionID, conn *net.Conn) error {
+		return afterHook(connID)
+	})
+
+	nbnet.AddListenerWriteHook(func(connID nbnet.ConnectionID, ip *net.IPAddr, data []byte) error {
+		return beforeHook(connID, ip.IP)
+	})
+
+	nbnet.AddListenerCloseHook(func(connID nbnet.ConnectionID, conn net.PacketConn) error {
+		return afterHook(connID)
+	})
+
+	return beforeHook, afterHook, nil
+}
diff --git a/client/internal/routemanager/systemops_nonlinux_test.go b/client/internal/routemanager/systemops_nonlinux_test.go
index afaf5ba7724..adb83bac6d8 100644
--- a/client/internal/routemanager/systemops_nonlinux_test.go
+++ b/client/internal/routemanager/systemops_nonlinux_test.go
@@ -1,16 +1,250 @@
-//go:build !linux || android
+//go:build !linux && !ios
 
 package routemanager
 
 import (
+	"bytes"
+	"fmt"
 	"net"
 	"net/netip"
+	"os"
+	"strings"
 	"testing"
 
+	"github.com/pion/transport/v3/stdnet"
+	log "github.com/sirupsen/logrus"
 	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
+	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+	"github.com/netbirdio/netbird/iface"
 )
 
+func assertWGOutInterface(t *testing.T, prefix netip.Prefix, wgIface *iface.WGIface, invert bool) {
+	t.Helper()
+
+	prefixGateway, _, err := getNextHop(prefix.Addr())
+	require.NoError(t, err, "getNextHop should not return err")
+	if invert {
+		assert.NotEqual(t, wgIface.Address().IP.String(), prefixGateway.String(), "route should not point to wireguard interface IP")
+	} else {
+		assert.Equal(t, wgIface.Address().IP.String(), prefixGateway.String(), "route should point to wireguard interface IP")
+	}
+}
+
+func TestAddRemoveRoutes(t *testing.T) {
+	testCases := []struct {
+		name                   string
+		prefix                 netip.Prefix
+		shouldRouteToWireguard bool
+		shouldBeRemoved        bool
+	}{
+		{
+			name:                   "Should Add And Remove Route 100.66.120.0/24",
+			prefix:                 netip.MustParsePrefix("100.66.120.0/24"),
+			shouldRouteToWireguard: true,
+			shouldBeRemoved:        true,
+		},
+		{
+			name:                   "Should Not Add Or Remove Route 127.0.0.1/32",
+			prefix:                 netip.MustParsePrefix("127.0.0.1/32"),
+			shouldRouteToWireguard: false,
+			shouldBeRemoved:        false,
+		},
+	}
+
+	for n, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			peerPrivateKey, _ := wgtypes.GeneratePrivateKey()
+			newNet, err := stdnet.NewNet()
+			if err != nil {
+				t.Fatal(err)
+			}
+			wgInterface, err := iface.NewWGIFace(fmt.Sprintf("utun53%d", n), "100.65.75.2/24", 33100, peerPrivateKey.String(), iface.DefaultMTU, newNet, nil)
+			require.NoError(t, err, "should create testing WGIface interface")
+			defer wgInterface.Close()
+
+			err = wgInterface.Create()
+			require.NoError(t, err, "should create testing wireguard interface")
+			_, _, err = setupRouting(nil, nil)
+			require.NoError(t, err)
+			t.Cleanup(func() {
+				assert.NoError(t, cleanupRouting())
+			})
+
+			err = addVPNRoute(testCase.prefix, wgInterface.Name())
+			require.NoError(t, err, "addVPNRoute should not return err")
+
+			if testCase.shouldRouteToWireguard {
+				assertWGOutInterface(t, testCase.prefix, wgInterface, false)
+			} else {
+				assertWGOutInterface(t, testCase.prefix, wgInterface, true)
+			}
+			exists, err := existsInRouteTable(testCase.prefix)
+			require.NoError(t, err, "existsInRouteTable should not return err")
+			if exists && testCase.shouldRouteToWireguard {
+				err = removeVPNRoute(testCase.prefix, wgInterface.Name())
+				require.NoError(t, err, "removeVPNRoute should not return err")
+
+				prefixGateway, _, err := getNextHop(testCase.prefix.Addr())
+				require.NoError(t, err, "getNextHop should not return err")
+
+				internetGateway, _, err := getNextHop(netip.MustParseAddr("0.0.0.0"))
+				require.NoError(t, err)
+
+				if testCase.shouldBeRemoved {
+					require.Equal(t, internetGateway, prefixGateway, "route should be pointing to default internet gateway")
+				} else {
+					require.NotEqual(t, internetGateway, prefixGateway, "route should be pointing to a different gateway than the internet gateway")
+				}
+			}
+		})
+	}
+}
+
+func TestGetNextHop(t *testing.T) {
+	gateway, _, err := getNextHop(netip.MustParseAddr("0.0.0.0"))
+	if err != nil {
+		t.Fatal("shouldn't return error when fetching the gateway: ", err)
+	}
+	if !gateway.IsValid() {
+		t.Fatal("should return a gateway")
+	}
+	addresses, err := net.InterfaceAddrs()
+	if err != nil {
+		t.Fatal("shouldn't return error when fetching interface addresses: ", err)
+	}
+
+	var testingIP string
+	var testingPrefix netip.Prefix
+	for _, address := range addresses {
+		if address.Network() != "ip+net" {
+			continue
+		}
+		prefix := netip.MustParsePrefix(address.String())
+		if !prefix.Addr().IsLoopback() && prefix.Addr().Is4() {
+			testingIP = prefix.Addr().String()
+			testingPrefix = prefix.Masked()
+			break
+		}
+	}
+
+	localIP, _, err := getNextHop(testingPrefix.Addr())
+	if err != nil {
+		t.Fatal("shouldn't return error: ", err)
+	}
+	if !localIP.IsValid() {
+		t.Fatal("should return a gateway for local network")
+	}
+	if localIP.String() == gateway.String() {
+		t.Fatal("local ip should not match with gateway IP")
+	}
+	if localIP.String() != testingIP {
+		t.Fatalf("local ip should match with testing IP: want %s got %s", testingIP, localIP.String())
+	}
+}
+
+func TestAddExistAndRemoveRouteNonAndroid(t *testing.T) {
+	defaultGateway, _, err := getNextHop(netip.MustParseAddr("0.0.0.0"))
+	t.Log("defaultGateway: ", defaultGateway)
+	if err != nil {
+		t.Fatal("shouldn't return error when fetching the gateway: ", err)
+	}
+	testCases := []struct {
+		name              string
+		prefix            netip.Prefix
+		preExistingPrefix netip.Prefix
+		shouldAddRoute    bool
+	}{
+		{
+			name:           "Should Add And Remove random Route",
+			prefix:         netip.MustParsePrefix("99.99.99.99/32"),
+			shouldAddRoute: true,
+		},
+		{
+			name:           "Should Not Add Route if overlaps with default gateway",
+			prefix:         netip.MustParsePrefix(defaultGateway.String() + "/31"),
+			shouldAddRoute: false,
+		},
+		{
+			name:              "Should Add Route if bigger network exists",
+			prefix:            netip.MustParsePrefix("100.100.100.0/24"),
+			preExistingPrefix: netip.MustParsePrefix("100.100.0.0/16"),
+			shouldAddRoute:    true,
+		},
+		{
+			name:              "Should Add Route if smaller network exists",
+			prefix:            netip.MustParsePrefix("100.100.0.0/16"),
+			preExistingPrefix: netip.MustParsePrefix("100.100.100.0/24"),
+			shouldAddRoute:    true,
+		},
+		{
+			name:              "Should Not Add Route if same network exists",
+			prefix:            netip.MustParsePrefix("100.100.0.0/16"),
+			preExistingPrefix: netip.MustParsePrefix("100.100.0.0/16"),
+			shouldAddRoute:    false,
+		},
+	}
+
+	for n, testCase := range testCases {
+		var buf bytes.Buffer
+		log.SetOutput(&buf)
+		defer func() {
+			log.SetOutput(os.Stderr)
+		}()
+		t.Run(testCase.name, func(t *testing.T) {
+			peerPrivateKey, _ := wgtypes.GeneratePrivateKey()
+			newNet, err := stdnet.NewNet()
+			if err != nil {
+				t.Fatal(err)
+			}
+			wgInterface, err := iface.NewWGIFace(fmt.Sprintf("utun53%d", n), "100.65.75.2/24", 33100, peerPrivateKey.String(), iface.DefaultMTU, newNet, nil)
+			require.NoError(t, err, "should create testing WGIface interface")
+			defer wgInterface.Close()
+
+			err = wgInterface.Create()
+			require.NoError(t, err, "should create testing wireguard interface")
+
+			_, _, err = setupRouting(nil, nil)
+			require.NoError(t, err)
+			t.Cleanup(func() {
+				assert.NoError(t, cleanupRouting())
+			})
+
+			// Prepare the environment
+			if testCase.preExistingPrefix.IsValid() {
+				err := addVPNRoute(testCase.preExistingPrefix, wgInterface.Name())
+				require.NoError(t, err, "should not return err when adding pre-existing route")
+			}
+
+			// Add the route
+			err = addVPNRoute(testCase.prefix, wgInterface.Name())
+			require.NoError(t, err, "should not return err when adding route")
+
+			if testCase.shouldAddRoute {
+				// test if route exists after adding
+				ok, err := existsInRouteTable(testCase.prefix)
+				require.NoError(t, err, "should not return err")
+				require.True(t, ok, "route should exist")
+
+				// remove route again if added
+				err = removeVPNRoute(testCase.prefix, wgInterface.Name())
+				require.NoError(t, err, "should not return err")
+			}
+
+			// route should either not have been added or should have been removed
+			// In case of already existing route, it should not have been added (but still exist)
+			ok, err := existsInRouteTable(testCase.prefix)
+			t.Log("Buffer string: ", buf.String())
+			require.NoError(t, err, "should not return err")
+
+			if !strings.Contains(buf.String(), "because it already exists") {
+				require.False(t, ok, "route should not exist")
+			}
+		})
+	}
+}
+
 func TestIsSubRange(t *testing.T) {
 	addresses, err := net.InterfaceAddrs()
 	if err != nil {
@@ -50,7 +284,8 @@ func TestIsSubRange(t *testing.T) {
 }
 
 func TestExistsInRouteTable(t *testing.T) {
-	require.NoError(t, setupRouting())
+	_, _, err := setupRouting(nil, nil)
+	require.NoError(t, err)
 	t.Cleanup(func() {
 		assert.NoError(t, cleanupRouting())
 	})
@@ -63,7 +298,8 @@ func TestExistsInRouteTable(t *testing.T) {
 	var addressPrefixes []netip.Prefix
 	for _, address := range addresses {
 		p := netip.MustParsePrefix(address.String())
-		if p.Addr().Is4() {
+		// Windows sometimes has hidden interface link local addrs that don't turn up on any interface
+		if p.Addr().Is4() && !p.Addr().IsLinkLocalUnicast() {
 			addressPrefixes = append(addressPrefixes, p.Masked())
 		}
 	}
diff --git a/client/internal/routemanager/systemops_unix_test.go b/client/internal/routemanager/systemops_unix_test.go
new file mode 100644
index 00000000000..561eaeea4b2
--- /dev/null
+++ b/client/internal/routemanager/systemops_unix_test.go
@@ -0,0 +1,234 @@
+//go:build (linux && !android) || (darwin && !ios) || freebsd || openbsd || netbsd || dragonfly
+
+package routemanager
+
+import (
+	"fmt"
+	"net"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/gopacket/gopacket"
+	"github.com/gopacket/gopacket/layers"
+	"github.com/gopacket/gopacket/pcap"
+	"github.com/miekg/dns"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	nbnet "github.com/netbirdio/netbird/util/net"
+)
+
+type PacketExpectation struct {
+	SrcIP   net.IP
+	DstIP   net.IP
+	SrcPort int
+	DstPort int
+	UDP     bool
+	TCP     bool
+}
+
+type testCase struct {
+	name              string
+	destination       string
+	expectedInterface string
+	dialer            dialer
+	expectedPacket    PacketExpectation
+}
+
+var testCases = []testCase{
+	{
+		name:              "To external host without custom dialer via vpn",
+		destination:       "192.0.2.1:53",
+		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",
+		destination:       "192.0.2.1:53",
+		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",
+		destination:       "10.0.0.2:53",
+		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
+		destination:       "10.0.0.2:53",
+		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",
+		destination:       "172.16.0.2:53",
+		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",
+		destination:       "172.16.0.2:53",
+		expectedInterface: expectedVPNint,
+		dialer:            &net.Dialer{},
+		expectedPacket:    createPacketExpectation("100.64.0.1", 12345, "172.16.0.2", 53),
+	},
+}
+
+func TestRouting(t *testing.T) {
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			setupTestEnv(t)
+
+			filter := createBPFFilter(tc.destination)
+			handle := startPacketCapture(t, tc.expectedInterface, filter)
+
+			sendTestPacket(t, tc.destination, tc.expectedPacket.SrcPort, tc.dialer)
+
+			packetSource := gopacket.NewPacketSource(handle, handle.LinkType())
+			packet, err := packetSource.NextPacket()
+			require.NoError(t, err)
+
+			verifyPacket(t, packet, tc.expectedPacket)
+		})
+	}
+}
+
+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()
+
+	inactive, err := pcap.NewInactiveHandle(intf)
+	require.NoError(t, err, "Failed to create inactive pcap handle")
+	defer inactive.CleanUp()
+
+	err = inactive.SetSnapLen(1600)
+	require.NoError(t, err, "Failed to set snap length on inactive handle")
+
+	err = inactive.SetTimeout(time.Second * 10)
+	require.NoError(t, err, "Failed to set timeout on inactive handle")
+
+	err = inactive.SetImmediateMode(true)
+	require.NoError(t, err, "Failed to set immediate mode on inactive handle")
+
+	handle, err := inactive.Activate()
+	require.NoError(t, err, "Failed to activate pcap handle")
+	t.Cleanup(handle.Close)
+
+	err = handle.SetBPFFilter(filter)
+	require.NoError(t, err, "Failed to set BPF filter")
+
+	return handle
+}
+
+func sendTestPacket(t *testing.T, destination string, sourcePort int, dialer dialer) {
+	t.Helper()
+
+	if dialer == nil {
+		dialer = &net.Dialer{}
+	}
+
+	if sourcePort != 0 {
+		localUDPAddr := &net.UDPAddr{
+			IP:   net.IPv4zero,
+			Port: sourcePort,
+		}
+		switch dialer := dialer.(type) {
+		case *nbnet.Dialer:
+			dialer.LocalAddr = localUDPAddr
+		case *net.Dialer:
+			dialer.LocalAddr = localUDPAddr
+		default:
+			t.Fatal("Unsupported dialer type")
+		}
+	}
+
+	msg := new(dns.Msg)
+	msg.Id = dns.Id()
+	msg.RecursionDesired = true
+	msg.Question = []dns.Question{
+		{Name: "example.com.", Qtype: dns.TypeA, Qclass: dns.ClassINET},
+	}
+
+	conn, err := dialer.Dial("udp", destination)
+	require.NoError(t, err, "Failed to dial UDP")
+	defer conn.Close()
+
+	data, err := msg.Pack()
+	require.NoError(t, err, "Failed to pack DNS message")
+
+	_, err = conn.Write(data)
+	if err != nil {
+		if strings.Contains(err.Error(), "required key not available") {
+			t.Logf("Ignoring WireGuard key error: %v", err)
+			return
+		}
+		t.Fatalf("Failed to send DNS query: %v", err)
+	}
+}
+
+func createBPFFilter(destination string) string {
+	host, port, err := net.SplitHostPort(destination)
+	if err != nil {
+		return fmt.Sprintf("udp and dst host %s and dst port %s", host, port)
+	}
+	return "udp"
+}
+
+func verifyPacket(t *testing.T, packet gopacket.Packet, exp PacketExpectation) {
+	t.Helper()
+
+	ipLayer := packet.Layer(layers.LayerTypeIPv4)
+	require.NotNil(t, ipLayer, "Expected IPv4 layer not found in packet")
+
+	ip, ok := ipLayer.(*layers.IPv4)
+	require.True(t, ok, "Failed to cast to IPv4 layer")
+
+	// Convert both source and destination IP addresses to 16-byte representation
+	expectedSrcIP := exp.SrcIP.To16()
+	actualSrcIP := ip.SrcIP.To16()
+	assert.Equal(t, expectedSrcIP, actualSrcIP, "Source IP mismatch")
+
+	expectedDstIP := exp.DstIP.To16()
+	actualDstIP := ip.DstIP.To16()
+	assert.Equal(t, expectedDstIP, actualDstIP, "Destination IP mismatch")
+
+	if exp.UDP {
+		udpLayer := packet.Layer(layers.LayerTypeUDP)
+		require.NotNil(t, udpLayer, "Expected UDP layer not found in packet")
+
+		udp, ok := udpLayer.(*layers.UDP)
+		require.True(t, ok, "Failed to cast to UDP layer")
+
+		assert.Equal(t, layers.UDPPort(exp.SrcPort), udp.SrcPort, "UDP source port mismatch")
+		assert.Equal(t, layers.UDPPort(exp.DstPort), udp.DstPort, "UDP destination port mismatch")
+	}
+
+	if exp.TCP {
+		tcpLayer := packet.Layer(layers.LayerTypeTCP)
+		require.NotNil(t, tcpLayer, "Expected TCP layer not found in packet")
+
+		tcp, ok := tcpLayer.(*layers.TCP)
+		require.True(t, ok, "Failed to cast to TCP layer")
+
+		assert.Equal(t, layers.TCPPort(exp.SrcPort), tcp.SrcPort, "TCP source port mismatch")
+		assert.Equal(t, layers.TCPPort(exp.DstPort), tcp.DstPort, "TCP destination port mismatch")
+	}
+}
diff --git a/client/internal/routemanager/systemops_windows.go b/client/internal/routemanager/systemops_windows.go
index c009ce66b9d..50fff0cd58d 100644
--- a/client/internal/routemanager/systemops_windows.go
+++ b/client/internal/routemanager/systemops_windows.go
@@ -6,9 +6,14 @@ import (
 	"fmt"
 	"net"
 	"net/netip"
+	"os/exec"
+	"strings"
 
 	log "github.com/sirupsen/logrus"
 	"github.com/yusufpapurcu/wmi"
+
+	"github.com/netbirdio/netbird/client/internal/peer"
+	"github.com/netbirdio/netbird/iface"
 )
 
 type Win32_IP4RouteTable struct {
@@ -16,6 +21,16 @@ type Win32_IP4RouteTable struct {
 	Mask        string
 }
 
+var routeManager *RouteManager
+
+func setupRouting(initAddresses []net.IP, wgIface *iface.WGIface) (peer.BeforeAddPeerHookFunc, peer.AfterRemovePeerHookFunc, error) {
+	return setupRoutingWithRouteManager(&routeManager, initAddresses, wgIface)
+}
+
+func cleanupRouting() error {
+	return cleanupRoutingWithRouteManager(routeManager)
+}
+
 func getRoutesFromTable() ([]netip.Prefix, error) {
 	var routes []Win32_IP4RouteTable
 	query := "SELECT Destination, Mask FROM Win32_IP4RouteTable"
@@ -48,10 +63,68 @@ func getRoutesFromTable() ([]netip.Prefix, error) {
 	return prefixList, nil
 }
 
-func addToRouteTableIfNoExists(prefix netip.Prefix, addr string, intf string) error {
-	return genericAddToRouteTableIfNoExists(prefix, addr, intf)
+func addRoutePowershell(prefix netip.Prefix, nexthop netip.Addr, intf string) error {
+	destinationPrefix := prefix.String()
+	psCmd := "New-NetRoute"
+
+	addressFamily := "IPv4"
+	if prefix.Addr().Is6() {
+		addressFamily = "IPv6"
+	}
+
+	script := fmt.Sprintf(
+		`%s -AddressFamily "%s" -DestinationPrefix "%s" -InterfaceAlias "%s" -Confirm:$False -ErrorAction Stop`,
+		psCmd, addressFamily, destinationPrefix, intf,
+	)
+
+	if nexthop.IsValid() {
+		script = fmt.Sprintf(
+			`%s -NextHop "%s"`, script, nexthop,
+		)
+	}
+
+	out, err := exec.Command("powershell", "-Command", script).CombinedOutput()
+	log.Tracef("PowerShell add route: %s", string(out))
+
+	if err != nil {
+		return fmt.Errorf("PowerShell add route: %w", err)
+	}
+
+	return nil
 }
 
-func removeFromRouteTableIfNonSystem(prefix netip.Prefix, addr string, intf string) error {
-	return genericRemoveFromRouteTableIfNonSystem(prefix, addr, intf)
+func addRouteCmd(prefix netip.Prefix, nexthop netip.Addr, _ string) error {
+	args := []string{"add", prefix.String(), nexthop.Unmap().String()}
+
+	out, err := exec.Command("route", args...).CombinedOutput()
+
+	log.Tracef("route %s output: %s", strings.Join(args, " "), out)
+	if err != nil {
+		return fmt.Errorf("route add: %w", err)
+	}
+
+	return nil
+}
+
+func addToRouteTable(prefix netip.Prefix, nexthop netip.Addr, intf string) error {
+	// Powershell doesn't support adding routes without an interface but allows to add interface by name
+	if intf != "" {
+		return addRoutePowershell(prefix, nexthop, intf)
+	}
+	return addRouteCmd(prefix, nexthop, intf)
+}
+
+func removeFromRouteTable(prefix netip.Prefix, nexthop netip.Addr, _ string) error {
+	args := []string{"delete", prefix.String()}
+	if nexthop.IsValid() {
+		args = append(args, nexthop.Unmap().String())
+	}
+
+	out, err := exec.Command("route", args...).CombinedOutput()
+	log.Tracef("route %s output: %s", strings.Join(args, " "), out)
+
+	if err != nil {
+		return fmt.Errorf("remove route: %w", err)
+	}
+	return nil
 }
diff --git a/client/internal/routemanager/systemops_windows_test.go b/client/internal/routemanager/systemops_windows_test.go
new file mode 100644
index 00000000000..a5e03b8d2ce
--- /dev/null
+++ b/client/internal/routemanager/systemops_windows_test.go
@@ -0,0 +1,289 @@
+package routemanager
+
+import (
+	"context"
+	"encoding/json"
+	"fmt"
+	"net"
+	"os/exec"
+	"strings"
+	"testing"
+	"time"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+
+	nbnet "github.com/netbirdio/netbird/util/net"
+)
+
+var expectedExtInt = "Ethernet1"
+
+type RouteInfo struct {
+	NextHop        string `json:"nexthop"`
+	InterfaceAlias string `json:"interfacealias"`
+	RouteMetric    int    `json:"routemetric"`
+}
+
+type FindNetRouteOutput struct {
+	IPAddress         string `json:"IPAddress"`
+	InterfaceIndex    int    `json:"InterfaceIndex"`
+	InterfaceAlias    string `json:"InterfaceAlias"`
+	AddressFamily     int    `json:"AddressFamily"`
+	NextHop           string `json:"NextHop"`
+	DestinationPrefix string `json:"DestinationPrefix"`
+}
+
+type testCase struct {
+	name               string
+	destination        string
+	expectedSourceIP   string
+	expectedDestPrefix string
+	expectedNextHop    string
+	expectedInterface  string
+	dialer             dialer
+}
+
+var expectedVPNint = "wgtest0"
+
+var testCases = []testCase{
+	{
+		name:               "To external host without custom dialer via vpn",
+		destination:        "192.0.2.1:53",
+		expectedSourceIP:   "100.64.0.1",
+		expectedDestPrefix: "128.0.0.0/1",
+		expectedNextHop:    "0.0.0.0",
+		expectedInterface:  "wgtest0",
+		dialer:             &net.Dialer{},
+	},
+	{
+		name:               "To external host with custom dialer via physical interface",
+		destination:        "192.0.2.1:53",
+		expectedDestPrefix: "192.0.2.1/32",
+		expectedInterface:  expectedExtInt,
+		dialer:             nbnet.NewDialer(),
+	},
+
+	{
+		name:               "To duplicate internal route with custom dialer via physical interface",
+		destination:        "10.0.0.2:53",
+		expectedDestPrefix: "10.0.0.2/32",
+		expectedInterface:  expectedExtInt,
+		dialer:             nbnet.NewDialer(),
+	},
+	{
+		name:               "To duplicate internal route without custom dialer via physical interface", // local route takes precedence
+		destination:        "10.0.0.2:53",
+		expectedSourceIP:   "10.0.0.1",
+		expectedDestPrefix: "10.0.0.0/8",
+		expectedNextHop:    "0.0.0.0",
+		expectedInterface:  "Loopback Pseudo-Interface 1",
+		dialer:             &net.Dialer{},
+	},
+
+	{
+		name:               "To unique vpn route with custom dialer via physical interface",
+		destination:        "172.16.0.2:53",
+		expectedDestPrefix: "172.16.0.2/32",
+		expectedInterface:  expectedExtInt,
+		dialer:             nbnet.NewDialer(),
+	},
+	{
+		name:               "To unique vpn route without custom dialer via vpn",
+		destination:        "172.16.0.2:53",
+		expectedSourceIP:   "100.64.0.1",
+		expectedDestPrefix: "172.16.0.0/12",
+		expectedNextHop:    "0.0.0.0",
+		expectedInterface:  "wgtest0",
+		dialer:             &net.Dialer{},
+	},
+
+	{
+		name:               "To more specific route without custom dialer via vpn interface",
+		destination:        "10.10.0.2:53",
+		expectedSourceIP:   "100.64.0.1",
+		expectedDestPrefix: "10.10.0.0/24",
+		expectedNextHop:    "0.0.0.0",
+		expectedInterface:  "wgtest0",
+		dialer:             &net.Dialer{},
+	},
+
+	{
+		name:               "To more specific route (local) without custom dialer via physical interface",
+		destination:        "127.0.10.2:53",
+		expectedSourceIP:   "10.0.0.1",
+		expectedDestPrefix: "127.0.0.0/8",
+		expectedNextHop:    "0.0.0.0",
+		expectedInterface:  "Loopback Pseudo-Interface 1",
+		dialer:             &net.Dialer{},
+	},
+}
+
+func TestRouting(t *testing.T) {
+	for _, tc := range testCases {
+		t.Run(tc.name, func(t *testing.T) {
+			setupTestEnv(t)
+
+			route, err := fetchOriginalGateway()
+			require.NoError(t, err, "Failed to fetch original gateway")
+			ip, err := fetchInterfaceIP(route.InterfaceAlias)
+			require.NoError(t, err, "Failed to fetch interface IP")
+
+			output := testRoute(t, tc.destination, tc.dialer)
+			if tc.expectedInterface == expectedExtInt {
+				verifyOutput(t, output, ip, tc.expectedDestPrefix, route.NextHop, route.InterfaceAlias)
+			} else {
+				verifyOutput(t, output, tc.expectedSourceIP, tc.expectedDestPrefix, tc.expectedNextHop, tc.expectedInterface)
+			}
+		})
+	}
+}
+
+// fetchInterfaceIP fetches the IPv4 address of the specified interface.
+func fetchInterfaceIP(interfaceAlias string) (string, error) {
+	script := fmt.Sprintf(`Get-NetIPAddress -InterfaceAlias "%s" | Where-Object AddressFamily -eq 2 | Select-Object -ExpandProperty IPAddress`, interfaceAlias)
+	out, err := exec.Command("powershell", "-Command", script).Output()
+	if err != nil {
+		return "", fmt.Errorf("failed to execute Get-NetIPAddress: %w", err)
+	}
+
+	ip := strings.TrimSpace(string(out))
+	return ip, nil
+}
+
+func testRoute(t *testing.T, destination string, dialer dialer) *FindNetRouteOutput {
+	t.Helper()
+
+	ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
+	defer cancel()
+
+	conn, err := dialer.DialContext(ctx, "udp", destination)
+	require.NoError(t, err, "Failed to dial destination")
+	defer func() {
+		err := conn.Close()
+		assert.NoError(t, err, "Failed to close connection")
+	}()
+
+	host, _, err := net.SplitHostPort(destination)
+	require.NoError(t, err)
+
+	script := fmt.Sprintf(`Find-NetRoute -RemoteIPAddress "%s" | Select-Object -Property IPAddress, InterfaceIndex, InterfaceAlias, AddressFamily, NextHop, DestinationPrefix | ConvertTo-Json`, host)
+
+	out, err := exec.Command("powershell", "-Command", script).Output()
+	require.NoError(t, err, "Failed to execute Find-NetRoute")
+
+	var outputs []FindNetRouteOutput
+	err = json.Unmarshal(out, &outputs)
+	require.NoError(t, err, "Failed to parse JSON outputs from Find-NetRoute")
+
+	require.Greater(t, len(outputs), 0, "No route found for destination")
+	combinedOutput := combineOutputs(outputs)
+
+	return combinedOutput
+}
+
+func createAndSetupDummyInterface(t *testing.T, interfaceName, ipAddressCIDR string) string {
+	t.Helper()
+
+	ip, ipNet, err := net.ParseCIDR(ipAddressCIDR)
+	require.NoError(t, err)
+	subnetMaskSize, _ := ipNet.Mask.Size()
+	script := fmt.Sprintf(`New-NetIPAddress -InterfaceAlias "%s" -IPAddress "%s" -PrefixLength %d -PolicyStore ActiveStore -Confirm:$False`, interfaceName, ip.String(), subnetMaskSize)
+	_, err = exec.Command("powershell", "-Command", script).CombinedOutput()
+	require.NoError(t, err, "Failed to assign IP address to loopback adapter")
+
+	// Wait for the IP address to be applied
+	ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
+	defer cancel()
+	err = waitForIPAddress(ctx, interfaceName, ip.String())
+	require.NoError(t, err, "IP address not applied within timeout")
+
+	t.Cleanup(func() {
+		script = fmt.Sprintf(`Remove-NetIPAddress -InterfaceAlias "%s" -IPAddress "%s" -Confirm:$False`, interfaceName, ip.String())
+		_, err = exec.Command("powershell", "-Command", script).CombinedOutput()
+		require.NoError(t, err, "Failed to remove IP address from loopback adapter")
+	})
+
+	return interfaceName
+}
+
+func fetchOriginalGateway() (*RouteInfo, error) {
+	cmd := exec.Command("powershell", "-Command", "Get-NetRoute -DestinationPrefix 0.0.0.0/0 | Select-Object NextHop, RouteMetric, InterfaceAlias | ConvertTo-Json")
+	output, err := cmd.CombinedOutput()
+	if err != nil {
+		return nil, fmt.Errorf("failed to execute Get-NetRoute: %w", err)
+	}
+
+	var routeInfo RouteInfo
+	err = json.Unmarshal(output, &routeInfo)
+	if err != nil {
+		return nil, fmt.Errorf("failed to parse JSON output: %w", err)
+	}
+
+	return &routeInfo, nil
+}
+
+func verifyOutput(t *testing.T, output *FindNetRouteOutput, sourceIP, destPrefix, nextHop, intf string) {
+	t.Helper()
+
+	assert.Equal(t, sourceIP, output.IPAddress, "Source IP mismatch")
+	assert.Equal(t, destPrefix, output.DestinationPrefix, "Destination prefix mismatch")
+	assert.Equal(t, nextHop, output.NextHop, "Next hop mismatch")
+	assert.Equal(t, intf, output.InterfaceAlias, "Interface mismatch")
+}
+
+func waitForIPAddress(ctx context.Context, interfaceAlias, expectedIPAddress string) error {
+	ticker := time.NewTicker(1 * time.Second)
+	defer ticker.Stop()
+
+	for {
+		select {
+		case <-ctx.Done():
+			return ctx.Err()
+		case <-ticker.C:
+			out, err := exec.Command("powershell", "-Command", fmt.Sprintf(`Get-NetIPAddress -InterfaceAlias "%s" | Select-Object -ExpandProperty IPAddress`, interfaceAlias)).CombinedOutput()
+			if err != nil {
+				return err
+			}
+
+			ipAddresses := strings.Split(strings.TrimSpace(string(out)), "\n")
+			for _, ip := range ipAddresses {
+				if strings.TrimSpace(ip) == expectedIPAddress {
+					return nil
+				}
+			}
+		}
+	}
+}
+
+func combineOutputs(outputs []FindNetRouteOutput) *FindNetRouteOutput {
+	var combined FindNetRouteOutput
+
+	for _, output := range outputs {
+		if output.IPAddress != "" {
+			combined.IPAddress = output.IPAddress
+		}
+		if output.InterfaceIndex != 0 {
+			combined.InterfaceIndex = output.InterfaceIndex
+		}
+		if output.InterfaceAlias != "" {
+			combined.InterfaceAlias = output.InterfaceAlias
+		}
+		if output.AddressFamily != 0 {
+			combined.AddressFamily = output.AddressFamily
+		}
+		if output.NextHop != "" {
+			combined.NextHop = output.NextHop
+		}
+		if output.DestinationPrefix != "" {
+			combined.DestinationPrefix = output.DestinationPrefix
+		}
+	}
+
+	return &combined
+}
+
+func setupDummyInterfacesAndRoutes(t *testing.T) {
+	t.Helper()
+
+	createAndSetupDummyInterface(t, "Loopback Pseudo-Interface 1", "10.0.0.1/8")
+}
diff --git a/client/internal/routemanager/sytemops_test.go b/client/internal/routemanager/sytemops_test.go
new file mode 100644
index 00000000000..28a6502d2ef
--- /dev/null
+++ b/client/internal/routemanager/sytemops_test.go
@@ -0,0 +1,101 @@
+//go:build !android && !ios
+
+package routemanager
+
+import (
+	"context"
+	"net"
+	"net/netip"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+	"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
+
+	"github.com/netbirdio/netbird/client/internal/stdnet"
+	"github.com/netbirdio/netbird/iface"
+)
+
+type dialer interface {
+	Dial(network, address string) (net.Conn, error)
+	DialContext(ctx context.Context, network, address string) (net.Conn, error)
+}
+
+func createWGInterface(t *testing.T, interfaceName, ipAddressCIDR string, listenPort int) *iface.WGIface {
+	t.Helper()
+
+	peerPrivateKey, err := wgtypes.GeneratePrivateKey()
+	require.NoError(t, err)
+
+	newNet, err := stdnet.NewNet(nil)
+	require.NoError(t, err)
+
+	wgInterface, err := iface.NewWGIFace(interfaceName, ipAddressCIDR, listenPort, peerPrivateKey.String(), iface.DefaultMTU, newNet, nil)
+	require.NoError(t, err, "should create testing WireGuard interface")
+
+	err = wgInterface.Create()
+	require.NoError(t, err, "should create testing WireGuard interface")
+
+	t.Cleanup(func() {
+		wgInterface.Close()
+	})
+
+	return wgInterface
+}
+
+func setupTestEnv(t *testing.T) {
+	t.Helper()
+
+	setupDummyInterfacesAndRoutes(t)
+
+	wgIface := createWGInterface(t, expectedVPNint, "100.64.0.1/24", 51820)
+	t.Cleanup(func() {
+		assert.NoError(t, wgIface.Close())
+	})
+
+	_, _, err := setupRouting(nil, wgIface)
+	require.NoError(t, err, "setupRouting should not return err")
+	t.Cleanup(func() {
+		assert.NoError(t, cleanupRouting())
+	})
+
+	// default route exists in main table and vpn table
+	err = addVPNRoute(netip.MustParsePrefix("0.0.0.0/0"), wgIface.Name())
+	require.NoError(t, err, "addVPNRoute should not return err")
+	t.Cleanup(func() {
+		err = removeVPNRoute(netip.MustParsePrefix("0.0.0.0/0"), wgIface.Name())
+		assert.NoError(t, err, "removeVPNRoute should not return err")
+	})
+
+	// 10.0.0.0/8 route exists in main table and vpn table
+	err = addVPNRoute(netip.MustParsePrefix("10.0.0.0/8"), wgIface.Name())
+	require.NoError(t, err, "addVPNRoute should not return err")
+	t.Cleanup(func() {
+		err = removeVPNRoute(netip.MustParsePrefix("10.0.0.0/8"), wgIface.Name())
+		assert.NoError(t, err, "removeVPNRoute should not return err")
+	})
+
+	// 10.10.0.0/24 more specific route exists in vpn table
+	err = addVPNRoute(netip.MustParsePrefix("10.10.0.0/24"), wgIface.Name())
+	require.NoError(t, err, "addVPNRoute should not return err")
+	t.Cleanup(func() {
+		err = removeVPNRoute(netip.MustParsePrefix("10.10.0.0/24"), wgIface.Name())
+		assert.NoError(t, err, "removeVPNRoute should not return err")
+	})
+
+	// 127.0.10.0/24 more specific route exists in vpn table
+	err = addVPNRoute(netip.MustParsePrefix("127.0.10.0/24"), wgIface.Name())
+	require.NoError(t, err, "addVPNRoute should not return err")
+	t.Cleanup(func() {
+		err = removeVPNRoute(netip.MustParsePrefix("127.0.10.0/24"), wgIface.Name())
+		assert.NoError(t, err, "removeVPNRoute should not return err")
+	})
+
+	// unique route in vpn table
+	err = addVPNRoute(netip.MustParsePrefix("172.16.0.0/12"), wgIface.Name())
+	require.NoError(t, err, "addVPNRoute should not return err")
+	t.Cleanup(func() {
+		err = removeVPNRoute(netip.MustParsePrefix("172.16.0.0/12"), wgIface.Name())
+		assert.NoError(t, err, "removeVPNRoute should not return err")
+	})
+}
diff --git a/client/internal/wgproxy/portlookup.go b/client/internal/wgproxy/portlookup.go
index 6ede4b83f1d..6f3d33487ea 100644
--- a/client/internal/wgproxy/portlookup.go
+++ b/client/internal/wgproxy/portlookup.go
@@ -1,10 +1,8 @@
 package wgproxy
 
 import (
-	"context"
 	"fmt"
-
-	nbnet "github.com/netbirdio/netbird/util/net"
+	"net"
 )
 
 const (
@@ -25,7 +23,7 @@ func (pl portLookup) searchFreePort() (int, error) {
 }
 
 func (pl portLookup) tryToBind(port int) error {
-	l, err := nbnet.NewListener().ListenPacket(context.Background(), "udp", fmt.Sprintf(":%d", port))
+	l, err := net.ListenPacket("udp", fmt.Sprintf(":%d", port))
 	if err != nil {
 		return err
 	}
diff --git a/client/internal/wgproxy/proxy_ebpf.go b/client/internal/wgproxy/proxy_ebpf.go
index b91cd7b439d..2235c5d2bdf 100644
--- a/client/internal/wgproxy/proxy_ebpf.go
+++ b/client/internal/wgproxy/proxy_ebpf.go
@@ -12,6 +12,7 @@ import (
 
 	"github.com/google/gopacket"
 	"github.com/google/gopacket/layers"
+	"github.com/pion/transport/v3"
 	log "github.com/sirupsen/logrus"
 
 	"github.com/netbirdio/netbird/client/internal/ebpf"
@@ -29,7 +30,7 @@ type WGEBPFProxy struct {
 	turnConnMutex sync.Mutex
 
 	rawConn net.PacketConn
-	conn    *net.UDPConn
+	conn    transport.UDPConn
 }
 
 // NewWGEBPFProxy create new WGEBPFProxy instance
@@ -67,7 +68,7 @@ func (p *WGEBPFProxy) Listen() error {
 		IP:   net.ParseIP("127.0.0.1"),
 	}
 
-	p.conn, err = nbnet.ListenUDP("udp", &addr)
+	conn, err := nbnet.ListenUDP("udp", &addr)
 	if err != nil {
 		cErr := p.Free()
 		if cErr != nil {
@@ -75,6 +76,7 @@ func (p *WGEBPFProxy) Listen() error {
 		}
 		return err
 	}
+	p.conn = conn
 
 	go p.proxyToRemote()
 	log.Infof("local wg proxy listening on: %d", wgPorxyPort)
diff --git a/go.mod b/go.mod
index 5566f85599b..29a1570c896 100644
--- a/go.mod
+++ b/go.mod
@@ -53,7 +53,7 @@ require (
 	github.com/hashicorp/go-multierror v1.1.1
 	github.com/hashicorp/go-secure-stdlib/base62 v0.1.2
 	github.com/hashicorp/go-version v1.6.0
-	github.com/libp2p/go-netroute v0.2.0
+	github.com/libp2p/go-netroute v0.2.1
 	github.com/magiconair/properties v1.8.5
 	github.com/mattn/go-sqlite3 v1.14.19
 	github.com/mdlayher/socket v0.4.1
diff --git a/go.sum b/go.sum
index 6da405341d5..b488a42a42a 100644
--- a/go.sum
+++ b/go.sum
@@ -345,8 +345,8 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw=
-github.com/libp2p/go-netroute v0.2.0 h1:0FpsbsvuSnAhXFnCY0VLFbJOzaK0VnP0r1QT/o4nWRE=
-github.com/libp2p/go-netroute v0.2.0/go.mod h1:Vio7LTzZ+6hoT4CMZi5/6CpY3Snzh2vgZhWgxMNwlQI=
+github.com/libp2p/go-netroute v0.2.1 h1:V8kVrpD8GK0Riv15/7VN6RbUQ3URNZVosw7H2v9tksU=
+github.com/libp2p/go-netroute v0.2.1/go.mod h1:hraioZr0fhBjG0ZRXJJ6Zj2IVEVNx6tDTFQfSmcq7mQ=
 github.com/lucor/goinfo v0.0.0-20210802170112-c078a2b0f08b/go.mod h1:PRq09yoB+Q2OJReAmwzKivcYyremnibWGbK7WfftHzc=
 github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls=
 github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
@@ -659,7 +659,6 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY
 golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
-golang.org/x/net v0.0.0-20210423184538-5f58ad60dda6/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
 golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
 golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
@@ -746,7 +745,6 @@ golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7w
 golang.org/x/sys v0.0.0-20210303074136-134d130e1a04/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210426080607-c94f62235c83/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
 golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210603081109-ebe580a85c40/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
diff --git a/util/grpc/dialer_linux.go b/util/grpc/dialer.go
similarity index 56%
rename from util/grpc/dialer_linux.go
rename to util/grpc/dialer.go
index b29ee4b2936..96b2bc32be0 100644
--- a/util/grpc/dialer_linux.go
+++ b/util/grpc/dialer.go
@@ -1,11 +1,10 @@
-//go:build !android
-
 package grpc
 
 import (
 	"context"
 	"net"
 
+	log "github.com/sirupsen/logrus"
 	"google.golang.org/grpc"
 
 	nbnet "github.com/netbirdio/netbird/util/net"
@@ -13,6 +12,11 @@ import (
 
 func WithCustomDialer() grpc.DialOption {
 	return grpc.WithContextDialer(func(ctx context.Context, addr string) (net.Conn, error) {
-		return nbnet.NewDialer().DialContext(ctx, "tcp", addr)
+		conn, err := nbnet.NewDialer().DialContext(ctx, "tcp", addr)
+		if err != nil {
+			log.Errorf("Failed to dial: %s", err)
+			return nil, err
+		}
+		return conn, nil
 	})
 }
diff --git a/util/grpc/dialer_generic.go b/util/grpc/dialer_generic.go
deleted file mode 100644
index 1c2285b14bf..00000000000
--- a/util/grpc/dialer_generic.go
+++ /dev/null
@@ -1,9 +0,0 @@
-//go:build !linux || android
-
-package grpc
-
-import "google.golang.org/grpc"
-
-func WithCustomDialer() grpc.DialOption {
-	return grpc.EmptyDialOption{}
-}
diff --git a/util/net/dialer.go b/util/net/dialer.go
new file mode 100644
index 00000000000..7b9bddbb52a
--- /dev/null
+++ b/util/net/dialer.go
@@ -0,0 +1,64 @@
+package net
+
+import (
+	"fmt"
+	"net"
+
+	log "github.com/sirupsen/logrus"
+)
+
+// Dialer extends the standard net.Dialer with the ability to execute hooks before
+// and after connections. This can be used to bypass the VPN for connections using this dialer.
+type Dialer struct {
+	*net.Dialer
+}
+
+// NewDialer returns a customized net.Dialer with overridden Control method
+func NewDialer() *Dialer {
+	dialer := &Dialer{
+		Dialer: &net.Dialer{},
+	}
+	dialer.init()
+
+	return dialer
+}
+
+func DialUDP(network string, laddr, raddr *net.UDPAddr) (*net.UDPConn, error) {
+	dialer := NewDialer()
+	dialer.LocalAddr = laddr
+
+	conn, err := dialer.Dial(network, raddr.String())
+	if err != nil {
+		return nil, fmt.Errorf("dialing UDP %s: %w", raddr.String(), err)
+	}
+
+	udpConn, ok := conn.(*net.UDPConn)
+	if !ok {
+		if err := conn.Close(); err != nil {
+			log.Errorf("Failed to closeConn connection: %v", err)
+		}
+		return nil, fmt.Errorf("expected UDP connection, got different type")
+	}
+
+	return udpConn, nil
+}
+
+func DialTCP(network string, laddr, raddr *net.TCPAddr) (*net.TCPConn, error) {
+	dialer := NewDialer()
+	dialer.LocalAddr = laddr
+
+	conn, err := dialer.Dial(network, raddr.String())
+	if err != nil {
+		return nil, fmt.Errorf("dialing TCP %s: %w", raddr.String(), err)
+	}
+
+	tcpConn, ok := conn.(*net.TCPConn)
+	if !ok {
+		if err := conn.Close(); err != nil {
+			log.Errorf("Failed to close connection: %v", err)
+		}
+		return nil, fmt.Errorf("expected TCP connection, got different type")
+	}
+
+	return tcpConn, nil
+}
diff --git a/util/net/dialer_generic.go b/util/net/dialer_generic.go
index a3c3ad67c74..2e102da50f8 100644
--- a/util/net/dialer_generic.go
+++ b/util/net/dialer_generic.go
@@ -1,19 +1,123 @@
-//go:build !linux || android
+//go:build !android && !ios
 
 package net
 
 import (
+	"context"
+	"fmt"
 	"net"
+	"sync"
+
+	"github.com/hashicorp/go-multierror"
+	log "github.com/sirupsen/logrus"
+)
+
+type DialerDialHookFunc func(ctx context.Context, connID ConnectionID, resolvedAddresses []net.IPAddr) error
+type DialerCloseHookFunc func(connID ConnectionID, conn *net.Conn) error
+
+var (
+	dialerDialHooksMutex  sync.RWMutex
+	dialerDialHooks       []DialerDialHookFunc
+	dialerCloseHooksMutex sync.RWMutex
+	dialerCloseHooks      []DialerCloseHookFunc
 )
 
-func NewDialer() *net.Dialer {
-	return &net.Dialer{}
+// AddDialerHook allows adding a new hook to be executed before dialing.
+func AddDialerHook(hook DialerDialHookFunc) {
+	dialerDialHooksMutex.Lock()
+	defer dialerDialHooksMutex.Unlock()
+	dialerDialHooks = append(dialerDialHooks, hook)
 }
 
-func DialUDP(network string, laddr, raddr *net.UDPAddr) (*net.UDPConn, error) {
-	return net.DialUDP(network, laddr, raddr)
+// AddDialerCloseHook allows adding a new hook to be executed on connection close.
+func AddDialerCloseHook(hook DialerCloseHookFunc) {
+	dialerCloseHooksMutex.Lock()
+	defer dialerCloseHooksMutex.Unlock()
+	dialerCloseHooks = append(dialerCloseHooks, hook)
+}
+
+// RemoveDialerHook removes all dialer hooks.
+func RemoveDialerHooks() {
+	dialerDialHooksMutex.Lock()
+	defer dialerDialHooksMutex.Unlock()
+	dialerDialHooks = nil
+
+	dialerCloseHooksMutex.Lock()
+	defer dialerCloseHooksMutex.Unlock()
+	dialerCloseHooks = nil
 }
 
-func DialTCP(network string, laddr, raddr *net.TCPAddr) (*net.TCPConn, error) {
-	return net.DialTCP(network, laddr, raddr)
+// DialContext wraps the net.Dialer's DialContext method to use the custom connection
+func (d *Dialer) DialContext(ctx context.Context, network, address string) (net.Conn, error) {
+	var resolver *net.Resolver
+	if d.Resolver != nil {
+		resolver = d.Resolver
+	}
+
+	connID := GenerateConnID()
+	if dialerDialHooks != nil {
+		if err := calliDialerHooks(ctx, connID, address, resolver); err != nil {
+			log.Errorf("Failed to call dialer hooks: %v", err)
+		}
+	}
+
+	conn, err := d.Dialer.DialContext(ctx, network, address)
+	if err != nil {
+		return nil, fmt.Errorf("dial: %w", err)
+	}
+
+	// Wrap the connection in Conn to handle Close with hooks
+	return &Conn{Conn: conn, ID: connID}, nil
+}
+
+// Dial wraps the net.Dialer's Dial method to use the custom connection
+func (d *Dialer) Dial(network, address string) (net.Conn, error) {
+	return d.DialContext(context.Background(), network, address)
+}
+
+// Conn wraps a net.Conn to override the Close method
+type Conn struct {
+	net.Conn
+	ID ConnectionID
+}
+
+// Close overrides the net.Conn Close method to execute all registered hooks after closing the connection
+func (c *Conn) Close() error {
+	err := c.Conn.Close()
+
+	dialerCloseHooksMutex.RLock()
+	defer dialerCloseHooksMutex.RUnlock()
+
+	for _, hook := range dialerCloseHooks {
+		if err := hook(c.ID, &c.Conn); err != nil {
+			log.Errorf("Error executing dialer close hook: %v", err)
+		}
+	}
+
+	return err
+}
+
+func calliDialerHooks(ctx context.Context, connID ConnectionID, address string, resolver *net.Resolver) error {
+	host, _, err := net.SplitHostPort(address)
+	if err != nil {
+		return fmt.Errorf("split host and port: %w", err)
+	}
+	ips, err := resolver.LookupIPAddr(ctx, host)
+	if err != nil {
+		return fmt.Errorf("failed to resolve address %s: %w", address, err)
+	}
+
+	log.Debugf("Dialer resolved IPs for %s: %v", address, ips)
+
+	var result *multierror.Error
+
+	dialerDialHooksMutex.RLock()
+	defer dialerDialHooksMutex.RUnlock()
+	for _, hook := range dialerDialHooks {
+		if err := hook(ctx, connID, ips); err != nil {
+			result = multierror.Append(result, fmt.Errorf("executing dial hook: %w", err))
+		}
+	}
+
+	return result.ErrorOrNil()
 }
diff --git a/util/net/dialer_linux.go b/util/net/dialer_linux.go
index d559490c517..aed5c59a322 100644
--- a/util/net/dialer_linux.go
+++ b/util/net/dialer_linux.go
@@ -2,59 +2,11 @@
 
 package net
 
-import (
-	"context"
-	"fmt"
-	"net"
-	"syscall"
+import "syscall"
 
-	log "github.com/sirupsen/logrus"
-)
-
-func NewDialer() *net.Dialer {
-	return &net.Dialer{
-		Control: func(network, address string, c syscall.RawConn) error {
-			return SetRawSocketMark(c)
-		},
-	}
-}
-
-func DialUDP(network string, laddr, raddr *net.UDPAddr) (*net.UDPConn, error) {
-	dialer := NewDialer()
-	dialer.LocalAddr = laddr
-
-	conn, err := dialer.DialContext(context.Background(), network, raddr.String())
-	if err != nil {
-		return nil, fmt.Errorf("dialing UDP %s: %w", raddr.String(), err)
-	}
-
-	udpConn, ok := conn.(*net.UDPConn)
-	if !ok {
-		if err := conn.Close(); err != nil {
-			log.Errorf("Failed to close connection: %v", err)
-		}
-		return nil, fmt.Errorf("expected UDP connection, got different type")
+// init configures the net.Dialer Control function to set the fwmark on the socket
+func (d *Dialer) init() {
+	d.Dialer.Control = func(_, _ string, c syscall.RawConn) error {
+		return SetRawSocketMark(c)
 	}
-
-	return udpConn, nil
-}
-
-func DialTCP(network string, laddr, raddr *net.TCPAddr) (*net.TCPConn, error) {
-	dialer := NewDialer()
-	dialer.LocalAddr = laddr
-
-	conn, err := dialer.DialContext(context.Background(), network, raddr.String())
-	if err != nil {
-		return nil, fmt.Errorf("dialing TCP %s: %w", raddr.String(), err)
-	}
-
-	tcpConn, ok := conn.(*net.TCPConn)
-	if !ok {
-		if err := conn.Close(); err != nil {
-			log.Errorf("Failed to close connection: %v", err)
-		}
-		return nil, fmt.Errorf("expected TCP connection, got different type")
-	}
-
-	return tcpConn, nil
 }
diff --git a/util/net/dialer_nonlinux.go b/util/net/dialer_nonlinux.go
new file mode 100644
index 00000000000..3254e6d066b
--- /dev/null
+++ b/util/net/dialer_nonlinux.go
@@ -0,0 +1,6 @@
+//go:build !linux || android
+
+package net
+
+func (d *Dialer) init() {
+}
diff --git a/util/net/listener.go b/util/net/listener.go
new file mode 100644
index 00000000000..f4d769f587e
--- /dev/null
+++ b/util/net/listener.go
@@ -0,0 +1,21 @@
+package net
+
+import (
+	"net"
+)
+
+// ListenerConfig extends the standard net.ListenConfig with the ability to execute hooks before
+// responding via the socket and after closing. This can be used to bypass the VPN for listeners.
+type ListenerConfig struct {
+	*net.ListenConfig
+}
+
+// NewListener creates a new ListenerConfig instance.
+func NewListener() *ListenerConfig {
+	listener := &ListenerConfig{
+		ListenConfig: &net.ListenConfig{},
+	}
+	listener.init()
+
+	return listener
+}
diff --git a/util/net/listener_generic.go b/util/net/listener_generic.go
index 241c744e528..ae412415ff9 100644
--- a/util/net/listener_generic.go
+++ b/util/net/listener_generic.go
@@ -1,13 +1,154 @@
-//go:build !linux || android
+//go:build !android && !ios
 
 package net
 
-import "net"
+import (
+	"context"
+	"fmt"
+	"net"
+	"sync"
 
-func NewListener() *net.ListenConfig {
-	return &net.ListenConfig{}
+	log "github.com/sirupsen/logrus"
+)
+
+// ListenerWriteHookFunc defines the function signature for write hooks for PacketConn.
+type ListenerWriteHookFunc func(connID ConnectionID, ip *net.IPAddr, data []byte) error
+
+// ListenerCloseHookFunc defines the function signature for close hooks for PacketConn.
+type ListenerCloseHookFunc func(connID ConnectionID, conn net.PacketConn) error
+
+var (
+	listenerWriteHooksMutex sync.RWMutex
+	listenerWriteHooks      []ListenerWriteHookFunc
+	listenerCloseHooksMutex sync.RWMutex
+	listenerCloseHooks      []ListenerCloseHookFunc
+)
+
+// AddListenerWriteHook allows adding a new write hook to be executed before a UDP packet is sent.
+func AddListenerWriteHook(hook ListenerWriteHookFunc) {
+	listenerWriteHooksMutex.Lock()
+	defer listenerWriteHooksMutex.Unlock()
+	listenerWriteHooks = append(listenerWriteHooks, hook)
+}
+
+// AddListenerCloseHook allows adding a new hook to be executed upon closing a UDP connection.
+func AddListenerCloseHook(hook ListenerCloseHookFunc) {
+	listenerCloseHooksMutex.Lock()
+	defer listenerCloseHooksMutex.Unlock()
+	listenerCloseHooks = append(listenerCloseHooks, hook)
+}
+
+// RemoveListenerHooks removes all dialer hooks.
+func RemoveListenerHooks() {
+	listenerWriteHooksMutex.Lock()
+	defer listenerWriteHooksMutex.Unlock()
+	listenerWriteHooks = nil
+
+	listenerCloseHooksMutex.Lock()
+	defer listenerCloseHooksMutex.Unlock()
+	listenerCloseHooks = nil
+}
+
+// ListenPacket listens on the network address and returns a PacketConn
+// which includes support for write hooks.
+func (l *ListenerConfig) ListenPacket(ctx context.Context, network, address string) (net.PacketConn, error) {
+	pc, err := l.ListenConfig.ListenPacket(ctx, network, address)
+	if err != nil {
+		return nil, fmt.Errorf("listen packet: %w", err)
+	}
+	connID := GenerateConnID()
+	return &PacketConn{PacketConn: pc, ID: connID, seenAddrs: &sync.Map{}}, nil
+}
+
+// PacketConn wraps net.PacketConn to override its WriteTo and Close methods to include hook functionality.
+type PacketConn struct {
+	net.PacketConn
+	ID        ConnectionID
+	seenAddrs *sync.Map
+}
+
+// WriteTo writes a packet with payload b to addr, executing registered write hooks beforehand.
+func (c *PacketConn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
+	callWriteHooks(c.ID, c.seenAddrs, b, addr)
+	return c.PacketConn.WriteTo(b, addr)
+}
+
+// Close overrides the net.PacketConn Close method to execute all registered hooks before closing the connection.
+func (c *PacketConn) Close() error {
+	c.seenAddrs = &sync.Map{}
+	return closeConn(c.ID, c.PacketConn)
+}
+
+// UDPConn wraps net.UDPConn to override its WriteTo and Close methods to include hook functionality.
+type UDPConn struct {
+	*net.UDPConn
+	ID        ConnectionID
+	seenAddrs *sync.Map
+}
+
+// WriteTo writes a packet with payload b to addr, executing registered write hooks beforehand.
+func (c *UDPConn) WriteTo(b []byte, addr net.Addr) (n int, err error) {
+	callWriteHooks(c.ID, c.seenAddrs, b, addr)
+	return c.UDPConn.WriteTo(b, addr)
+}
+
+// Close overrides the net.UDPConn Close method to execute all registered hooks before closing the connection.
+func (c *UDPConn) Close() error {
+	c.seenAddrs = &sync.Map{}
+	return closeConn(c.ID, c.UDPConn)
+}
+
+func callWriteHooks(id ConnectionID, seenAddrs *sync.Map, b []byte, addr net.Addr) {
+	// Lookup the address in the seenAddrs map to avoid calling the hooks for every write
+	if _, loaded := seenAddrs.LoadOrStore(addr.String(), true); !loaded {
+		ipStr, _, splitErr := net.SplitHostPort(addr.String())
+		if splitErr != nil {
+			log.Errorf("Error splitting IP address and port: %v", splitErr)
+			return
+		}
+
+		ip, err := net.ResolveIPAddr("ip", ipStr)
+		if err != nil {
+			log.Errorf("Error resolving IP address: %v", err)
+			return
+		}
+		log.Debugf("Listener resolved IP for %s: %s", addr, ip)
+
+		func() {
+			listenerWriteHooksMutex.RLock()
+			defer listenerWriteHooksMutex.RUnlock()
+
+			for _, hook := range listenerWriteHooks {
+				if err := hook(id, ip, b); err != nil {
+					log.Errorf("Error executing listener write hook: %v", err)
+				}
+			}
+		}()
+	}
+}
+
+func closeConn(id ConnectionID, conn net.PacketConn) error {
+	err := conn.Close()
+
+	listenerCloseHooksMutex.RLock()
+	defer listenerCloseHooksMutex.RUnlock()
+
+	for _, hook := range listenerCloseHooks {
+		if err := hook(id, conn); err != nil {
+			log.Errorf("Error executing listener close hook: %v", err)
+		}
+	}
+
+	return err
 }
 
-func ListenUDP(network string, locAddr *net.UDPAddr) (*net.UDPConn, error) {
-	return net.ListenUDP(network, locAddr)
+// ListenUDP listens on the network address and returns a transport.UDPConn
+// which includes support for write and close hooks.
+func ListenUDP(network string, laddr *net.UDPAddr) (*UDPConn, error) {
+	udpConn, err := net.ListenUDP(network, laddr)
+	if err != nil {
+		return nil, fmt.Errorf("listen UDP: %w", err)
+	}
+	connID := GenerateConnID()
+	return &UDPConn{UDPConn: udpConn, ID: connID, seenAddrs: &sync.Map{}}, nil
 }
diff --git a/util/net/listener_linux.go b/util/net/listener_linux.go
index 7b9bda97c7d..8d332160a04 100644
--- a/util/net/listener_linux.go
+++ b/util/net/listener_linux.go
@@ -3,28 +3,12 @@
 package net
 
 import (
-	"context"
-	"fmt"
-	"net"
 	"syscall"
 )
 
-func NewListener() *net.ListenConfig {
-	return &net.ListenConfig{
-		Control: func(network, address string, c syscall.RawConn) error {
-			return SetRawSocketMark(c)
-		},
+// init configures the net.ListenerConfig Control function to set the fwmark on the socket
+func (l *ListenerConfig) init() {
+	l.ListenConfig.Control = func(_, _ string, c syscall.RawConn) error {
+		return SetRawSocketMark(c)
 	}
 }
-
-func ListenUDP(network string, laddr *net.UDPAddr) (*net.UDPConn, error) {
-	pc, err := NewListener().ListenPacket(context.Background(), network, laddr.String())
-	if err != nil {
-		return nil, fmt.Errorf("listening on %s:%s with fwmark: %w", network, laddr, err)
-	}
-	udpConn, ok := pc.(*net.UDPConn)
-	if !ok {
-		return nil, fmt.Errorf("packetConn is not a *net.UDPConn")
-	}
-	return udpConn, nil
-}
diff --git a/util/net/listener_mobile.go b/util/net/listener_mobile.go
new file mode 100644
index 00000000000..0dbbb360b53
--- /dev/null
+++ b/util/net/listener_mobile.go
@@ -0,0 +1,11 @@
+//go:build android || ios
+
+package net
+
+import (
+	"net"
+)
+
+func ListenUDP(network string, laddr *net.UDPAddr) (*net.UDPConn, error) {
+	return net.ListenUDP(network, laddr)
+}
diff --git a/util/net/listener_nonlinux.go b/util/net/listener_nonlinux.go
new file mode 100644
index 00000000000..fb6eadaaad8
--- /dev/null
+++ b/util/net/listener_nonlinux.go
@@ -0,0 +1,6 @@
+//go:build !linux || android
+
+package net
+
+func (l *ListenerConfig) init() {
+}
diff --git a/util/net/net.go b/util/net/net.go
index 5714e52294e..9ea7ae80340 100644
--- a/util/net/net.go
+++ b/util/net/net.go
@@ -1,6 +1,17 @@
 package net
 
+import "github.com/google/uuid"
+
 const (
 	// NetbirdFwmark is the fwmark value used by Netbird via wireguard
 	NetbirdFwmark = 0x1BD00
 )
+
+// ConnectionID provides a globally unique identifier for network connections.
+// It's used to track connections throughout their lifecycle so the close hook can correlate with the dial hook.
+type ConnectionID string
+
+// GenerateConnID generates a unique identifier for each connection.
+func GenerateConnID() ConnectionID {
+	return ConnectionID(uuid.NewString())
+}