From d3b63c6be9e60779b24c53c3ee3298cc228b4279 Mon Sep 17 00:00:00 2001 From: PizzaLovingNerd Date: Mon, 8 Jun 2026 12:38:46 -0700 Subject: [PATCH 01/19] [infrastructure] Better support for atomic distros in install.sh, docker fixes in getting-started.sh (#6139) * Made the docker check first for getting-started.sh, better atomic support for install.sh * Check for docker socket perms * Added fallback for systems without rpm-ostree or bootc. * macOS fix for docker socket check * Change error message for docker group. No longer using a blanket recommendation for the docker group. --- infrastructure_files/getting-started.sh | 45 ++++++++++++++++++++++++- release_files/install.sh | 21 ++++++++++-- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/infrastructure_files/getting-started.sh b/infrastructure_files/getting-started.sh index 910cea095..770cecc44 100755 --- a/infrastructure_files/getting-started.sh +++ b/infrastructure_files/getting-started.sh @@ -19,6 +19,46 @@ readonly MSG_SEPARATOR="==========================================" # Utility Functions ############################################ +check_docker_sock_perms() { + local sock="${DOCKER_HOST:-unix:///var/run/docker.sock}" + sock="${sock#unix://}" + + if [[ ! -S "$sock" ]]; then + return 0 + fi + + if [[ ! -r "$sock" ]] || [[ ! -w "$sock" ]]; then + local group + if [[ "${OSTYPE}" == "darwin"* ]]; then + group="$(stat -f '%Sg' "$sock")" + else + group="$(stat -c '%G' "$sock")" + fi + + echo "Cannot access Docker socket: $sock" > /dev/stderr + echo "" > /dev/stderr + echo "Socket permissions:" > /dev/stderr + ls -l "$sock" > /dev/stderr + echo "" > /dev/stderr + + if [[ "$group" == "docker" ]]; then + echo "Your user may need to be added to the '$group' group:" > /dev/stderr + echo " sudo usermod -aG $group \"$USER\"" > /dev/stderr + echo "Then log out and back in, or run this for the current shell:" > /dev/stderr + echo " newgrp $group" > /dev/stderr + echo "Note: newgrp is temporary; usermod is the permanent group change." > /dev/stderr + else + echo "The Docker socket is owned by the '$group' group, which is not the standard 'docker' group." > /dev/stderr + echo "For safety, this script will not suggest adding your user to '$group'." > /dev/stderr + echo "Instead, either run this script with appropriate privileges (for example, via sudo) or follow Docker's post-install steps to configure access via the 'docker' group:" > /dev/stderr + echo " https://docs.docker.com/engine/install/linux-postinstall/" > /dev/stderr + fi + + exit 1 + fi + return 0 +} + check_docker_compose() { if command -v docker-compose &> /dev/null then @@ -581,12 +621,15 @@ start_services_and_show_instructions() { } init_environment() { + # Check if docker compose is installed using check_docker_compose function + DOCKER_COMPOSE_COMMAND=$(check_docker_compose) + check_docker_sock_perms + initialize_default_values configure_domain configure_reverse_proxy check_jq - DOCKER_COMPOSE_COMMAND=$(check_docker_compose) check_existing_installation generate_configuration_files diff --git a/release_files/install.sh b/release_files/install.sh index 1e71936f3..a002de472 100755 --- a/release_files/install.sh +++ b/release_files/install.sh @@ -417,15 +417,30 @@ if type uname >/dev/null 2>&1; then # Check the availability of a compatible package manager if check_use_bin_variable; then PACKAGE_MANAGER="bin" + elif [ -e /run/ostree-booted ]; then + if [ -x "$(command -v rpm-ostree)" ]; then + PACKAGE_MANAGER="rpm-ostree" + echo "The installation will be performed using rpm-ostree package manager" + elif [ -x "$(command -v bootc)" ]; then + echo "Detected bootc system without rpm-ostree." >&2 + echo "NetBird cannot be installed via package manager on this system." >&2 + echo "Options:" >&2 + echo " 1. Install via Distrobox (instructions in the installation docs)" >&2 + echo " 2. Rebuild your base image with rpm-ostree included" >&2 + echo " 3. Bake NetBird into your Containerfile" >&2 + exit 1 + else + echo "Detected ostree-booted system without rpm-ostree or bootc." >&2 + echo "NetBird cannot be installed automatically on this atomic system." >&2 + echo "Please install NetBird by rebuilding your base image or use a supported package manager." >&2 + exit 1 + fi elif [ -x "$(command -v apt-get)" ]; then PACKAGE_MANAGER="apt" echo "The installation will be performed using apt package manager" elif [ -x "$(command -v dnf)" ]; then PACKAGE_MANAGER="dnf" echo "The installation will be performed using dnf package manager" - elif [ -x "$(command -v rpm-ostree)" ]; then - PACKAGE_MANAGER="rpm-ostree" - echo "The installation will be performed using rpm-ostree package manager" elif [ -x "$(command -v yum)" ]; then PACKAGE_MANAGER="yum" echo "The installation will be performed using yum package manager" From 8e1d5b78c251837ed796fbcdf56eed43ab833933 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:24:17 +0900 Subject: [PATCH 02/19] [client] Preserve user deselect-all across management route sync (#6363) --- client/internal/routemanager/manager.go | 7 ++ .../routemanager/selector_management_test.go | 71 +++++++++++++++++++ .../internal/routeselector/routeselector.go | 8 +++ 3 files changed, 86 insertions(+) create mode 100644 client/internal/routemanager/selector_management_test.go diff --git a/client/internal/routemanager/manager.go b/client/internal/routemanager/manager.go index 839ec14c0..f10a2b5e0 100644 --- a/client/internal/routemanager/manager.go +++ b/client/internal/routemanager/manager.go @@ -700,6 +700,13 @@ func resolveURLsToIPs(urls []string) []net.IP { // updateRouteSelectorFromManagement updates the route selector based on the isSelected status from the management server func (m *DefaultManager) updateRouteSelectorFromManagement(clientRoutes route.HAMap) { + // An explicit user "deselect all" must not be overridden by management auto-apply. + // Auto-applying an exit node here would call SelectRoutes, which clears the + // deselect-all flag and re-enables every route the user turned off. + if m.routeSelector.IsDeselectAll() { + return + } + exitNodeInfo := m.collectExitNodeInfo(clientRoutes) if len(exitNodeInfo.allIDs) == 0 { return diff --git a/client/internal/routemanager/selector_management_test.go b/client/internal/routemanager/selector_management_test.go new file mode 100644 index 000000000..04659db65 --- /dev/null +++ b/client/internal/routemanager/selector_management_test.go @@ -0,0 +1,71 @@ +package routemanager + +import ( + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/internal/routeselector" + "github.com/netbirdio/netbird/route" +) + +func exitNodeRoutes(netID route.NetID, skipAutoApply bool) route.HAMap { + haID := route.HAUniqueID(string(netID) + "|0.0.0.0/0") + return route.HAMap{ + haID: []*route.Route{ + { + ID: "r-" + route.ID(netID), + NetID: netID, + Network: netip.MustParsePrefix("0.0.0.0/0"), + NetworkType: route.IPv4Network, + Enabled: true, + SkipAutoApply: skipAutoApply, + }, + }, + } +} + +func TestUpdateRouteSelectorFromManagement(t *testing.T) { + t.Run("management auto-apply selects exit node without user selection", func(t *testing.T) { + m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()} + routes := exitNodeRoutes("exit1", false) + + m.updateRouteSelectorFromManagement(routes) + + require.True(t, m.routeSelector.IsSelected("exit1"), "auto-apply exit node should be selected") + require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "selected exit node should pass the filter") + }) + + t.Run("management SkipAutoApply leaves exit node deselected", func(t *testing.T) { + m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()} + routes := exitNodeRoutes("exit1", true) + + m.updateRouteSelectorFromManagement(routes) + + require.False(t, m.routeSelector.IsSelected("exit1"), "SkipAutoApply exit node should not be selected") + require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "deselected exit node should be filtered out") + }) + + t.Run("user selection is not overridden by management", func(t *testing.T) { + m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()} + require.NoError(t, m.routeSelector.SelectRoutes([]route.NetID{"exit1"}, true, []route.NetID{"exit1"})) + routes := exitNodeRoutes("exit1", true) + + m.updateRouteSelectorFromManagement(routes) + + require.True(t, m.routeSelector.IsSelected("exit1"), "explicit user selection must survive a management sync that wants to skip auto-apply") + require.Len(t, m.routeSelector.FilterSelectedExitNodes(routes), 1, "user-selected exit node should pass the filter") + }) + + t.Run("deselect-all is preserved across a management sync", func(t *testing.T) { + m := &DefaultManager{routeSelector: routeselector.NewRouteSelector()} + m.routeSelector.DeselectAllRoutes() + routes := exitNodeRoutes("exit1", false) + + m.updateRouteSelectorFromManagement(routes) + + require.True(t, m.routeSelector.IsDeselectAll(), "an explicit deselect-all must not be cleared by management auto-apply") + require.Empty(t, m.routeSelector.FilterSelectedExitNodes(routes), "no routes should be selected while deselect-all is set") + }) +} diff --git a/client/internal/routeselector/routeselector.go b/client/internal/routeselector/routeselector.go index 2ddc24bf2..b9991cd37 100644 --- a/client/internal/routeselector/routeselector.go +++ b/client/internal/routeselector/routeselector.go @@ -116,6 +116,14 @@ func (rs *RouteSelector) DeselectAllRoutes() { clear(rs.selectedRoutes) } +// IsDeselectAll reports whether the user has explicitly deselected all routes. +func (rs *RouteSelector) IsDeselectAll() bool { + rs.mu.RLock() + defer rs.mu.RUnlock() + + return rs.deselectAll +} + // IsSelected checks if a specific route is selected. func (rs *RouteSelector) IsSelected(routeID route.NetID) bool { rs.mu.RLock() From 106527182ffda88676fc165f31d33062d789e2ca Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:24:51 +0900 Subject: [PATCH 03/19] [client] Snapshot iptables rule maps before persisting state (#6345) --- client/firewall/iptables/acl_linux.go | 14 +++++++---- client/firewall/iptables/router_linux.go | 11 +++++++-- client/firewall/iptables/rulestore_linux.go | 26 ++++++++++++++++++++- 3 files changed, 44 insertions(+), 7 deletions(-) diff --git a/client/firewall/iptables/acl_linux.go b/client/firewall/iptables/acl_linux.go index e5e19cec9..4b4cebf9c 100644 --- a/client/firewall/iptables/acl_linux.go +++ b/client/firewall/iptables/acl_linux.go @@ -3,6 +3,7 @@ package iptables import ( "errors" "fmt" + "maps" "net" "slices" @@ -421,12 +422,17 @@ func (m *aclManager) updateState() { currentState.Lock() defer currentState.Unlock() + // Clone the maps so the persisted state holds a private snapshot. The + // live maps keep being mutated by subsequent rule operations while the + // state manager marshals the state from its periodic-save goroutine. + // Sharing them by reference races the two and aborts the process with a + // concurrent map iteration and write. if m.v6 { - currentState.ACLEntries6 = m.entries - currentState.ACLIPsetStore6 = m.ipsetStore + currentState.ACLEntries6 = maps.Clone(m.entries) + currentState.ACLIPsetStore6 = m.ipsetStore.clone() } else { - currentState.ACLEntries = m.entries - currentState.ACLIPsetStore = m.ipsetStore + currentState.ACLEntries = maps.Clone(m.entries) + currentState.ACLIPsetStore = m.ipsetStore.clone() } if err := m.stateManager.UpdateState(currentState); err != nil { diff --git a/client/firewall/iptables/router_linux.go b/client/firewall/iptables/router_linux.go index 290e5da1e..42d305f5c 100644 --- a/client/firewall/iptables/router_linux.go +++ b/client/firewall/iptables/router_linux.go @@ -4,6 +4,7 @@ package iptables import ( "fmt" + "maps" "net/netip" "strconv" "strings" @@ -749,11 +750,17 @@ func (r *router) updateState() { currentState.Lock() defer currentState.Unlock() + // Clone the rule map so the persisted state holds a private snapshot. The + // live map keeps being mutated by subsequent rule operations while the + // state manager marshals the state from its periodic-save goroutine. + // Sharing it by reference races the two and aborts the process with a + // concurrent map iteration and write. The ipset counter guards itself + // during marshaling, so it can be shared directly. if r.v6 { - currentState.RouteRules6 = r.rules + currentState.RouteRules6 = maps.Clone(r.rules) currentState.RouteIPsetCounter6 = r.ipsetCounter } else { - currentState.RouteRules = r.rules + currentState.RouteRules = maps.Clone(r.rules) currentState.RouteIPsetCounter = r.ipsetCounter } diff --git a/client/firewall/iptables/rulestore_linux.go b/client/firewall/iptables/rulestore_linux.go index 004c512a4..a6d36540e 100644 --- a/client/firewall/iptables/rulestore_linux.go +++ b/client/firewall/iptables/rulestore_linux.go @@ -1,6 +1,9 @@ package iptables -import "encoding/json" +import ( + "encoding/json" + "maps" +) type ipList struct { ips map[string]struct{} @@ -19,6 +22,14 @@ func (s *ipList) addIP(ip string) { s.ips[ip] = struct{}{} } +// clone returns a deep copy of the ipList with its own ips map. +func (s *ipList) clone() *ipList { + if s == nil { + return nil + } + return &ipList{ips: maps.Clone(s.ips)} +} + // MarshalJSON implements json.Marshaler func (s *ipList) MarshalJSON() ([]byte, error) { return json.Marshal(struct { @@ -55,6 +66,19 @@ func newIpsetStore() *ipsetStore { } } +// clone returns a deep copy of the ipsetStore with its own ipsets map and +// independent ipList entries. +func (s *ipsetStore) clone() *ipsetStore { + if s == nil { + return nil + } + cloned := &ipsetStore{ipsets: make(map[string]*ipList, len(s.ipsets))} + for name, list := range s.ipsets { + cloned.ipsets[name] = list.clone() + } + return cloned +} + func (s *ipsetStore) ipset(ipsetName string) (*ipList, bool) { r, ok := s.ipsets[ipsetName] return r, ok From 367d37050b6898032d6ed1295c5546d6e3777c31 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 9 Jun 2026 17:25:46 +0900 Subject: [PATCH 04/19] [relay, client] Fall back to WebSocket relay transport on oversized QUIC datagrams (#6339) --- shared/relay/client/client.go | 73 ++++++++- shared/relay/client/dialer/capability.go | 18 +++ shared/relay/client/dialer/net/err.go | 5 + shared/relay/client/dialer/quic/conn.go | 20 ++- shared/relay/client/dialer/quic/quic.go | 30 ++++ shared/relay/client/dialer/race_dialer.go | 39 +++++ .../relay/client/dialer/race_dialer_test.go | 63 ++++++++ shared/relay/client/dialers_generic.go | 43 +++++- shared/relay/client/dialers_generic_test.go | 101 +++++++++++++ shared/relay/client/dialers_js.go | 6 +- shared/relay/client/manager.go | 16 +- shared/relay/client/picker.go | 2 + shared/relay/client/transport.go | 129 ++++++++++++++++ shared/relay/client/transport_test.go | 140 ++++++++++++++++++ 14 files changed, 663 insertions(+), 22 deletions(-) create mode 100644 shared/relay/client/dialer/capability.go create mode 100644 shared/relay/client/dialers_generic_test.go create mode 100644 shared/relay/client/transport.go create mode 100644 shared/relay/client/transport_test.go diff --git a/shared/relay/client/client.go b/shared/relay/client/client.go index 1800bddb2..002b8d134 100644 --- a/shared/relay/client/client.go +++ b/shared/relay/client/client.go @@ -9,12 +9,14 @@ import ( "net/url" "strings" "sync" + "sync/atomic" "time" log "github.com/sirupsen/logrus" auth "github.com/netbirdio/netbird/shared/relay/auth/hmac" "github.com/netbirdio/netbird/shared/relay/client/dialer" + netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net" "github.com/netbirdio/netbird/shared/relay/healthcheck" "github.com/netbirdio/netbird/shared/relay/messages" ) @@ -172,6 +174,19 @@ type Client struct { stateSubscription *PeersStateSubscription mtu uint16 + + // transportFallback, when set, records datagram-too-large failures so a + // datagram-sized transport is avoided on subsequent connects. Shared via + // the manager. + transportFallback *transportFallback + // datagramFallbackTriggered guards a single fallback per connection so a + // burst of oversized datagrams triggers one reconnect, not many. + datagramFallbackTriggered atomic.Bool +} + +// SetTransportFallback wires the shared datagram-transport fallback tracker. +func (c *Client) SetTransportFallback(tf *transportFallback) { + c.transportFallback = tf } // NewClient creates a new client for the relay server. The client is not connected to the server until the Connect @@ -361,12 +376,13 @@ func (c *Client) Close() error { } func (c *Client) connect(ctx context.Context) (*RelayAddr, error) { - dialers := c.getDialers() + mode := transportModeFromEnv() + dialers := c.getDialers(mode) var conn net.Conn if c.serverIP.IsValid() { var err error - conn, err = c.dialRaceDirect(ctx, dialers) + conn, err = c.dialRaceDirect(ctx, mode, dialers) if err != nil { c.log.Infof("dial via server IP %s failed, falling back to FQDN: %v", c.serverIP, err) conn = nil @@ -375,6 +391,9 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) { if conn == nil { rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, c.connectionURL, dialers...) + if mode.sequential() { + rd.WithSequential() + } var err error conn, err = rd.Dial(ctx) if err != nil { @@ -382,6 +401,7 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) { } } c.relayConn = conn + c.datagramFallbackTriggered.Store(false) instanceURL, err := c.handShake(ctx) if err != nil { @@ -396,7 +416,7 @@ func (c *Client) connect(ctx context.Context) (*RelayAddr, error) { } // dialRaceDirect dials c.serverIP, preserving the original FQDN as the TLS ServerName for SNI. -func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) (net.Conn, error) { +func (c *Client) dialRaceDirect(ctx context.Context, mode TransportMode, dialers []dialer.DialeFn) (net.Conn, error) { directURL, serverName, err := substituteHost(c.connectionURL, c.serverIP) if err != nil { return nil, fmt.Errorf("substitute host: %w", err) @@ -406,6 +426,9 @@ func (c *Client) dialRaceDirect(ctx context.Context, dialers []dialer.DialeFn) ( rd := dialer.NewRaceDial(c.log, dialer.DefaultConnectionTimeout, directURL, dialers...). WithServerName(serverName) + if mode.sequential() { + rd.WithSequential() + } return rd.Dial(ctx) } @@ -631,13 +654,53 @@ func (c *Client) writeTo(containerRef *connContainer, dstID messages.PeerID, pay } // the write always return with 0 length because the underling does not support the size feedback. - _, err = c.relayConn.Write(msg) + conn := c.relayConn + _, err = conn.Write(msg) if err != nil { - c.log.Errorf("failed to write transport message: %s", err) + if errors.Is(err, netErr.ErrDatagramTooLarge) { + c.onDatagramTooLarge(conn, err) + } else { + c.log.Errorf("failed to write transport message: %s", err) + } } return len(payload), err } +// onDatagramTooLarge reacts to a datagram rejected as too large for the path. +// When a non-datagram transport is available, it records a fallback for this +// server and closes the connection so the reconnect avoids datagram-sized +// transports. A single fallback is triggered per connection regardless of how +// many oversized datagrams arrive. cause carries the datagram size and budget. +func (c *Client) onDatagramTooLarge(conn net.Conn, cause error) { + // Handle one oversized datagram per connection; a burst triggers a single + // fallback (and a single log line), not many. + if !c.datagramFallbackTriggered.CompareAndSwap(false, true) { + return + } + + // If the selected mode offers no non-datagram transport (e.g. pinned to a + // datagram-sized transport), reconnecting would just re-fail, so leave the + // connection up rather than loop. + if len(nonDatagramSized(c.baseDialers(transportModeFromEnv()))) == 0 { + c.log.Warnf("%s, but no non-datagram transport is available, not falling back", cause) + return + } + + // Without the shared tracker a reconnect would just select the same + // transport again and re-fail, so leave the connection up rather than loop. + if c.transportFallback == nil { + c.log.Debugf("%s, but no transport fallback configured, leaving connection up", cause) + return + } + + window := c.transportFallback.recordFailure(c.connectionURL) + c.log.Warnf("%s, avoiding datagram-sized transport for %s", cause, window) + + if err := conn.Close(); err != nil { + c.log.Debugf("close relay connection for transport fallback: %s", err) + } +} + func (c *Client) listenForStopEvents(ctx context.Context, hc *healthcheck.Receiver, conn net.Conn, internalStopFlag *internalStopFlag) { for { select { diff --git a/shared/relay/client/dialer/capability.go b/shared/relay/client/dialer/capability.go new file mode 100644 index 000000000..511cb2ac7 --- /dev/null +++ b/shared/relay/client/dialer/capability.go @@ -0,0 +1,18 @@ +package dialer + +// DatagramSized is implemented by dialers whose connections carry each write in +// a single datagram, so a write can be rejected when it exceeds the path's +// datagram budget (e.g. QUIC). Transports without this capability (e.g. +// WebSocket over TCP) impose no per-write size limit, so the relay client can +// fall back to them when a datagram-sized transport rejects a write as too +// large. The capability is advertised per dialer rather than hardcoded, so a +// new transport only needs to declare whether it is datagram-sized. +type DatagramSized interface { + DatagramSized() +} + +// IsDatagramSized reports whether d produces datagram-sized connections. +func IsDatagramSized(d DialeFn) bool { + _, ok := d.(DatagramSized) + return ok +} diff --git a/shared/relay/client/dialer/net/err.go b/shared/relay/client/dialer/net/err.go index fee844963..c622420dc 100644 --- a/shared/relay/client/dialer/net/err.go +++ b/shared/relay/client/dialer/net/err.go @@ -4,4 +4,9 @@ import "errors" var ( ErrClosedByServer = errors.New("closed by server") + + // ErrDatagramTooLarge is returned when a transport message exceeds the + // QUIC datagram size the path to the relay can carry. The relay client + // treats it as a signal to fall back to a non-datagram transport. + ErrDatagramTooLarge = errors.New("datagram frame too large") ) diff --git a/shared/relay/client/dialer/quic/conn.go b/shared/relay/client/dialer/quic/conn.go index 1d90d7139..a5c982551 100644 --- a/shared/relay/client/dialer/quic/conn.go +++ b/shared/relay/client/dialer/quic/conn.go @@ -8,7 +8,6 @@ import ( "time" "github.com/quic-go/quic-go" - log "github.com/sirupsen/logrus" netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net" ) @@ -52,11 +51,8 @@ func (c *Conn) Read(b []byte) (n int, err error) { } func (c *Conn) Write(b []byte) (int, error) { - err := c.session.SendDatagram(b) - if err != nil { - err = c.remoteCloseErrHandling(err) - log.Errorf("failed to write to QUIC stream: %v", err) - return 0, err + if err := c.session.SendDatagram(b); err != nil { + return 0, c.writeErrHandling(err, len(b)) } return len(b), nil } @@ -95,3 +91,15 @@ func (c *Conn) remoteCloseErrHandling(err error) error { } return err } + +// writeErrHandling normalizes SendDatagram errors. A datagram that exceeds the +// path's QUIC packet budget is mapped to ErrDatagramTooLarge (annotated with the +// datagram size and path budget) so the relay client can fall back to a +// non-datagram transport. +func (c *Conn) writeErrHandling(err error, size int) error { + var tooLarge *quic.DatagramTooLargeError + if errors.As(err, &tooLarge) { + return fmt.Errorf("%w: %d byte datagram over path budget %d", netErr.ErrDatagramTooLarge, size, tooLarge.MaxDatagramPayloadSize) + } + return c.remoteCloseErrHandling(err) +} diff --git a/shared/relay/client/dialer/quic/quic.go b/shared/relay/client/dialer/quic/quic.go index 86f6f178d..5e1758a1c 100644 --- a/shared/relay/client/dialer/quic/quic.go +++ b/shared/relay/client/dialer/quic/quic.go @@ -9,6 +9,7 @@ import ( "time" "github.com/quic-go/quic-go" + "github.com/quic-go/quic-go/logging" log "github.com/sirupsen/logrus" nbnet "github.com/netbirdio/netbird/client/net" @@ -23,6 +24,12 @@ func (d Dialer) Protocol() string { return Network } +// DatagramSized marks QUIC as a datagram-sized transport: relay traffic is +// carried in QUIC DATAGRAM frames, which must fit a single packet. +func (d Dialer) DatagramSized() { + // Intentional marker method; presence is the capability signal. +} + func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, error) { quicURL, err := prepareURL(address) if err != nil { @@ -47,6 +54,7 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, MaxIdleTimeout: 4 * time.Minute, EnableDatagrams: true, InitialPacketSize: nbRelay.QUICInitialPacketSize, + Tracer: connectionTracer(quicURL), } udpConn, err := nbnet.ListenUDP("udp", &net.UDPAddr{Port: 0}) @@ -74,6 +82,28 @@ func (d Dialer) Dial(ctx context.Context, address, serverName string) (net.Conn, return conn, nil } +// connectionTracer returns a QUIC tracer that logs the DPLPMTUD result and the +// reason a relay connection closed, so the path MTU settled on and teardown +// cause are visible in logs. Lines carry the relay address as a structured +// field, matching the rest of the relay client logging. +func connectionTracer(addr string) func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer { + relayLog := log.WithField("relay", addr) + return func(context.Context, logging.Perspective, quic.ConnectionID) *logging.ConnectionTracer { + return &logging.ConnectionTracer{ + UpdatedMTU: func(mtu logging.ByteCount, done bool) { + if done { + relayLog.Infof("QUIC path MTU settled at %d", mtu) + return + } + relayLog.Debugf("QUIC path MTU probing at %d", mtu) + }, + ClosedConnection: func(err error) { + relayLog.Debugf("QUIC connection closed: %v", err) + }, + } + } +} + func prepareURL(address string) (string, error) { var host string var defaultPort string diff --git a/shared/relay/client/dialer/race_dialer.go b/shared/relay/client/dialer/race_dialer.go index 15208b858..aef1ef464 100644 --- a/shared/relay/client/dialer/race_dialer.go +++ b/shared/relay/client/dialer/race_dialer.go @@ -32,6 +32,7 @@ type RaceDial struct { serverName string dialerFns []DialeFn connectionTimeout time.Duration + sequential bool } func NewRaceDial(log *log.Entry, connectionTimeout time.Duration, serverURL string, dialerFns ...DialeFn) *RaceDial { @@ -53,7 +54,21 @@ func (r *RaceDial) WithServerName(serverName string) *RaceDial { return r } +// WithSequential makes Dial try the dialers in order, falling back to the next +// only when one fails to connect, instead of racing them concurrently. +// +// Mutates the receiver and is not safe for concurrent reconfiguration; a +// RaceDial is intended to be constructed per dial and discarded. +func (r *RaceDial) WithSequential() *RaceDial { + r.sequential = true + return r +} + func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) { + if r.sequential { + return r.dialSequential(ctx) + } + connChan := make(chan dialResult, len(r.dialerFns)) winnerConn := make(chan net.Conn, 1) abortCtx, abort := context.WithCancel(ctx) @@ -72,6 +87,30 @@ func (r *RaceDial) Dial(ctx context.Context) (net.Conn, error) { return conn, nil } +// dialSequential tries each dialer in order, returning the first connection and +// falling back to the next on failure. +func (r *RaceDial) dialSequential(ctx context.Context) (net.Conn, error) { + for _, dfn := range r.dialerFns { + if err := ctx.Err(); err != nil { + return nil, err + } + attemptCtx, cancel := context.WithTimeout(ctx, r.connectionTimeout) + r.log.Infof("dialing Relay server via %s", dfn.Protocol()) + conn, err := dfn.Dial(attemptCtx, r.serverURL, r.serverName) + cancel() + if err != nil { + if errors.Is(err, context.Canceled) { + return nil, err + } + r.log.Errorf("failed to dial via %s: %s", dfn.Protocol(), err) + continue + } + r.log.Infof("successfully dialed via: %s", dfn.Protocol()) + return conn, nil + } + return nil, errors.New("failed to dial to Relay server on any protocol") +} + func (r *RaceDial) dial(dfn DialeFn, abortCtx context.Context, connChan chan dialResult) { ctx, cancel := context.WithTimeout(abortCtx, r.connectionTimeout) defer cancel() diff --git a/shared/relay/client/dialer/race_dialer_test.go b/shared/relay/client/dialer/race_dialer_test.go index a53edc00e..bd2f4bb85 100644 --- a/shared/relay/client/dialer/race_dialer_test.go +++ b/shared/relay/client/dialer/race_dialer_test.go @@ -250,3 +250,66 @@ func TestRaceDialFirstSuccessfulDialerWins(t *testing.T) { } } } + +func TestRaceDialSequentialFallback(t *testing.T) { + logger := logrus.NewEntry(logrus.New()) + serverURL := "test.server.com" + + var firstDialed, secondDialed bool + preferred := &MockDialer{ + protocolStr: "quic", + dialFunc: func(ctx context.Context, address string) (net.Conn, error) { + firstDialed = true + return nil, errors.New("quic unreachable") + }, + } + fallbackConn := &MockConn{remoteAddr: &MockAddr{network: "ws"}} + fallback := &MockDialer{ + protocolStr: "ws", + dialFunc: func(ctx context.Context, address string) (net.Conn, error) { + secondDialed = true + return fallbackConn, nil + }, + } + + rd := NewRaceDial(logger, DefaultConnectionTimeout, serverURL, preferred, fallback).WithSequential() + conn, err := rd.Dial(context.Background()) + if err != nil { + t.Fatalf("expected fallback to succeed, got %v", err) + } + if conn != fallbackConn { + t.Errorf("expected fallback connection, got %v", conn) + } + if !firstDialed || !secondDialed { + t.Errorf("expected both dialers attempted in order, first=%v second=%v", firstDialed, secondDialed) + } +} + +func TestRaceDialSequentialPreferredWins(t *testing.T) { + logger := logrus.NewEntry(logrus.New()) + serverURL := "test.server.com" + + preferredConn := &MockConn{remoteAddr: &MockAddr{network: "quic"}} + preferred := &MockDialer{ + protocolStr: "quic", + dialFunc: func(ctx context.Context, address string) (net.Conn, error) { + return preferredConn, nil + }, + } + fallback := &MockDialer{ + protocolStr: "ws", + dialFunc: func(ctx context.Context, address string) (net.Conn, error) { + t.Errorf("fallback dialer must not be tried when preferred succeeds") + return nil, errors.New("should not happen") + }, + } + + rd := NewRaceDial(logger, DefaultConnectionTimeout, serverURL, preferred, fallback).WithSequential() + conn, err := rd.Dial(context.Background()) + if err != nil { + t.Fatalf("expected preferred to succeed, got %v", err) + } + if conn != preferredConn { + t.Errorf("expected preferred connection, got %v", conn) + } +} diff --git a/shared/relay/client/dialers_generic.go b/shared/relay/client/dialers_generic.go index a8ed79961..95e319338 100644 --- a/shared/relay/client/dialers_generic.go +++ b/shared/relay/client/dialers_generic.go @@ -9,11 +9,42 @@ import ( "github.com/netbirdio/netbird/shared/relay/client/dialer/ws" ) -// getDialers returns the list of dialers to use for connecting to the relay server. -func (c *Client) getDialers() []dialer.DialeFn { - if c.mtu > 0 && c.mtu > iface.DefaultMTU { - c.log.Infof("MTU %d exceeds default (%d), forcing WebSocket transport to avoid DATAGRAM frame size issues", c.mtu, iface.DefaultMTU) - return []dialer.DialeFn{ws.Dialer{}} +// getDialers returns the ordered dialers for connecting to the relay server. It +// applies the datagram fallback generically: if this server recently rejected a +// datagram-sized transport, those dialers are dropped, leaving the rest. +func (c *Client) getDialers(mode TransportMode) []dialer.DialeFn { + dialers := c.baseDialers(mode) + + if c.transportFallback != nil && c.transportFallback.avoidDatagramSized(c.connectionURL) { + if filtered := nonDatagramSized(dialers); len(filtered) > 0 { + c.log.Infof("relay recently rejected a datagram-sized transport, avoiding it") + return filtered + } } - return []dialer.DialeFn{quic.Dialer{}, ws.Dialer{}} + return dialers +} + +// baseDialers returns the ordered dialers for the mode, before any datagram +// fallback filtering. For racing modes (auto) the order is irrelevant; for +// prefer modes the first entry is tried before falling back to the second. +func (c *Client) baseDialers(mode TransportMode) []dialer.DialeFn { + switch mode { + case TransportModeWS: + c.log.Infof("%s=ws, using WebSocket transport", EnvRelayTransport) + return []dialer.DialeFn{ws.Dialer{}} + case TransportModeQUIC: + c.log.Infof("%s=quic, using QUIC transport", EnvRelayTransport) + return []dialer.DialeFn{quic.Dialer{}} + } + + all := []dialer.DialeFn{quic.Dialer{}, ws.Dialer{}} + if mode == TransportModePreferWS { + all = []dialer.DialeFn{ws.Dialer{}, quic.Dialer{}} + } + + if c.mtu > 0 && c.mtu > iface.DefaultMTU { + c.log.Infof("MTU %d exceeds default (%d), avoiding datagram-sized transports", c.mtu, iface.DefaultMTU) + return nonDatagramSized(all) + } + return all } diff --git a/shared/relay/client/dialers_generic_test.go b/shared/relay/client/dialers_generic_test.go new file mode 100644 index 000000000..c4ef9cc59 --- /dev/null +++ b/shared/relay/client/dialers_generic_test.go @@ -0,0 +1,101 @@ +//go:build !js + +package client + +import ( + "os" + "testing" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "github.com/netbirdio/netbird/client/iface" + "github.com/netbirdio/netbird/shared/relay/client/dialer" + netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net" + "github.com/netbirdio/netbird/shared/relay/client/dialer/quic" + "github.com/netbirdio/netbird/shared/relay/client/dialer/ws" +) + +// TestDatagramSizedCapability locks the capability the generic fallback relies +// on: QUIC is datagram-sized, WebSocket is not. +func TestDatagramSizedCapability(t *testing.T) { + assert.True(t, dialer.IsDatagramSized(quic.Dialer{}), "QUIC must advertise datagram-sized") + assert.False(t, dialer.IsDatagramSized(ws.Dialer{}), "WebSocket must not advertise datagram-sized") +} + +func protocols(dialers []dialer.DialeFn) []string { + out := make([]string, len(dialers)) + for i, d := range dialers { + out[i] = d.Protocol() + } + return out +} + +func TestGetDialers(t *testing.T) { + const url = "rels://relay.example:443" + + tests := []struct { + name string + mode string + mtu uint16 + preferWS bool + want []string + }{ + {name: "auto races quic and ws", mode: "auto", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}}, + {name: "ws pinned", mode: "ws", mtu: iface.DefaultMTU, want: []string{"WS"}}, + {name: "quic pinned", mode: "quic", mtu: iface.DefaultMTU, want: []string{"quic"}}, + {name: "prefer-quic orders quic first", mode: "prefer-quic", mtu: iface.DefaultMTU, want: []string{"quic", "WS"}}, + {name: "prefer-ws orders ws first", mode: "prefer-ws", mtu: iface.DefaultMTU, want: []string{"WS", "quic"}}, + {name: "mtu above default forces ws", mode: "auto", mtu: iface.DefaultMTU + 100, want: []string{"WS"}}, + {name: "sticky fallback forces ws in auto", mode: "auto", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}}, + {name: "sticky fallback forces ws in prefer-quic", mode: "prefer-quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"WS"}}, + {name: "quic pin overrides sticky fallback", mode: "quic", mtu: iface.DefaultMTU, preferWS: true, want: []string{"quic"}}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Setenv(EnvRelayTransport, tc.mode) + if tc.mode == "" { + os.Unsetenv(EnvRelayTransport) + } + + tf := newTransportFallback() + if tc.preferWS { + tf.recordFailure(url) + } + + c := &Client{ + log: log.WithField("test", t.Name()), + connectionURL: url, + mtu: tc.mtu, + transportFallback: tf, + } + + assert.Equal(t, tc.want, protocols(c.getDialers(transportModeFromEnv()))) + }) + } +} + +// TestStickyFallbackAfterDatagramTooLarge verifies the full chain: an oversized +// datagram records a fallback that makes the next dial pick WebSocket, the way a +// reconnect would after the connection is closed. +func TestStickyFallbackAfterDatagramTooLarge(t *testing.T) { + const url = "rels://relay.example:443" + t.Setenv(EnvRelayTransport, string(TransportModeAuto)) + + c := &Client{ + log: log.WithField("test", t.Name()), + connectionURL: url, + mtu: iface.DefaultMTU, + transportFallback: newTransportFallback(), + } + + // First dial races both transports. + assert.Equal(t, []string{"quic", "WS"}, protocols(c.getDialers(transportModeFromEnv()))) + + // An oversized datagram records the fallback for this server. + c.onDatagramTooLarge(&closeTrackingConn{}, netErr.ErrDatagramTooLarge) + + // The reconnect now sticks to WebSocket. + assert.Equal(t, []string{"WS"}, protocols(c.getDialers(transportModeFromEnv()))) +} diff --git a/shared/relay/client/dialers_js.go b/shared/relay/client/dialers_js.go index 6bd0e6696..c93787729 100644 --- a/shared/relay/client/dialers_js.go +++ b/shared/relay/client/dialers_js.go @@ -7,7 +7,11 @@ import ( "github.com/netbirdio/netbird/shared/relay/client/dialer/ws" ) -func (c *Client) getDialers() []dialer.DialeFn { +func (c *Client) getDialers(_ TransportMode) []dialer.DialeFn { // JS/WASM build only uses WebSocket transport return []dialer.DialeFn{ws.Dialer{}} } + +func (c *Client) baseDialers(_ TransportMode) []dialer.DialeFn { + return []dialer.DialeFn{ws.Dialer{}} +} diff --git a/shared/relay/client/manager.go b/shared/relay/client/manager.go index 3858b3c83..f87da15de 100644 --- a/shared/relay/client/manager.go +++ b/shared/relay/client/manager.go @@ -79,23 +79,30 @@ type Manager struct { cleanupInterval time.Duration keepUnusedServerTime time.Duration + + // transportFallback is shared across home and foreign relay clients so a + // datagram-too-large failure makes that server avoid datagram-sized transports across reconnects. + transportFallback *transportFallback } // NewManager creates a new manager instance. // The serverURL address can be empty. In this case, the manager will not serve. func NewManager(ctx context.Context, serverURLs []string, peerID string, mtu uint16, opts ...ManagerOption) *Manager { tokenStore := &relayAuth.TokenStore{} + tf := newTransportFallback() m := &Manager{ - ctx: ctx, - peerID: peerID, - tokenStore: tokenStore, - mtu: mtu, + ctx: ctx, + peerID: peerID, + tokenStore: tokenStore, + mtu: mtu, + transportFallback: tf, serverPicker: &ServerPicker{ TokenStore: tokenStore, PeerID: peerID, MTU: mtu, ConnectionTimeout: defaultConnectionTimeout, + TransportFallback: tf, }, relayClients: make(map[string]*RelayTrack), onDisconnectedListeners: make(map[string]*list.List), @@ -287,6 +294,7 @@ func (m *Manager) openConnVia(ctx context.Context, serverAddress, peerKey string m.relayClientsMutex.Unlock() relayClient := NewClientWithServerIP(serverAddress, serverIP, m.tokenStore, m.peerID, m.mtu) + relayClient.SetTransportFallback(m.transportFallback) err := relayClient.Connect(m.ctx) if err != nil { rt.err = err diff --git a/shared/relay/client/picker.go b/shared/relay/client/picker.go index 39d0ba072..992e48114 100644 --- a/shared/relay/client/picker.go +++ b/shared/relay/client/picker.go @@ -29,6 +29,7 @@ type ServerPicker struct { PeerID string MTU uint16 ConnectionTimeout time.Duration + TransportFallback *transportFallback } func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) { @@ -70,6 +71,7 @@ func (sp *ServerPicker) PickServer(parentCtx context.Context) (*Client, error) { func (sp *ServerPicker) startConnection(ctx context.Context, resultChan chan connResult, url string) { log.Infof("try to connecting to relay server: %s", url) relayClient := NewClient(url, sp.TokenStore, sp.PeerID, sp.MTU) + relayClient.SetTransportFallback(sp.TransportFallback) err := relayClient.Connect(ctx) resultChan <- connResult{ RelayClient: relayClient, diff --git a/shared/relay/client/transport.go b/shared/relay/client/transport.go new file mode 100644 index 000000000..002707401 --- /dev/null +++ b/shared/relay/client/transport.go @@ -0,0 +1,129 @@ +package client + +import ( + "os" + "strings" + "sync" + "time" + + log "github.com/sirupsen/logrus" + + "github.com/netbirdio/netbird/shared/relay/client/dialer" +) + +// EnvRelayTransport pins the relay transport. Valid values: "auto" (default, +// race QUIC and WebSocket), "quic" (QUIC only), "ws" (WebSocket only), +// "prefer-quic" / "prefer-ws" (try the preferred transport first, fall back to +// the other only if it fails to connect; no race). The prefer modes trade a +// slower connect when the preferred transport is blackholed for deterministic +// transport selection. +const EnvRelayTransport = "NB_RELAY_TRANSPORT" + +const ( + // transportFallbackBase is the initial window a relay server avoids + // datagram-sized transports after a datagram is rejected as too large. + transportFallbackBase = 10 * time.Minute + // transportFallbackMax caps the pinned window when failures repeat. + transportFallbackMax = 60 * time.Minute +) + +// TransportMode selects which relay dialers are used. +type TransportMode string + +const ( + TransportModeAuto TransportMode = "auto" + TransportModeQUIC TransportMode = "quic" + TransportModeWS TransportMode = "ws" + TransportModePreferQUIC TransportMode = "prefer-quic" + TransportModePreferWS TransportMode = "prefer-ws" +) + +// transportModeFromEnv reads EnvRelayTransport, defaulting to auto for an empty +// or unrecognized value. +func transportModeFromEnv() TransportMode { + switch TransportMode(strings.ToLower(strings.TrimSpace(os.Getenv(EnvRelayTransport)))) { + case "", TransportModeAuto: + return TransportModeAuto + case TransportModeQUIC: + return TransportModeQUIC + case TransportModeWS: + return TransportModeWS + case TransportModePreferQUIC: + return TransportModePreferQUIC + case TransportModePreferWS: + return TransportModePreferWS + default: + log.Warnf("invalid %s value %q, using %q", EnvRelayTransport, os.Getenv(EnvRelayTransport), TransportModeAuto) + return TransportModeAuto + } +} + +// sequential reports whether the mode tries dialers in order with fallback +// instead of racing them concurrently. +func (m TransportMode) sequential() bool { + return m == TransportModePreferQUIC || m == TransportModePreferWS +} + +// transportFallback tracks relay servers that have rejected a datagram-sized +// transport (a write too large for the path) and should temporarily avoid such +// transports. It is shared across the relay manager so the preference survives +// client recreation (foreign relay clients are evicted and rebuilt on +// disconnect). Entries are keyed by server URL and expire after a window that +// grows on repeated failures. +type transportFallback struct { + mu sync.Mutex + entries map[string]*fallbackEntry +} + +type fallbackEntry struct { + until time.Time + duration time.Duration +} + +func newTransportFallback() *transportFallback { + return &transportFallback{entries: make(map[string]*fallbackEntry)} +} + +// avoidDatagramSized reports whether serverURL is currently within a window +// where datagram-sized transports should be avoided. +func (f *transportFallback) avoidDatagramSized(serverURL string) bool { + f.mu.Lock() + defer f.mu.Unlock() + e := f.entries[serverURL] + return e != nil && time.Now().Before(e.until) +} + +// recordFailure makes serverURL avoid datagram-sized transports for a window: +// transportFallbackBase on the first failure, doubling up to transportFallbackMax +// when a datagram transport fails again after a previous window expired. It +// returns the active window duration. +func (f *transportFallback) recordFailure(serverURL string) time.Duration { + f.mu.Lock() + defer f.mu.Unlock() + + now := time.Now() + e := f.entries[serverURL] + switch { + case e == nil: + e = &fallbackEntry{duration: transportFallbackBase} + f.entries[serverURL] = e + case now.Before(e.until): + return time.Until(e.until) + default: + e.duration = min(e.duration*2, transportFallbackMax) + } + e.until = now.Add(e.duration) + return e.duration +} + +// nonDatagramSized returns the dialers from in that are not datagram-sized, +// preserving order. +func nonDatagramSized(in []dialer.DialeFn) []dialer.DialeFn { + out := make([]dialer.DialeFn, 0, len(in)) + for _, d := range in { + if !dialer.IsDatagramSized(d) { + out = append(out, d) + } + } + return out +} diff --git a/shared/relay/client/transport_test.go b/shared/relay/client/transport_test.go new file mode 100644 index 000000000..8e10c8d42 --- /dev/null +++ b/shared/relay/client/transport_test.go @@ -0,0 +1,140 @@ +package client + +import ( + "net" + "os" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + netErr "github.com/netbirdio/netbird/shared/relay/client/dialer/net" +) + +// closeTrackingConn records whether Close was called; only Close is exercised. +type closeTrackingConn struct { + net.Conn + closed bool +} + +func (c *closeTrackingConn) Close() error { + c.closed = true + return nil +} + +func TestTransportModeFromEnv(t *testing.T) { + tests := []struct { + value string + want TransportMode + }{ + {"", TransportModeAuto}, + {"auto", TransportModeAuto}, + {"quic", TransportModeQUIC}, + {"QUIC", TransportModeQUIC}, + {"ws", TransportModeWS}, + {" Ws ", TransportModeWS}, + {"prefer-quic", TransportModePreferQUIC}, + {"prefer-ws", TransportModePreferWS}, + {"garbage", TransportModeAuto}, + } + + for _, tc := range tests { + t.Run(tc.value, func(t *testing.T) { + t.Setenv(EnvRelayTransport, tc.value) + if tc.value == "" { + os.Unsetenv(EnvRelayTransport) + } + assert.Equal(t, tc.want, transportModeFromEnv()) + }) + } +} + +func TestTransportFallbackRecordAndExpiry(t *testing.T) { + const url = "rels://relay.example:443" + f := newTransportFallback() + + assert.False(t, f.avoidDatagramSized(url), "no fallback recorded yet") + + d := f.recordFailure(url) + assert.Equal(t, transportFallbackBase, d, "first failure pins for the base window") + assert.True(t, f.avoidDatagramSized(url), "datagram-sized transport avoided within the window") + + // A second failure while still inside the window must not grow the window. + d = f.recordFailure(url) + assert.LessOrEqual(t, d, transportFallbackBase, "still within the active window") + require.NotNil(t, f.entries[url]) + assert.Equal(t, transportFallbackBase, f.entries[url].duration, "duration unchanged inside window") + + // Expire the window: datagram-sized transport allowed again. + f.entries[url].until = time.Now().Add(-time.Second) + assert.False(t, f.avoidDatagramSized(url), "window expired, datagram-sized transport allowed") +} + +func TestTransportFallbackGrowsOnRepeat(t *testing.T) { + const url = "rels://relay.example:443" + f := newTransportFallback() + + want := transportFallbackBase + for i := range 6 { + d := f.recordFailure(url) + assert.Equal(t, want, d, "window after %d expiries", i) + + // expire the window so the next failure is treated as a repeat + f.entries[url].until = time.Now().Add(-time.Second) + + want = min(want*2, transportFallbackMax) + } + + assert.Equal(t, transportFallbackMax, f.entries[url].duration, "window caps at the max") +} + +func TestOnDatagramTooLargeAuto(t *testing.T) { + const url = "rels://relay.example:443" + t.Setenv(EnvRelayTransport, string(TransportModeAuto)) + + tf := newTransportFallback() + c := &Client{ + log: log.WithField("test", t.Name()), + connectionURL: url, + transportFallback: tf, + } + conn := &closeTrackingConn{} + + c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge) + + assert.True(t, conn.closed, "connection closed to force reconnect") + assert.True(t, tf.avoidDatagramSized(url), "fallback recorded for the server") + + // A second oversized datagram on the same connection must not re-close. + conn.closed = false + c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge) + assert.False(t, conn.closed, "single fallback per connection") +} + +func TestOnDatagramTooLargeQUICPinned(t *testing.T) { + const url = "rels://relay.example:443" + t.Setenv(EnvRelayTransport, string(TransportModeQUIC)) + + tf := newTransportFallback() + c := &Client{ + log: log.WithField("test", t.Name()), + connectionURL: url, + transportFallback: tf, + } + conn := &closeTrackingConn{} + + c.onDatagramTooLarge(conn, netErr.ErrDatagramTooLarge) + + assert.False(t, conn.closed, "QUIC pin keeps the connection, no fallback redial") + assert.False(t, tf.avoidDatagramSized(url), "QUIC pin records no fallback") +} + +func TestTransportFallbackPerServer(t *testing.T) { + f := newTransportFallback() + f.recordFailure("rels://a.example:443") + + assert.True(t, f.avoidDatagramSized("rels://a.example:443")) + assert.False(t, f.avoidDatagramSized("rels://b.example:443"), "fallback is scoped to one server") +} From d56859dc5deb0c421d3831ea74a4ae6471539b43 Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 9 Jun 2026 19:26:03 +0900 Subject: [PATCH 05/19] [client] Filter DNS fallback upstreams matching our server IP to prevent loops (#6183) --- client/internal/dns/server.go | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/client/internal/dns/server.go b/client/internal/dns/server.go index 7a35e56d8..dcd4cb9d0 100644 --- a/client/internal/dns/server.go +++ b/client/internal/dns/server.go @@ -777,13 +777,24 @@ func (s *DefaultServer) applyHostConfig() { // context is released rather than leaked until GC. func (s *DefaultServer) registerFallback() { originalNameservers := s.hostManager.getOriginalNameservers() - if len(originalNameservers) == 0 { + + serverIP := s.service.RuntimeIP() + var servers []netip.AddrPort + for _, ns := range originalNameservers { + if ns == serverIP { + log.Debugf("skipping original nameserver %s as it is the same as the server IP %s", ns, serverIP) + continue + } + servers = append(servers, netip.AddrPortFrom(ns, DefaultPort)) + } + + if len(servers) == 0 { log.Debugf("no fallback upstreams to register; clearing PriorityFallback handler") s.clearFallback() return } - log.Infof("registering original nameservers %v as upstream handlers with priority %d", originalNameservers, PriorityFallback) + log.Infof("registering original nameservers %v as upstream handlers with priority %d", servers, PriorityFallback) handler, err := newUpstreamResolver( s.ctx, @@ -797,11 +808,6 @@ func (s *DefaultServer) registerFallback() { return } handler.selectedRoutes = s.selectedRoutes - - var servers []netip.AddrPort - for _, ns := range originalNameservers { - servers = append(servers, netip.AddrPortFrom(ns, DefaultPort)) - } handler.addRace(servers) prev := s.fallbackHandler From ed7a9363aa29ef4ffe053cdf3f9dc60da200bd7f Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Tue, 9 Jun 2026 20:26:43 +0900 Subject: [PATCH 06/19] [management] Emit IPv6 default permit firewall rule for exit node routes (#6368) --- .../server/types/networkmap_components.go | 8 +++- .../networkmap_components_correctness_test.go | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/management/server/types/networkmap_components.go b/management/server/types/networkmap_components.go index 3a7e20ec5..b5514e19b 100644 --- a/management/server/types/networkmap_components.go +++ b/management/server/types/networkmap_components.go @@ -557,7 +557,6 @@ func (c *NetworkMapComponents) getRoutingPeerRoutes(peerID string) (enabledRoute return enabledRoutes, disabledRoutes } - func (c *NetworkMapComponents) filterRoutesByGroups(routes []*route.Route, groupListMap LookupMap) []*route.Route { var filteredRoutes []*route.Route for _, r := range routes { @@ -628,9 +627,14 @@ func (c *NetworkMapComponents) getDefaultPermit(r *route.Route, includeIPv6 bool rules := []*RouteFirewallRule{&rule} - if includeIPv6 && r.IsDynamic() { + isDefaultV4 := r.Network.Addr().Is4() && r.Network.Bits() == 0 + if includeIPv6 && (r.IsDynamic() || isDefaultV4) { ruleV6 := rule ruleV6.SourceRanges = []string{"::/0"} + if isDefaultV4 { + ruleV6.Destination = "::/0" + ruleV6.RouteID = r.ID + "-v6-default" + } rules = append(rules, &ruleV6) } diff --git a/management/server/types/networkmap_components_correctness_test.go b/management/server/types/networkmap_components_correctness_test.go index bcfb6fdf9..3785a7399 100644 --- a/management/server/types/networkmap_components_correctness_test.go +++ b/management/server/types/networkmap_components_correctness_test.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/netip" + "slices" "testing" "time" @@ -1029,6 +1030,48 @@ func TestComponents_RouteDefaultPermit(t *testing.T) { assert.True(t, hasDefaultPermit, "route without ACG should have default permit rule with 0.0.0.0/0 source") } +// TestComponents_ExitNodeDefaultPermitIPv6 verifies that a default exit node route +// (0.0.0.0/0) without AccessControlGroups also emits an IPv6 default permit rule +// (::/0 source and destination) for peers that support IPv6, mirroring the route +// the client installs. Without it, IPv6 traffic is routed to the exit node but +// dropped at the forward chain. +func TestComponents_ExitNodeDefaultPermitIPv6(t *testing.T) { + account, validatedPeers := scalableTestAccount(20, 2) + + routingPeerID := "peer-5" + routingPeer := account.Peers[routingPeerID] + routingPeer.IPv6 = netip.MustParseAddr("fd00::5") + routingPeer.Meta.Capabilities = append(routingPeer.Meta.Capabilities, nbpeer.PeerCapabilityIPv6Overlay) + + account.Routes["route-exit"] = &route.Route{ + ID: "route-exit", Network: netip.MustParsePrefix("0.0.0.0/0"), + PeerID: routingPeerID, Peer: routingPeer.Key, + Enabled: true, Groups: []string{"group-all"}, PeerGroups: []string{"group-0"}, + AccessControlGroups: []string{}, + AccountID: "test-account", + } + + nm := componentsNetworkMap(account, routingPeerID, validatedPeers) + require.NotNil(t, nm) + + hasV4 := false + hasV6 := false + for _, rfr := range nm.RoutesFirewallRules { + switch rfr.Destination { + case "0.0.0.0/0": + if slices.Contains(rfr.SourceRanges, "0.0.0.0/0") { + hasV4 = true + } + case "::/0": + if slices.Contains(rfr.SourceRanges, "::/0") { + hasV6 = true + } + } + } + assert.True(t, hasV4, "exit node route should have an IPv4 default permit rule (0.0.0.0/0)") + assert.True(t, hasV6, "exit node route should have an IPv6 default permit rule (::/0)") +} + // ────────────────────────────────────────────────────────────────────────────── // 15. MULTIPLE ROUTERS PER NETWORK // ────────────────────────────────────────────────────────────────────────────── From 13200265d8c1aabbe72f19fc30a3c82d2324958b Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 9 Jun 2026 13:57:17 +0200 Subject: [PATCH 07/19] [proxy] Add no-blocking mapping updates (#6369) --- management/internals/shared/grpc/proxy.go | 2 + proxy/cmd/proxy/cmd/root.go | 1 + proxy/internal/roundtrip/netbird.go | 164 +++++++++---- proxy/internal/roundtrip/netbird_test.go | 131 +++++++++- proxy/lifecycle.go | 5 + proxy/mapping_stall_test.go | 282 ++++++++++++++++++++++ proxy/server.go | 81 ++++++- 7 files changed, 607 insertions(+), 59 deletions(-) create mode 100644 proxy/mapping_stall_test.go diff --git a/management/internals/shared/grpc/proxy.go b/management/internals/shared/grpc/proxy.go index 72735b210..0feb807f6 100644 --- a/management/internals/shared/grpc/proxy.go +++ b/management/internals/shared/grpc/proxy.go @@ -666,8 +666,10 @@ func (s *ProxyServiceServer) sender(conn *proxyConnection, errChan chan<- error) case resp := <-conn.sendChan: if err := conn.sendResponse(resp); err != nil { errChan <- err + log.WithContext(conn.ctx).Tracef("Failed to send response to proxy %s: %v", conn.proxyID, err) return } + log.WithContext(conn.ctx).Tracef("Send response to proxy %s", conn.proxyID) case <-conn.ctx.Done(): return } diff --git a/proxy/cmd/proxy/cmd/root.go b/proxy/cmd/proxy/cmd/root.go index d0e11517e..ad8e1b7c0 100644 --- a/proxy/cmd/proxy/cmd/root.go +++ b/proxy/cmd/proxy/cmd/root.go @@ -249,6 +249,7 @@ func runServer(cmd *cobra.Command, args []string) error { Private: private, MaxDialTimeout: maxDialTimeout, MaxSessionIdleTimeout: maxSessionIdleTimeout, + MappingBatchWatchdog: envDurationOrDefault("NB_PROXY_MAPPING_BATCH_WATCHDOG", 0), GeoDataDir: geoDataDir, CrowdSecAPIURL: crowdsecAPIURL, CrowdSecAPIKey: crowdsecAPIKey, diff --git a/proxy/internal/roundtrip/netbird.go b/proxy/internal/roundtrip/netbird.go index 1d1e68f4a..13d386da2 100644 --- a/proxy/internal/roundtrip/netbird.go +++ b/proxy/internal/roundtrip/netbird.go @@ -28,6 +28,10 @@ import ( const deviceNamePrefix = "ingress-proxy-" +const clientStopTimeout = 30 * time.Second + +const createProxyPeerTimeout = 30 * time.Second + // backendKey identifies a backend by its host:port from the target URL. type backendKey string @@ -162,6 +166,7 @@ type NetBird struct { clientsMux sync.RWMutex clients map[types.AccountID]*clientEntry + lifecycleMu sync.Map initLogOnce sync.Once statusNotifier statusNotifier // readyHandler runs after the embedded client for an account reports @@ -177,6 +182,10 @@ type NetBird struct { // (i.e. when a new client was actually created, not when an existing one // was reused). The duration covers keygen + gRPC CreateProxyPeer + embed.New. OnAddPeer func(d time.Duration, err error) + + // startClient runs the post-create client startup. Nil uses runClientStartup; + // tests override it to avoid a real embed client.Start. + startClient func(accountID types.AccountID, client *embed.Client) } // ClientDebugInfo contains debug information about a client. @@ -200,31 +209,20 @@ type skipTLSVerifyContextKey struct{} func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, serviceID types.ServiceID) error { si := serviceInfo{serviceID: serviceID} - n.clientsMux.Lock() + if n.registerExistingClient(accountID, key, si) { + return nil + } - entry, exists := n.clients[accountID] - if exists { - entry.services[key] = si - started := entry.started - n.clientsMux.Unlock() - - n.logger.WithFields(log.Fields{ - "account_id": accountID, - "service_key": key, - }).Debug("registered service with existing client") - - if started && n.statusNotifier != nil { - // Use a background context, not the caller's: the management - // connection notification must land even if the request / - // stream that triggered this registration is cancelled. - // Mirrors the async runClientStartup path. - if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, serviceID, true); err != nil { - n.logger.WithFields(log.Fields{ - "account_id": accountID, - "service_key": key, - }).WithError(err).Warn("failed to notify status for existing client") - } + lifecycle := n.accountLifecycle(accountID) + lifecycle.Lock() + transferred := false + defer func() { + if !transferred { + lifecycle.Unlock() } + }() + + if n.registerExistingClient(accountID, key, si) { return nil } @@ -234,10 +232,10 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se n.OnAddPeer(time.Since(createStart), err) } if err != nil { - n.clientsMux.Unlock() return err } + n.clientsMux.Lock() n.clients[accountID] = entry n.clientsMux.Unlock() @@ -246,17 +244,64 @@ func (n *NetBird) AddPeer(ctx context.Context, accountID types.AccountID, key Se "service_key": key, }).Info("created new client for account") - // Attempt to start the client in the background; if this fails we will - // retry on the first request via RoundTrip. runClientStartup uses its - // own background context so the caller's request-scoped ctx can't - // cancel the inbound bring-up. - go n.runClientStartup(accountID, entry.client) + transferred = true + go func() { + defer lifecycle.Unlock() + n.startClientStartup(accountID, entry.client) + }() return nil } +func (n *NetBird) startClientStartup(accountID types.AccountID, client *embed.Client) { + if n.startClient != nil { + n.startClient(accountID, client) + return + } + n.runClientStartup(accountID, client) +} + +// registerExistingClient registers the service against an already-present +// client for the account and returns true when it did. It notifies management +// of the new service when the client is already started. +func (n *NetBird) registerExistingClient(accountID types.AccountID, key ServiceKey, si serviceInfo) bool { + n.clientsMux.Lock() + entry, exists := n.clients[accountID] + if !exists { + n.clientsMux.Unlock() + return false + } + entry.services[key] = si + started := entry.started + n.clientsMux.Unlock() + + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).Debug("registered service with existing client") + + if started && n.statusNotifier != nil { + if err := n.statusNotifier.NotifyStatus(context.Background(), accountID, si.serviceID, true); err != nil { + n.logger.WithFields(log.Fields{ + "account_id": accountID, + "service_key": key, + }).WithError(err).Warn("failed to notify status for existing client") + } + } + return true +} + +// accountLifecycle returns the per-account lifecycle mutex, serialising client +// creation against teardown so a slow client.Stop cannot race a new +// client.Start for the same account, without blocking clientsMux. +func (n *NetBird) accountLifecycle(accountID types.AccountID) *sync.Mutex { + mu, _ := n.lifecycleMu.LoadOrStore(accountID, &sync.Mutex{}) + return mu.(*sync.Mutex) +} + // createClientEntry generates a WireGuard keypair, authenticates with management, -// and creates an embedded NetBird client. Must be called with clientsMux held. +// and creates an embedded NetBird client. Must be called with the account's +// lifecycle mutex held. func (n *NetBird) createClientEntry(ctx context.Context, accountID types.AccountID, key ServiceKey, authToken string, si serviceInfo) (*clientEntry, error) { serviceID := si.serviceID n.logger.WithFields(log.Fields{ @@ -276,7 +321,9 @@ func (n *NetBird) createClientEntry(ctx context.Context, accountID types.Account "public_key": publicKey.String(), }).Debug("authenticating new proxy peer with management") - resp, err := n.mgmtClient.CreateProxyPeer(ctx, &proto.CreateProxyPeerRequest{ + createCtx, cancel := context.WithTimeout(ctx, createProxyPeerTimeout) + defer cancel() + resp, err := n.mgmtClient.CreateProxyPeer(createCtx, &proto.CreateProxyPeerRequest{ ServiceId: string(serviceID), AccountId: string(accountID), Token: authToken, @@ -444,6 +491,15 @@ func (n *NetBird) notifyClientReady(accountID types.AccountID, client *embed.Cli // RemovePeer unregisters a service from an account. The client is only stopped // when no services are using it anymore. func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key ServiceKey) error { + lifecycle := n.accountLifecycle(accountID) + lifecycle.Lock() + transferred := false + defer func() { + if !transferred { + lifecycle.Unlock() + } + }() + n.clientsMux.Lock() entry, exists := n.clients[accountID] @@ -466,17 +522,8 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key delete(entry.services, key) stopClient := len(entry.services) == 0 - var client *embed.Client - var transport, insecureTransport *http.Transport - var inbound any - var stopHandler func(types.AccountID, any) if stopClient { n.logger.WithField("account_id", accountID).Info("stopping client, no more services") - client = entry.client - transport = entry.transport - insecureTransport = entry.insecureTransport - inbound = entry.inbound - stopHandler = n.stopHandler delete(n.clients, accountID) } else { n.logger.WithFields(log.Fields{ @@ -490,19 +537,40 @@ func (n *NetBird) RemovePeer(ctx context.Context, accountID types.AccountID, key n.notifyDisconnect(ctx, accountID, key, si.serviceID) if stopClient { - if inbound != nil && stopHandler != nil { - stopHandler(accountID, inbound) - } - transport.CloseIdleConnections() - insecureTransport.CloseIdleConnections() - if err := client.Stop(ctx); err != nil { - n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client") - } + transferred = true + go n.stopClientLocked(accountID, lifecycle, entry) } return nil } +// stopClientLocked releases a client's resources off the caller's goroutine so a +// slow client.Stop cannot wedge the mapping receive loop (which calls RemovePeer +// synchronously). It unlocks lifecycle when done so a new client.Start for the +// same account waits for this teardown. +func (n *NetBird) stopClientLocked(accountID types.AccountID, lifecycle *sync.Mutex, entry *clientEntry) { + defer lifecycle.Unlock() + + if entry.inbound != nil && n.stopHandler != nil { + n.stopHandler(accountID, entry.inbound) + } + if entry.transport != nil { + entry.transport.CloseIdleConnections() + } + if entry.insecureTransport != nil { + entry.insecureTransport.CloseIdleConnections() + } + if entry.client == nil { + return + } + + ctx, cancel := context.WithTimeout(context.Background(), clientStopTimeout) + defer cancel() + if err := entry.client.Stop(ctx); err != nil { + n.logger.WithField("account_id", accountID).WithError(err).Warn("failed to stop netbird client") + } +} + func (n *NetBird) notifyDisconnect(ctx context.Context, accountID types.AccountID, key ServiceKey, serviceID types.ServiceID) { if n.statusNotifier == nil { return diff --git a/proxy/internal/roundtrip/netbird_test.go b/proxy/internal/roundtrip/netbird_test.go index b1c36b465..700cca83e 100644 --- a/proxy/internal/roundtrip/netbird_test.go +++ b/proxy/internal/roundtrip/netbird_test.go @@ -6,6 +6,7 @@ import ( "net/netip" "sync" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -22,6 +23,18 @@ func (m *mockMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxy return &proto.CreateProxyPeerResponse{Success: true}, nil } +// signalMgmtClient closes entered the first time CreateProxyPeer is called, so +// tests can detect AddPeer reaching client creation. +type signalMgmtClient struct { + entered chan struct{} + once sync.Once +} + +func (m *signalMgmtClient) CreateProxyPeer(_ context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) { + m.once.Do(func() { close(m.entered) }) + return &proto.CreateProxyPeerResponse{Success: true}, nil +} + type mockStatusNotifier struct { mu sync.Mutex statuses []statusCall @@ -52,11 +65,15 @@ func (m *mockStatusNotifier) calls() []statusCall { // mockNetBird creates a NetBird instance for testing without actually connecting. // It uses an invalid management URL to prevent real connections. func mockNetBird() *NetBird { - return NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ + nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ MgmtAddr: "http://invalid.test:9999", WGPort: 0, PreSharedKey: "", }, nil, nil, &mockMgmtClient{}) + // Skip the real embed client.Start, which would hang against the unreachable + // mgmt URL and (now that the lifecycle lock spans startup) serialise removes. + nb.startClient = func(types.AccountID, *embed.Client) {} + return nb } func TestNetBird_AddPeer_CreatesClientForNewAccount(t *testing.T) { @@ -288,6 +305,7 @@ func TestNetBird_AddPeer_ExistingStartedClient_NotifiesStatus(t *testing.T) { WGPort: 0, PreSharedKey: "", }, nil, notifier, &mockMgmtClient{}) + nb.startClient = func(types.AccountID, *embed.Client) {} accountID := types.AccountID("account-1") // Add first service — creates a new client entry. @@ -372,6 +390,117 @@ func TestNetBird_RemovePeer_NotifiesDisconnection(t *testing.T) { assert.False(t, calls[0].connected) } +// TestNetBird_RemovePeer_TeardownIsAsync proves the fix for the receive-loop +// stall: RemovePeer must return promptly even when the client teardown blocks, +// because teardown runs off the caller's goroutine. The receive loop calls +// RemovePeer synchronously, so a blocking teardown inline would wedge it. +func TestNetBird_RemovePeer_TeardownIsAsync(t *testing.T) { + nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + }, nil, &mockStatusNotifier{}, &mockMgmtClient{}) + + accountID := types.AccountID("acct-async-teardown") + key := DomainServiceKey("svc.example") + + teardownEntered := make(chan struct{}) + releaseTeardown := make(chan struct{}) + nb.SetClientLifecycle(nil, func(types.AccountID, any) { + close(teardownEntered) + <-releaseTeardown + }) + + nb.clientsMux.Lock() + nb.clients[accountID] = &clientEntry{ + services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}}, + started: true, + inbound: struct{}{}, + } + nb.clientsMux.Unlock() + + done := make(chan error, 1) + go func() { done <- nb.RemovePeer(context.Background(), accountID, key) }() + + select { + case err := <-done: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("RemovePeer did not return while teardown was blocked — teardown is not async") + } + + select { + case <-teardownEntered: + case <-time.After(2 * time.Second): + t.Fatal("teardown never ran") + } + + close(releaseTeardown) +} + +// TestNetBird_AddPeer_WaitsForTeardown proves the lifecycle lock serialises a +// new client bringup behind an in-flight teardown for the same account, so a +// slow client.Stop can never race a new client.Start for that account. +// +// It targets the handoff race specifically: AddPeer is launched immediately +// after RemovePeer returns, WITHOUT waiting for the teardown goroutine to start. +// This only passes if RemovePeer acquires the lifecycle lock synchronously +// (before returning) and hands it to the teardown goroutine — if the goroutine +// acquired the lock itself, AddPeer could win the lock in this window and start +// a replacement client while the old teardown is still pending. +func TestNetBird_AddPeer_WaitsForTeardown(t *testing.T) { + nb := NewNetBird(context.Background(), "test-proxy", "invalid.test", ClientConfig{ + MgmtAddr: "http://invalid.test:9999", + }, nil, &mockStatusNotifier{}, &mockMgmtClient{}) + nb.startClient = func(types.AccountID, *embed.Client) {} + + accountID := types.AccountID("acct-serialize") + key := DomainServiceKey("svc.example") + + addEntered := make(chan struct{}) + releaseTeardown := make(chan struct{}) + nb.SetClientLifecycle(nil, func(types.AccountID, any) { + // Block teardown until released. If AddPeer ever reaches createClientEntry + // (signalled via the mgmt client below) while we hold the lock, the lock + // failed to serialise and the test fails before we release. + <-releaseTeardown + }) + + nb.clientsMux.Lock() + nb.clients[accountID] = &clientEntry{ + services: map[ServiceKey]serviceInfo{key: {serviceID: types.ServiceID("svc-1")}}, + started: true, + inbound: struct{}{}, + } + nb.clientsMux.Unlock() + + // createClientEntry calls CreateProxyPeer; closing addEntered there tells us + // AddPeer got past the lifecycle lock and into client creation. + nb.mgmtClient = &signalMgmtClient{entered: addEntered} + + require.NoError(t, nb.RemovePeer(context.Background(), accountID, key)) + + // Launch AddPeer with NO synchronisation against the teardown goroutine. + addReturned := make(chan struct{}) + go func() { + _ = nb.AddPeer(context.Background(), accountID, DomainServiceKey("svc2.example"), "key-2", types.ServiceID("svc-2")) + close(addReturned) + }() + + select { + case <-addEntered: + t.Fatal("AddPeer entered client creation while teardown held the lifecycle lock — handoff race not closed") + case <-addReturned: + t.Fatal("AddPeer completed while teardown held the lifecycle lock — not serialised") + case <-time.After(300 * time.Millisecond): + } + + close(releaseTeardown) + select { + case <-addReturned: + case <-time.After(2 * time.Second): + t.Fatal("AddPeer never completed after teardown released the lifecycle lock") + } +} + // TestNotifyClientReady_UsesBackgroundCtx pins the contract that the // post-Start hooks (readyHandler + statusNotifier.NotifyStatus) run on // a fresh context.Background() rather than inheriting the AddPeer diff --git a/proxy/lifecycle.go b/proxy/lifecycle.go index 41d4bc496..0d4aded9c 100644 --- a/proxy/lifecycle.go +++ b/proxy/lifecycle.go @@ -114,6 +114,10 @@ type Config struct { MaxDialTimeout time.Duration // MaxSessionIdleTimeout caps the per-service session idle timeout. MaxSessionIdleTimeout time.Duration + // MappingBatchWatchdog bounds how long a single mapping batch may spend + // being applied before the receive loop reconnects to resync. Zero falls + // back to the internal default. + MappingBatchWatchdog time.Duration // GeoDataDir is the directory containing GeoLite2 MMDB files. GeoDataDir string @@ -164,6 +168,7 @@ func New(ctx context.Context, cfg Config) *Server { Private: cfg.Private, MaxDialTimeout: cfg.MaxDialTimeout, MaxSessionIdleTimeout: cfg.MaxSessionIdleTimeout, + MappingBatchWatchdog: cfg.MappingBatchWatchdog, GeoDataDir: cfg.GeoDataDir, CrowdSecAPIURL: cfg.CrowdSecAPIURL, CrowdSecAPIKey: cfg.CrowdSecAPIKey, diff --git a/proxy/mapping_stall_test.go b/proxy/mapping_stall_test.go new file mode 100644 index 000000000..acf313d19 --- /dev/null +++ b/proxy/mapping_stall_test.go @@ -0,0 +1,282 @@ +package proxy + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + + "github.com/netbirdio/netbird/proxy/internal/roundtrip" + "github.com/netbirdio/netbird/proxy/internal/types" + "github.com/netbirdio/netbird/shared/management/proto" +) + +// blockingMgmtClient implements roundtrip's managementClient interface. +// CreateProxyPeer parks until release is closed, signalling entry on entered. +// This reproduces the confirmed real-world stall: createClientEntry calls +// CreateProxyPeer synchronously while holding clientsMux, and the proxy's +// receive loop calls that path synchronously inside processMappings. +type blockingMgmtClient struct { + entered chan struct{} + once sync.Once +} + +func (b *blockingMgmtClient) CreateProxyPeer(ctx context.Context, _ *proto.CreateProxyPeerRequest, _ ...grpc.CallOption) (*proto.CreateProxyPeerResponse, error) { + b.once.Do(func() { close(b.entered) }) + // Park until the caller's context is cancelled. In production this ctx is + // the gRPC mapping-stream context with no per-call timeout, so a slow or + // unresponsive CreateProxyPeer parks the receive loop here indefinitely. + <-ctx.Done() + return nil, ctx.Err() +} + +// gatedMappingStream is a mock GetMappingUpdate client stream that hands out a +// pre-seeded list of messages, then records how many times Recv advanced. It +// lets the test observe whether the single-threaded receive loop ever gets +// past the first (blocking) batch to pull the second message. +type gatedMappingStream struct { + grpc.ClientStream + messages []*proto.GetMappingUpdateResponse + idx int32 +} + +func (g *gatedMappingStream) Recv() (*proto.GetMappingUpdateResponse, error) { + i := int(atomic.LoadInt32(&g.idx)) + if i >= len(g.messages) { + // Block instead of returning EOF so the loop doesn't exit; we only + // care whether the loop ever reaches this second Recv at all. + select {} + } + msg := g.messages[i] + atomic.AddInt32(&g.idx, 1) + return msg, nil +} + +func (g *gatedMappingStream) deliveredCount() int32 { return atomic.LoadInt32(&g.idx) } + +func (g *gatedMappingStream) Header() (metadata.MD, error) { return nil, nil } //nolint:nilnil +func (g *gatedMappingStream) Trailer() metadata.MD { return nil } +func (g *gatedMappingStream) CloseSend() error { return nil } +func (g *gatedMappingStream) Context() context.Context { return context.Background() } +func (g *gatedMappingStream) SendMsg(any) error { return nil } +func (g *gatedMappingStream) RecvMsg(any) error { return nil } + +// noopNotifier satisfies roundtrip's statusNotifier interface. +type noopNotifier struct{} + +func (noopNotifier) NotifyStatus(context.Context, types.AccountID, types.ServiceID, bool) error { + return nil +} + +// noopProxyClient is a proto.ProxyServiceClient that no-ops the one method the +// teardown unwind reaches (SendStatusUpdate, via notifyError when the parked +// AddPeer is cancelled). The embedded nil interface satisfies the rest at +// compile time; none of those methods are called by this test. +type noopProxyClient struct { + proto.ProxyServiceClient +} + +func (noopProxyClient) SendStatusUpdate(context.Context, *proto.SendStatusUpdateRequest, ...grpc.CallOption) (*proto.SendStatusUpdateResponse, error) { + return &proto.SendStatusUpdateResponse{}, nil +} + +// TestMappingStream_StallsWhenApplyBlocks proves the deadlock: the proxy's +// mapping receive loop processes batches strictly serially, so when applying +// one batch blocks (here: createClientEntry parked on a synchronous +// CreateProxyPeer call, exactly as observed in production), the loop never +// advances to Recv the next batch. Management can keep sending updates onto +// the stream with no error and no channel overflow, yet the proxy applies +// nothing further — it is stuck. +func TestMappingStream_StallsWhenApplyBlocks(t *testing.T) { + logger := log.New() + logger.SetLevel(log.PanicLevel) + + mgmt := &blockingMgmtClient{ + entered: make(chan struct{}), + } + + nb := roundtrip.NewNetBird( + context.Background(), + "proxy-test", + "proxy.example.com", + roundtrip.ClientConfig{}, + logger, + noopNotifier{}, + mgmt, + ) + + s := &Server{ + Logger: logger, + netbird: nb, + mgmtClient: noopProxyClient{}, + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + } + + // First batch: a CREATED mapping for a brand-new account. addMapping -> + // netbird.AddPeer -> createClientEntry -> CreateProxyPeer, which blocks. + // Empty Path keeps setupHTTPMapping a no-op (it returns early), so the + // ONLY blocking point is the synchronous CreateProxyPeer in AddPeer — + // no routers/auth need wiring. The second batch exists only to detect + // whether the loop ever advances past the blocked first batch. + stream := &gatedMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + { + Mapping: []*proto.ProxyMapping{ + { + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "svc-1", + AccountId: "acct-1", + AuthToken: "token-1", + }, + }, + }, + { + Mapping: []*proto.ProxyMapping{ + { + Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_CREATED, + Id: "svc-2", + AccountId: "acct-2", + AuthToken: "token-2", + }, + }, + }, + }, + } + + ctx, cancel := context.WithCancel(context.Background()) + // Unblock the parked apply on teardown via ctx (CreateProxyPeer returns + // ctx.Err()), so the wedged loop goroutine unwinds before embed.New — + // avoiding any dependency on collaborators this test deliberately leaves + // nil. The deadlock is fully proven before this fires. + t.Cleanup(cancel) + + loopDone := make(chan struct{}) + syncDone := false + go func() { + defer close(loopDone) + _ = s.handleMappingStream(ctx, stream, &syncDone, time.Time{}) + }() + + // The loop must reach the blocking apply for the first batch. + select { + case <-mgmt.entered: + case <-time.After(2 * time.Second): + t.Fatal("receive loop never reached CreateProxyPeer for the first batch") + } + + // THE DEADLOCK: while the first batch is parked in CreateProxyPeer, the + // single-threaded loop cannot advance. The second batch is never pulled, + // even though it is already available on the stream. Give it ample time. + // deliveredCount is atomic; syncDone is intentionally not read here because + // the loop goroutine owns it (reading it from the test would race). + time.Sleep(500 * time.Millisecond) + assert.Equal(t, int32(1), stream.deliveredCount(), + "loop must NOT consume the second batch while the first is blocked in apply — proxy is stuck") + + select { + case <-loopDone: + t.Fatal("receive loop returned while it should be wedged in apply") + default: + // Still wedged, as expected. + } +} + +// TestMappingStream_StallsWhenRemoveBlocks proves the deadlock for the REMOVE +// path observed in production: a mapping remove tears down the account's last +// embedded client via netbird.RemovePeer -> client.Stop -> Engine.Stop, whose +// jobExecutorWG.Wait() is unbounded. Because the receive loop is single- +// threaded, a blocked remove wedges the loop: no further mapping updates of any +// kind (create/modify/remove) are applied, while management keeps sending them +// successfully (no send error, no channel-full). Matches the reported symptom: +// the last log line is a remove that stops a client, then silence. +func TestMappingStream_StallsWhenRemoveBlocks(t *testing.T) { + logger := log.New() + logger.SetLevel(log.PanicLevel) + + enteredRemove := make(chan struct{}) + blockRemove := make(chan struct{}) + var once sync.Once + + s := &Server{ + Logger: logger, + mgmtClient: noopProxyClient{}, + routerReady: closedChan(), + lastMappings: make(map[types.ServiceID]*proto.ProxyMapping), + // Stand in for netbird.RemovePeer -> client.Stop hanging on + // Engine.Stop's unbounded jobExecutorWG.Wait(). Only the first remove + // blocks; later removes return immediately so the recovery assertion + // can observe the loop advancing. + removePeer: func(ctx context.Context, _ types.AccountID, _ roundtrip.ServiceKey) error { + first := false + once.Do(func() { + first = true + close(enteredRemove) + }) + if !first { + return nil + } + select { + case <-blockRemove: + case <-ctx.Done(): + } + return nil + }, + } + + // Batch 1 removes a service (blocks in teardown). Batch 2 is a later update + // that must never be applied while the remove is wedged. + stream := &gatedMappingStream{ + messages: []*proto.GetMappingUpdateResponse{ + { + Mapping: []*proto.ProxyMapping{ + {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-1", AccountId: "acct-1"}, + }, + }, + { + Mapping: []*proto.ProxyMapping{ + {Type: proto.ProxyMappingUpdateType_UPDATE_TYPE_REMOVED, Id: "svc-2", AccountId: "acct-1"}, + }, + }, + }, + } + + loopDone := make(chan struct{}) + syncDone := false + go func() { + defer close(loopDone) + _ = s.handleMappingStream(context.Background(), stream, &syncDone, time.Time{}) + }() + + select { + case <-enteredRemove: + case <-time.After(2 * time.Second): + t.Fatal("receive loop never reached the blocking remove for the first batch") + } + + // THE DEADLOCK: the loop is parked in the blocked remove and cannot advance. + // syncDone is owned by the loop goroutine, so it is not read here. + time.Sleep(500 * time.Millisecond) + assert.Equal(t, int32(1), stream.deliveredCount(), + "loop must NOT consume the second batch while the first remove is blocked — proxy is stuck") + + select { + case <-loopDone: + t.Fatal("receive loop returned while it should be wedged on the remove") + default: + } + + // Unblock and confirm the wedge was solely the blocked remove: the loop + // then advances and consumes the next batch. + close(blockRemove) + assert.Eventually(t, func() bool { + return stream.deliveredCount() >= 2 + }, 2*time.Second, 5*time.Millisecond, + "once the remove unblocks, the loop must advance and consume the next batch") +} diff --git a/proxy/server.go b/proxy/server.go index 1f5e0abd6..6d5acfe46 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -118,6 +118,9 @@ type Server struct { // The mapping worker waits on this before processing updates. routerReady chan struct{} + // removePeer defaults to netbird.RemovePeer; overridable in tests. + removePeer func(ctx context.Context, accountID types.AccountID, key roundtrip.ServiceKey) error + // inbound, when non-nil, manages per-account inbound listeners. Set by // initPrivateInbound only when Private is true so the standalone // proxy keeps its zero-overhead default path. @@ -227,6 +230,10 @@ type Server struct { // Zero means no cap (the proxy honors whatever management sends). // Set via NB_PROXY_MAX_SESSION_IDLE_TIMEOUT for shared deployments. MaxSessionIdleTimeout time.Duration + // MappingBatchWatchdog bounds how long a single mapping batch may spend + // in processMappings before the receive loop reconnects to resync. + // Zero uses defaultMappingBatchWatchdog. + MappingBatchWatchdog time.Duration } // clampIdleTimeout returns d capped to MaxSessionIdleTimeout when configured. @@ -1172,24 +1179,30 @@ func (s *Server) newManagementMappingWorker(ctx context.Context, client proto.Pr s.healthChecker.SetManagementConnected(false) } + connected := false + onConnected := func() { connected = true } + var streamErr error if syncSupported { - streamErr = s.trySyncMappings(ctx, client, &initialSyncDone) + streamErr = s.trySyncMappings(ctx, client, &initialSyncDone, onConnected) if isSyncUnimplemented(streamErr) { syncSupported = false s.Logger.Info("management does not support SyncMappings, falling back to GetMappingUpdate") - streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone) + streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected) } } else { - streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone) + streamErr = s.tryGetMappingUpdate(ctx, client, &initialSyncDone, onConnected) } if s.healthChecker != nil { s.healthChecker.SetManagementConnected(false) } - // Stream established — reset backoff so the next failure retries quickly. - bo.Reset() + // Reset backoff only when a stream actually connected, so immediate + // connect failures still back off instead of spinning. + if connected { + bo.Reset() + } if streamErr == nil { return fmt.Errorf("stream closed by server") @@ -1221,7 +1234,7 @@ func (s *Server) proxyCapabilities() *proto.ProxyCapabilities { } } -func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool) error { +func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error { connectTime := time.Now() mappingClient, err := client.GetMappingUpdate(ctx, &proto.GetMappingUpdateRequest{ ProxyId: s.ID, @@ -1234,6 +1247,7 @@ func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServ return fmt.Errorf("create mapping stream: %w", err) } + onConnected() if s.healthChecker != nil { s.healthChecker.SetManagementConnected(true) } @@ -1242,7 +1256,7 @@ func (s *Server) tryGetMappingUpdate(ctx context.Context, client proto.ProxyServ return s.handleMappingStream(ctx, mappingClient, initialSyncDone, connectTime) } -func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool) error { +func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceClient, initialSyncDone *bool, onConnected func()) error { connectTime := time.Now() stream, err := client.SyncMappings(ctx) if err != nil { @@ -1263,6 +1277,7 @@ func (s *Server) trySyncMappings(ctx context.Context, client proto.ProxyServiceC return fmt.Errorf("send sync init: %w", err) } + onConnected() if s.healthChecker != nil { s.healthChecker.SetManagementConnected(true) } @@ -1307,7 +1322,9 @@ func (s *Server) handleSyncMappingsStream(ctx context.Context, stream proto.Prox batchStart := time.Now() s.Logger.Debug("Received mapping update, starting processing") - s.processMappings(ctx, msg.GetMapping()) + if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil { + return err + } s.Logger.Debug("Processing mapping update completed") tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart) @@ -1391,7 +1408,9 @@ func (s *Server) handleMappingStream(ctx context.Context, mappingClient proto.Pr batchStart := time.Now() s.Logger.Debug("Received mapping update, starting processing") - s.processMappings(ctx, msg.GetMapping()) + if err := s.processMappingsGuarded(ctx, msg.GetMapping()); err != nil { + return err + } s.Logger.Debug("Processing mapping update completed") tracker.recordBatch(ctx, s, msg.GetMapping(), msg.GetInitialSyncComplete(), batchStart) } @@ -1456,6 +1475,44 @@ func redactMappingForLog(m *proto.ProxyMapping) *proto.ProxyMapping { return c } +const defaultMappingBatchWatchdog = 2 * time.Minute + +// mappingBatchWatchdog returns the configured batch watchdog or the default. +func (s *Server) mappingBatchWatchdog() time.Duration { + if s.MappingBatchWatchdog > 0 { + return s.MappingBatchWatchdog + } + return defaultMappingBatchWatchdog +} + +// processMappingsGuarded applies a batch under a watchdog, returning an error +// if processing exceeds the watchdog so the caller reconnects and resyncs +// instead of wedging silently. +func (s *Server) processMappingsGuarded(ctx context.Context, mappings []*proto.ProxyMapping) error { + batchCtx, cancel := context.WithCancel(ctx) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + s.processMappings(batchCtx, mappings) + }() + + watchdog := s.mappingBatchWatchdog() + timer := time.NewTimer(watchdog) + defer timer.Stop() + + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + s.Logger.Errorf("processing mapping batch exceeded %s, cancelling and reconnecting to resync", watchdog) + return fmt.Errorf("mapping batch processing stalled after %s", watchdog) + } +} + func (s *Server) processMappings(ctx context.Context, mappings []*proto.ProxyMapping) { debug := s.Logger != nil && s.Logger.IsLevelEnabled(log.DebugLevel) for _, mapping := range mappings { @@ -1951,7 +2008,11 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) func (s *Server) removeMapping(ctx context.Context, mapping *proto.ProxyMapping) { accountID := types.AccountID(mapping.GetAccountId()) svcKey := s.serviceKeyForMapping(mapping) - if err := s.netbird.RemovePeer(ctx, accountID, svcKey); err != nil { + removePeer := s.removePeer + if removePeer == nil { + removePeer = s.netbird.RemovePeer + } + if err := removePeer(ctx, accountID, svcKey); err != nil { s.Logger.WithFields(log.Fields{ "account_id": accountID, "service_id": mapping.GetId(), From a40028092debb75a7a42e89c126d49882045fd65 Mon Sep 17 00:00:00 2001 From: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:24:26 +0200 Subject: [PATCH 08/19] [management] log user agent and return request id (#6380) --- formatter/hook/hook.go | 3 ++ management/server/context/keys.go | 1 + .../server/telemetry/http_api_metrics.go | 6 ++++ shared/context/keys.go | 1 + shared/management/http/api/openapi.yml | 32 +++++++++++++++++++ 5 files changed, 43 insertions(+) diff --git a/formatter/hook/hook.go b/formatter/hook/hook.go index f0ee509f8..69758566d 100644 --- a/formatter/hook/hook.go +++ b/formatter/hook/hook.go @@ -99,6 +99,9 @@ func addFields(entry *logrus.Entry) { if ctxAccountID, ok := entry.Context.Value(context.AccountIDKey).(string); ok { entry.Data[context.AccountIDKey] = ctxAccountID } + if ctxUserAgent, ok := entry.Context.Value(context.UserAgentKey).(string); ok { + entry.Data[context.UserAgentKey] = ctxUserAgent + } if ctxInitiatorID, ok := entry.Context.Value(context.UserIDKey).(string); ok { entry.Data[context.UserIDKey] = ctxInitiatorID } diff --git a/management/server/context/keys.go b/management/server/context/keys.go index 7a65afbbd..aa534c5d9 100644 --- a/management/server/context/keys.go +++ b/management/server/context/keys.go @@ -12,6 +12,7 @@ const ( RoleKey = nbcontext.RoleKey UserIDKey = nbcontext.UserIDKey PeerIDKey = nbcontext.PeerIDKey + UserAgentKey = nbcontext.UserAgentKey ) // RoleFromContext returns the role stored in ctx, or empty string and false if absent. diff --git a/management/server/telemetry/http_api_metrics.go b/management/server/telemetry/http_api_metrics.go index e48e6d64a..360d36949 100644 --- a/management/server/telemetry/http_api_metrics.go +++ b/management/server/telemetry/http_api_metrics.go @@ -21,6 +21,8 @@ const ( httpRequestCounterPrefix = "management.http.request.counter" httpResponseCounterPrefix = "management.http.response.counter" httpRequestDurationPrefix = "management.http.request.duration.ms" + + RequestIDHeader = "X-Request-Id" ) // WrappedResponseWriter is a wrapper for http.ResponseWriter that allows the @@ -172,6 +174,10 @@ func (m *HTTPMiddleware) Handler(h http.Handler) http.Handler { reqID := xid.New().String() //nolint ctx = context.WithValue(ctx, nbContext.RequestIDKey, reqID) + //nolint + ctx = context.WithValue(ctx, nbContext.UserAgentKey, r.UserAgent()) + + rw.Header().Set(RequestIDHeader, reqID) log.WithContext(ctx).Tracef("HTTP request %v: %v %v", reqID, r.Method, r.URL) diff --git a/shared/context/keys.go b/shared/context/keys.go index ca56be67e..3287a6366 100644 --- a/shared/context/keys.go +++ b/shared/context/keys.go @@ -6,4 +6,5 @@ const ( RoleKey = "role" UserIDKey = "userID" PeerIDKey = "peerID" + UserAgentKey = "userAgent" ) diff --git a/shared/management/http/api/openapi.yml b/shared/management/http/api/openapi.yml index 03e30e6b7..f8c687b7b 100644 --- a/shared/management/http/api/openapi.yml +++ b/shared/management/http/api/openapi.yml @@ -5107,31 +5107,63 @@ components: responses: not_found: description: Resource not found + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' content: { } validation_failed_simple: description: Validation failed + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' content: { } bad_request: description: Bad Request + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' content: { } internal_error: description: Internal Server Error + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' content: { } validation_failed: description: Validation failed + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' content: { } forbidden: description: Forbidden + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' content: { } requires_authentication: description: Requires authentication + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' content: { } conflict: description: Conflict + headers: + X-Request-Id: + $ref: '#/components/headers/X-Request-Id' content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + headers: + X-Request-Id: + description: | + Unique identifier assigned to the request by the server and set on every + response. Useful for correlating client requests with server-side logs. + schema: + type: string + example: cot7r4n3l3vh3qj4qveg securitySchemes: BearerAuth: type: http From e919b2d55d191daffd70e7f048667efefee6b789 Mon Sep 17 00:00:00 2001 From: Zoltan Papp Date: Wed, 10 Jun 2026 11:43:24 +0200 Subject: [PATCH 09/19] [client] Preserve posture checks on config-only sync updates (#6373) * [client] Preserve posture checks on config-only sync updates When management sends a MessageTypeControlConfig update (e.g. relay token rotation), the SyncResponse carries no NetworkMap and no Checks. Moving the updateChecksIfNew call after the nm == nil guard ensures posture checks are only updated when a full network map is present, preventing relay token rotation from silently clearing the previously applied posture check state. * [client] Clarify posture check update logic with explicit comment * [client] Extract NetBird config and sync persistence into helpers Move the NetbirdConfig handling block out of handleSync into updateNetbirdConfig and the sync response persistence into persistSyncResponse, mirroring updateChecksIfNew. This flattens handleSync and makes the individual update steps unit-testable. --- client/internal/engine.go | 119 ++++++++++++++++++++++---------------- 1 file changed, 70 insertions(+), 49 deletions(-) diff --git a/client/internal/engine.go b/client/internal/engine.go index 980326720..2b41d2015 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -880,62 +880,25 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate) } - if update.GetNetbirdConfig() != nil { - wCfg := update.GetNetbirdConfig() - err := e.updateTURNs(wCfg.GetTurns()) - if err != nil { - return fmt.Errorf("update TURNs: %w", err) - } + if err := e.updateNetbirdConfig(update.GetNetbirdConfig()); err != nil { + return err + } - err = e.updateSTUNs(wCfg.GetStuns()) - if err != nil { - return fmt.Errorf("update STUNs: %w", err) - } - - var stunTurn []*stun.URI - stunTurn = append(stunTurn, e.STUNs...) - stunTurn = append(stunTurn, e.TURNs...) - e.stunTurn.Store(stunTurn) - - err = e.handleRelayUpdate(wCfg.GetRelay()) - if err != nil { - return err - } - - err = e.handleFlowUpdate(wCfg.GetFlow()) - if err != nil { - return fmt.Errorf("handle the flow configuration: %w", err) - } - - if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil { - log.Warnf("Failed to update DNS server config: %v", err) - } - - // todo update signal + // Posture checks are bound to the network map presence: + // NetworkMap != nil, checks present -> apply the received checks + // NetworkMap != nil, checks nil -> posture checks were removed, clear them + // NetworkMap == nil -> config-only update (e.g. relay token rotation), + // leave the previously applied checks untouched + nm := update.GetNetworkMap() + if nm == nil { + return nil } if err := e.updateChecksIfNew(update.Checks); err != nil { return err } - nm := update.GetNetworkMap() - if nm == nil { - return nil - } - - // Persist sync response under the dedicated lock (syncRespMux), not under syncMsgMux. - // A non-nil syncStore is what marks persistence as enabled. Hold the lock for - // the whole Set so the store cannot be cleared (disabled / engine close) - // mid-call and have this write resurrect a file that was just removed. - e.syncRespMux.RLock() - if e.syncStore != nil { - if err := e.syncStore.Set(update); err != nil { - log.Errorf("failed to persist sync response: %v", err) - } else { - log.Debugf("sync response persisted with serial %d", nm.GetSerial()) - } - } - e.syncRespMux.RUnlock() + e.persistSyncResponse(update) // only apply new changes and ignore old ones if err := e.updateNetworkMap(nm); err != nil { @@ -947,6 +910,64 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error { return nil } +// updateNetbirdConfig applies the management-provided NetBird configuration: +// STUN/TURN and relay servers, flow logging and DNS settings. A nil config is a no-op, +// which is the case for sync updates carrying only a network map. +func (e *Engine) updateNetbirdConfig(wCfg *mgmProto.NetbirdConfig) error { + if wCfg == nil { + return nil + } + + if err := e.updateTURNs(wCfg.GetTurns()); err != nil { + return fmt.Errorf("update TURNs: %w", err) + } + + if err := e.updateSTUNs(wCfg.GetStuns()); err != nil { + return fmt.Errorf("update STUNs: %w", err) + } + + var stunTurn []*stun.URI + stunTurn = append(stunTurn, e.STUNs...) + stunTurn = append(stunTurn, e.TURNs...) + e.stunTurn.Store(stunTurn) + + if err := e.handleRelayUpdate(wCfg.GetRelay()); err != nil { + return err + } + + if err := e.handleFlowUpdate(wCfg.GetFlow()); err != nil { + return fmt.Errorf("handle the flow configuration: %w", err) + } + + if err := e.PopulateNetbirdConfig(wCfg, nil); err != nil { + log.Warnf("Failed to update DNS server config: %v", err) + } + + // todo update signal + + return nil +} + +// persistSyncResponse stores the full sync response so it can be restored on the next +// startup. Persistence is enabled only when syncStore is set. The dedicated syncRespMux +// (not syncMsgMux) is held for the whole Set so the store cannot be cleared (disabled / +// engine close) mid-call and have this write resurrect a file that was just removed. +func (e *Engine) persistSyncResponse(update *mgmProto.SyncResponse) { + e.syncRespMux.RLock() + defer e.syncRespMux.RUnlock() + + if e.syncStore == nil { + return + } + + if err := e.syncStore.Set(update); err != nil { + log.Errorf("failed to persist sync response: %v", err) + return + } + + log.Debugf("sync response persisted with serial %d", update.GetNetworkMap().GetSerial()) +} + func (e *Engine) handleRelayUpdate(update *mgmProto.RelayConfig) error { if update != nil { // when we receive token we expect valid address list too From e229050ba3f353c0086172f8e0093130ca01b618 Mon Sep 17 00:00:00 2001 From: Boris Dolgov Date: Wed, 10 Jun 2026 12:05:34 +0200 Subject: [PATCH 10/19] [proxy] Notify certificate ready for domains covered by the static certificate (#6389) --- proxy/server.go | 70 ++++++++++++++++++++---------- proxy/static_cert_test.go | 89 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+), 23 deletions(-) create mode 100644 proxy/static_cert_test.go diff --git a/proxy/server.go b/proxy/server.go index 6d5acfe46..ca3f335ab 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -75,29 +75,30 @@ type portRouter struct { } type Server struct { - ctx context.Context - mgmtClient proto.ProxyServiceClient - proxy *proxy.ReverseProxy - netbird *roundtrip.NetBird - acme *acme.Manager - auth *auth.Middleware - http *http.Server - https *http.Server - debug *http.Server - healthServer *health.Server - healthChecker *health.Checker - meter *proxymetrics.Metrics - accessLog *accesslog.Logger - mainRouter *nbtcp.Router - mainPort uint16 - udpMu sync.Mutex - udpRelays map[types.ServiceID]*udprelay.Relay - udpRelayWg sync.WaitGroup - portMu sync.RWMutex - portRouters map[uint16]*portRouter - svcPorts map[types.ServiceID][]uint16 - lastMappings map[types.ServiceID]*proto.ProxyMapping - portRouterWg sync.WaitGroup + ctx context.Context + mgmtClient proto.ProxyServiceClient + proxy *proxy.ReverseProxy + netbird *roundtrip.NetBird + acme *acme.Manager + staticCertWatcher *certwatch.Watcher + auth *auth.Middleware + http *http.Server + https *http.Server + debug *http.Server + healthServer *health.Server + healthChecker *health.Checker + meter *proxymetrics.Metrics + accessLog *accesslog.Logger + mainRouter *nbtcp.Router + mainPort uint16 + udpMu sync.Mutex + udpRelays map[types.ServiceID]*udprelay.Relay + udpRelayWg sync.WaitGroup + portMu sync.RWMutex + portRouters map[uint16]*portRouter + svcPorts map[types.ServiceID][]uint16 + lastMappings map[types.ServiceID]*proto.ProxyMapping + portRouterWg sync.WaitGroup // hijackTracker tracks hijacked connections (e.g. WebSocket upgrades) // so they can be closed during graceful shutdown, since http.Server.Shutdown @@ -792,6 +793,7 @@ func (s *Server) configureTLS(ctx context.Context) (*tls.Config, error) { return nil, fmt.Errorf("initialize certificate watcher: %w", err) } go certWatcher.Watch(ctx) + s.staticCertWatcher = certWatcher tlsConfig.GetCertificate = certWatcher.GetCertificate return tlsConfig, nil } @@ -1623,6 +1625,8 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi var wildcardHit bool if s.acme != nil { wildcardHit = s.acme.AddDomain(d, accountID, svcID) + } else { + wildcardHit = s.staticCertCovers(d) } httpRoute := nbtcp.Route{ Type: nbtcp.RouteHTTP, @@ -1647,6 +1651,26 @@ func (s *Server) setupHTTPMapping(ctx context.Context, mapping *proto.ProxyMappi return nil } +// staticCertCovers reports whether the static certificate loaded when ACME is +// disabled covers the given domain, making it certificate-ready immediately — +// the equivalent of a wildcard hit in the ACME path. Domains the certificate +// does not cover are logged: clients connecting to them will get TLS errors. +func (s *Server) staticCertCovers(d domain.Domain) bool { + if s.staticCertWatcher == nil { + return false + } + leaf := s.staticCertWatcher.Leaf() + if leaf == nil { + return false + } + name := d.PunycodeString() + if err := leaf.VerifyHostname(name); err != nil { + s.Logger.Warnf("static certificate (SANs %v) does not cover domain %q: %v", leaf.DNSNames, name, err) + return false + } + return true +} + // setupTCPMapping sets up a TCP port-forwarding fallback route on the listen port. func (s *Server) setupTCPMapping(ctx context.Context, mapping *proto.ProxyMapping) error { svcID := types.ServiceID(mapping.GetId()) diff --git a/proxy/static_cert_test.go b/proxy/static_cert_test.go new file mode 100644 index 000000000..54d2b6485 --- /dev/null +++ b/proxy/static_cert_test.go @@ -0,0 +1,89 @@ +package proxy + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/proxy/internal/certwatch" + "github.com/netbirdio/netbird/shared/management/domain" +) + +func generateCertWithSANs(t *testing.T, dnsNames []string) (certPEM, keyPEM []byte) { + t.Helper() + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + require.NoError(t, err) + + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: dnsNames[0]}, + DNSNames: dnsNames, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(24 * time.Hour), + } + + certDER, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + require.NoError(t, err) + certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + + keyDER, err := x509.MarshalECPrivateKey(key) + require.NoError(t, err) + keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}) + + return certPEM, keyPEM +} + +func newStaticWatcher(t *testing.T, dnsNames []string) *certwatch.Watcher { + t.Helper() + + dir := t.TempDir() + certPEM, keyPEM := generateCertWithSANs(t, dnsNames) + certPath := filepath.Join(dir, "tls.crt") + keyPath := filepath.Join(dir, "tls.key") + require.NoError(t, os.WriteFile(certPath, certPEM, 0o600)) + require.NoError(t, os.WriteFile(keyPath, keyPEM, 0o600)) + + w, err := certwatch.NewWatcher(certPath, keyPath, quietLifecycleLogger()) + require.NoError(t, err) + return w +} + +func TestStaticCertCovers(t *testing.T) { + s := &Server{ + Logger: quietLifecycleLogger(), + staticCertWatcher: newStaticWatcher(t, []string{"*.p.example.com", "exact.example.com"}), + } + + cases := []struct { + domain string + covered bool + }{ + {"svc.p.example.com", true}, + {"exact.example.com", true}, + {"a.b.p.example.com", false}, // wildcard does not span labels + {"p.example.com", false}, + {"other.example.com", false}, + } + for _, tc := range cases { + t.Run(tc.domain, func(t *testing.T) { + assert.Equal(t, tc.covered, s.staticCertCovers(domain.Domain(tc.domain))) + }) + } +} + +func TestStaticCertCoversNoWatcher(t *testing.T) { + s := &Server{Logger: quietLifecycleLogger()} + assert.False(t, s.staticCertCovers(domain.Domain("svc.p.example.com"))) +} From 61abf5b9ea379bff206309ceb3c5da7962d59639 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 10 Jun 2026 13:35:26 +0200 Subject: [PATCH 11/19] [proxy] Use UUID for proxy ID generation (#6391) Use UUID for proxy ID instead of the second to avoid race conditions when running multiple nodes at the same time. --- proxy/server.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/proxy/server.go b/proxy/server.go index ca3f335ab..cd90682b0 100644 --- a/proxy/server.go +++ b/proxy/server.go @@ -24,6 +24,7 @@ import ( "time" "github.com/cenkalti/backoff/v4" + "github.com/google/uuid" "github.com/pires/go-proxyproto" prometheus2 "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" @@ -615,7 +616,7 @@ func (s *Server) initDefaults() { // If no ID is set then one can be generated. if s.ID == "" { - s.ID = "netbird-proxy-" + s.startTime.Format("20060102150405") + s.ID = fmt.Sprintf("netbird-proxy-%s", uuid.NewString()) } // Fallback version option in case it is not set. if s.Version == "" { From 1a09aa671566016ee1fe7d24f4cce7b2cf6da0b9 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 10 Jun 2026 14:50:57 +0200 Subject: [PATCH 12/19] [misc] Update Go toolchain version in go.mod (#6377) --- go.mod | 2 ++ 1 file changed, 2 insertions(+) diff --git a/go.mod b/go.mod index bafdeaf86..9bf9edd08 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/netbirdio/netbird go 1.25.5 +toolchain go1.25.11 + require ( cunicu.li/go-rosenpass v0.5.42 github.com/cenkalti/backoff/v4 v4.3.0 From 079bce3c2f29964d315549bd63b3fb44335fdf9b Mon Sep 17 00:00:00 2001 From: Philip Laine Date: Wed, 10 Jun 2026 15:00:10 +0200 Subject: [PATCH 13/19] Add commands to discover and write Kubernetes configuration (#6260) --- client/cmd/kubernetes.go | 301 ++++++++++++++++++++++++++++++++++ client/cmd/kubernetes_test.go | 120 ++++++++++++++ client/cmd/root.go | 5 + go.mod | 4 +- go.sum | 4 +- 5 files changed, 430 insertions(+), 4 deletions(-) create mode 100644 client/cmd/kubernetes.go create mode 100644 client/cmd/kubernetes_test.go diff --git a/client/cmd/kubernetes.go b/client/cmd/kubernetes.go new file mode 100644 index 000000000..cc91477c6 --- /dev/null +++ b/client/cmd/kubernetes.go @@ -0,0 +1,301 @@ +package cmd + +import ( + "context" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/goccy/go-yaml" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/netbirdio/netbird/client/proto" +) + +const ( + KubernetesDNSSuffix = "netbird-kubeapi-proxy" +) + +var kubernetesCmd = &cobra.Command{ + Use: "kubernetes", + Short: "Kubernetes cluster commands.", + Long: "Kubernetes cluster commands.", +} + +var kubernetesListCmd = &cobra.Command{ + Use: "list", + RunE: kubernetesList, + Short: "List Kubernetes clusters.", + Long: "List Kubernetes clusters by discovering NetBird peers running netbird-kubeapi-proxy.", +} + +var kubernetesWriteKubeconfigCmd = &cobra.Command{ + Use: "write-kubeconfig", + RunE: kubernetesWriteKubeconfig, + Args: cobra.ExactArgs(1), + Short: "Write kubeconfig for a Kubernetes cluster.", + Long: "Updates kubeconfig in place to allow token-less access to the Kubernetes cluster through NetBird.", +} + +func init() { + kubernetesWriteKubeconfigCmd.Flags().String("kubeconfig", "", "path to kubeconfig file") +} + +func kubernetesList(cmd *cobra.Command, _ []string) error { + conn, err := getClient(cmd) + if err != nil { + return err + } + defer conn.Close() + client := proto.NewDaemonServiceClient(conn) + statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + return err + } + + kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, "") + if err != nil { + return err + } + if len(kcs) == 0 { + cmd.Println("No Kubernetes clusters available.") + return nil + } + cmd.Println("Available Kubernetes clusters:") + for _, k := range kcs { + cmd.Printf("\n - Name: %s\n FQDN: %s\n Version: %s\n", k.name, k.url.Host, k.version) + } + return nil +} + +func kubernetesWriteKubeconfig(cmd *cobra.Command, args []string) error { + kubeconfigPath, err := resolveKubeconfigPath(cmd) + if err != nil { + return err + } + + conn, err := getClient(cmd) + if err != nil { + return err + } + defer conn.Close() + client := proto.NewDaemonServiceClient(conn) + statusResp, err := client.Status(cmd.Context(), &proto.StatusRequest{GetFullPeerStatus: true}) + if err != nil { + return err + } + + clusterName := args[0] + kcs, err := getKubernetesClusters(cmd.Context(), statusResp.FullStatus.Peers, clusterName) + if err != nil { + return err + } + if len(kcs) == 0 { + return fmt.Errorf("kubernetes cluster named %s not found", clusterName) + } + if len(kcs) > 1 { + return fmt.Errorf("too many Kubernetes clusters returned") + } + err = writeKubeconfig(kubeconfigPath, kcs[0]) + if err != nil { + return err + } + return nil +} + +type kubernetesCluster struct { + name string + url *url.URL + version string +} + +func getKubernetesClusters(ctx context.Context, peers []*proto.PeerState, nameFilter string) ([]kubernetesCluster, error) { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, + } + httpClient := &http.Client{ + Transport: transport, + } + resolver := net.Resolver{ + // Required so both DNS records are returned. + // https://github.com/golang/go/issues/17093 + PreferGo: true, + } + + kcs := []kubernetesCluster{} + attempted := map[string]struct{}{} + for _, peer := range peers { + fqdns, err := resolver.LookupAddr(ctx, peer.IP) + if err != nil { + return nil, err + } + for _, fqdn := range fqdns { + if _, ok := attempted[fqdn]; ok { + continue + } + attempted[fqdn] = struct{}{} + comps := strings.Split(fqdn, ".") + if len(comps) < 2 { + continue + } + if comps[1] != KubernetesDNSSuffix { + continue + } + if nameFilter != "" && nameFilter != comps[0] { + continue + } + clusterURL, clusterVersion, err := fingerprintClusters(ctx, httpClient, fqdn) + if err != nil { + log.Debugf("could not fingerprint Kubernetes cluster %s %q", fqdn, err) + continue + } + kc := kubernetesCluster{ + name: comps[0], + url: clusterURL, + version: clusterVersion, + } + if nameFilter != "" { + return []kubernetesCluster{kc}, nil + } + kcs = append(kcs, kc) + } + } + return kcs, nil +} + +func fingerprintClusters(ctx context.Context, httpClient *http.Client, fqdn string) (*url.URL, string, error) { + clusterURL, err := url.Parse("https://" + fqdn) + if err != nil { + return nil, "", err + } + versionURL, err := clusterURL.Parse("/version") + if err != nil { + return nil, "", err + } + req, err := http.NewRequestWithContext(ctx, http.MethodGet, versionURL.String(), nil) + if err != nil { + return nil, "", err + } + resp, err := httpClient.Do(req) + if err != nil { + return nil, "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, "", fmt.Errorf("expected %d response but got %s", http.StatusOK, resp.Status) + } + b, err := io.ReadAll(resp.Body) + if err != nil { + return nil, "", err + } + versionData := map[string]string{} + err = json.Unmarshal(b, &versionData) + if err != nil { + return nil, "", err + } + version, ok := versionData["gitVersion"] + if !ok { + return nil, "", errors.New("no version found in response") + } + return clusterURL, version, nil +} + +func resolveKubeconfigPath(cmd *cobra.Command) (string, error) { + if cmd.Flags().Changed("kubeconfig") { + path, err := cmd.Flags().GetString("kubeconfig") + if err != nil { + return "", err + } + return path, nil + } + if env := os.Getenv("KUBECONFIG"); env != "" { + return env, nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("could not determine home directory: %w", err) + } + return filepath.Join(home, ".kube", "config"), nil +} + +func writeKubeconfig(kubeconfigPath string, kc kubernetesCluster) error { + b, err := os.ReadFile(kubeconfigPath) + if err != nil && !errors.Is(err, os.ErrNotExist) { + return err + } + var cfg map[string]any + if err := yaml.Unmarshal(b, &cfg); err != nil { + return err + } + if cfg == nil { + cfg = map[string]any{ + "apiVersion": "v1", + "kind": "Config", + } + } + + cfg["clusters"] = appendWithName(cfg["clusters"], map[string]any{ + "name": kc.name, + "cluster": map[string]any{ + "server": kc.url.String(), + "insecure-skip-tls-verify": true, + }, + }) + cfg["users"] = appendWithName(cfg["users"], map[string]any{ + "name": "netbird", + "user": map[string]any{ + "token": "none", + }, + }) + cfg["contexts"] = appendWithName(cfg["contexts"], map[string]any{ + "name": kc.name, + "context": map[string]any{ + "cluster": kc.name, + "user": "netbird", + "namespace": "default", + }, + }) + cfg["current-context"] = kc.name + + out, err := yaml.Marshal(cfg) + if err != nil { + return err + } + if err := os.WriteFile(kubeconfigPath, out, 0o600); err != nil { + return err + } + return nil +} + +func appendWithName(data any, add map[string]any) any { + if data == nil { + return []any{add} + } + v, ok := data.([]any) + if !ok { + return []any{add} + } + i := slices.IndexFunc(v, func(item any) bool { + m, ok := item.(map[string]any) + if !ok { + return false + } + return m["name"] == add["name"] + }) + if i == -1 { + return append(v, add) + } + v[i] = add + return v +} diff --git a/client/cmd/kubernetes_test.go b/client/cmd/kubernetes_test.go new file mode 100644 index 000000000..c40d20996 --- /dev/null +++ b/client/cmd/kubernetes_test.go @@ -0,0 +1,120 @@ +package cmd + +import ( + "net/http" + "net/http/httptest" + "net/url" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestFingerprintClusters(t *testing.T) { + t.Parallel() + + srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + //nolint: errcheck + w.Write([]byte(`{"gitVersion": "foobar"}`)) + })) + defer srv.Close() + + clusterURL, clusterVersion, err := fingerprintClusters(t.Context(), srv.Client(), srv.Listener.Addr().String()) + require.NoError(t, err) + require.Equal(t, srv.URL, clusterURL.String()) + require.Equal(t, "foobar", clusterVersion) +} + +func TestResolveKubeconfigPath(t *testing.T) { + home, err := os.UserHomeDir() + if err != nil { + t.Fatalf("could not determine home directory: %v", err) + } + defaultPath := filepath.Join(home, ".kube", "config") + path, err := resolveKubeconfigPath(&cobra.Command{}) + require.NoError(t, err) + require.Equal(t, defaultPath, path) + + flagPath := "flag-path" + cmd := &cobra.Command{} + cmd.Flags().String("kubeconfig", "", "") + err = cmd.Flags().Set("kubeconfig", flagPath) + require.NoError(t, err) + path, err = resolveKubeconfigPath(cmd) + require.NoError(t, err) + require.Equal(t, flagPath, path) + + envPath := "env-path" + t.Setenv("KUBECONFIG", envPath) + path, err = resolveKubeconfigPath(&cobra.Command{}) + require.NoError(t, err) + require.Equal(t, envPath, path) +} + +func TestWriteKubeconfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + existing string + }{ + { + name: "empty file", + }, + { + name: "existing content", + existing: `apiVersion: v1 +clusters: +- cluster: + insecure-skip-tls-verify: true + server: https://foobar.com + name: foo +current-context: test +kind: Config +users: [] +`, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + kubeconfigPath := filepath.Join(t.TempDir(), "config") + err := os.WriteFile(kubeconfigPath, []byte(tt.existing), 0o644) + require.NoError(t, err) + + kc := kubernetesCluster{ + name: "foo", + url: &url.URL{Scheme: "https", Host: "example.com"}, + } + err = writeKubeconfig(kubeconfigPath, kc) + require.NoError(t, err) + + b, err := os.ReadFile(kubeconfigPath) + require.NoError(t, err) + expected := `apiVersion: v1 +clusters: +- cluster: + insecure-skip-tls-verify: true + server: https://example.com + name: foo +contexts: +- context: + cluster: foo + namespace: default + user: netbird + name: foo +current-context: foo +kind: Config +users: +- name: netbird + user: + token: none +` + require.Equal(t, expected, string(b)) + }) + } + +} diff --git a/client/cmd/root.go b/client/cmd/root.go index 0a0aa4197..5c9e1ff8a 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -169,6 +169,11 @@ func init() { debugCmd.AddCommand(forCmd) debugCmd.AddCommand(persistenceCmd) + // kubernetes commands + rootCmd.AddCommand(kubernetesCmd) + kubernetesCmd.AddCommand(kubernetesListCmd) + kubernetesCmd.AddCommand(kubernetesWriteKubeconfigCmd) + // profile commands profileCmd.AddCommand(profileListCmd) profileCmd.AddCommand(profileAddCmd) diff --git a/go.mod b/go.mod index 9bf9edd08..f42a3abe2 100644 --- a/go.mod +++ b/go.mod @@ -56,6 +56,7 @@ require ( github.com/fsnotify/fsnotify v1.9.0 github.com/gliderlabs/ssh v0.3.8 github.com/go-jose/go-jose/v4 v4.1.4 + github.com/goccy/go-yaml v1.18.0 github.com/godbus/dbus/v5 v5.1.0 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang/mock v1.6.0 @@ -213,10 +214,9 @@ require ( github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/webauthn v0.16.4 // indirect github.com/go-webauthn/x v0.2.3 // indirect - github.com/goccy/go-yaml v1.18.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect - github.com/google/btree v1.1.2 // indirect + github.com/google/btree v1.1.3 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-tpm v0.9.8 // indirect github.com/google/s2a-go v0.1.9 // indirect diff --git a/go.sum b/go.sum index 2f42f96b1..e8ff034d8 100644 --- a/go.sum +++ b/go.sum @@ -275,8 +275,8 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= -github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= -github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= From 62da4821334b99dc2a7020986776c5fcb901feba Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 10 Jun 2026 16:59:09 +0200 Subject: [PATCH 14/19] [management] Add version gate to stop sending deprecated RemotePeers field (#6371) * [management] Add version gate to stop sending deprecated RemotePeers field don't send top-level remote peers on peers in the v0.29.3 or newer * precompute deprecated remote peers version constraint * [management] update tests to validate network map-based remote peers * [management] move deprecatedRemotePeersVersion constant closer to its usage * fix misplaced precomputed constraint definition * ensure top-level RemotePeers is empty for v0.29.3+ clients --- .../internals/shared/grpc/conversion.go | 39 ++++++++++++++++++- .../internals/shared/grpc/conversion_test.go | 36 +++++++++++++++++ shared/management/client/client_test.go | 16 +++++--- 3 files changed, 84 insertions(+), 7 deletions(-) diff --git a/management/internals/shared/grpc/conversion.go b/management/internals/shared/grpc/conversion.go index b4a0d8b28..ced982a30 100644 --- a/management/internals/shared/grpc/conversion.go +++ b/management/internals/shared/grpc/conversion.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/hashicorp/go-version" + nbversion "github.com/netbirdio/netbird/version" log "github.com/sirupsen/logrus" goproto "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" @@ -28,6 +30,23 @@ import ( "github.com/netbirdio/netbird/shared/sshauth" ) +const ( + // deprecatedRemotePeersVersion is the version of Netbird that introduced the NetworkMap.RemotePeers field, deprecated in favor of RemotePeers. + deprecatedRemotePeersVersion = "0.29.3" +) + +// precomputedDeprecatedRemotePeersConstraint is the parsed ">= 0.29.3" constraint, +// built once at init since the bound is a compile-time constant. +var precomputedDeprecatedRemotePeersConstraint version.Constraints + +func init() { + constraint, err := version.NewConstraint(">= " + deprecatedRemotePeersVersion) + if err != nil { + panic("parse deprecated remote peers version constraint: " + err.Error()) + } + precomputedDeprecatedRemotePeersConstraint = constraint +} + func toNetbirdConfig(config *nbconfig.Config, turnCredentials *Token, relayToken *Token, extraSettings *types.ExtraSettings) *proto.NetbirdConfig { if config == nil { return nil @@ -155,7 +174,11 @@ func ToSyncResponse(ctx context.Context, config *nbconfig.Config, httpConfig *nb remotePeers := make([]*proto.RemotePeerConfig, 0, len(networkMap.Peers)+len(networkMap.OfflinePeers)) remotePeers = appendRemotePeerConfig(remotePeers, networkMap.Peers, dnsName, includeIPv6) - response.RemotePeers = remotePeers + + if !shouldSkipSendingDeprecatedRemotePeers(peer.Meta.WtVersion) { + response.RemotePeers = remotePeers + } + response.NetworkMap.RemotePeers = remotePeers response.RemotePeersIsEmpty = len(remotePeers) == 0 response.NetworkMap.RemotePeersIsEmpty = response.RemotePeersIsEmpty @@ -246,6 +269,19 @@ func buildAuthorizedUsersProto(ctx context.Context, authorizedUsers map[string]m return hashedUsers, machineUsers } +func shouldSkipSendingDeprecatedRemotePeers(peerVersion string) bool { + if nbversion.IsDevelopmentVersion(peerVersion) { + return true + } + + peerNBVersion, err := version.NewVersion(peerVersion) + if err != nil { + return false + } + + return precomputedDeprecatedRemotePeersConstraint.Check(peerNBVersion) +} + func appendRemotePeerConfig(dst []*proto.RemotePeerConfig, peers []*nbpeer.Peer, dnsName string, includeIPv6 bool) []*proto.RemotePeerConfig { for _, rPeer := range peers { allowedIPs := []string{rPeer.IP.String() + "/32"} @@ -363,7 +399,6 @@ func toProtocolFirewallRules(rules []*types.FirewallRule, includeIPv6, useSource return result } - // populateSourcePrefixes sets SourcePrefixes on fwRule and returns any // additional rules needed (e.g. a v6 wildcard clone when the peer IP is unspecified). func populateSourcePrefixes(fwRule *proto.FirewallRule, rule *types.FirewallRule, includeIPv6 bool) []*proto.FirewallRule { diff --git a/management/internals/shared/grpc/conversion_test.go b/management/internals/shared/grpc/conversion_test.go index 5efb24319..01a67e4fa 100644 --- a/management/internals/shared/grpc/conversion_test.go +++ b/management/internals/shared/grpc/conversion_test.go @@ -202,6 +202,42 @@ func TestBuildJWTConfig_Audiences(t *testing.T) { } } +// TestShouldSkipSendingDeprecatedRemotePeers covers the version gate that +// stops populating the deprecated top-level SyncResponse.RemotePeers field for +// peers new enough to read RemotePeers off the NetworkMap. Development builds +// are treated as latest and skip the field. The gate otherwise fails safe: a +// release version older than the boundary, or one that can't be parsed (empty, +// garbage, prereleases of the boundary) still receives the deprecated field so +// older/unknown clients keep working. +func TestShouldSkipSendingDeprecatedRemotePeers(t *testing.T) { + tests := []struct { + name string + peerVersion string + wantSkip bool + }{ + {"exact boundary skips", "0.29.3", true}, + {"newer patch skips", "0.29.4", true}, + {"newer minor skips", "0.30.0", true}, + {"newer major skips", "1.0.0", true}, + {"v-prefixed newer skips", "v0.30.0", true}, + {"development build skips", "development", true}, + {"development build with commit skips", "development-abc123def456-dirty", true}, + {"older patch keeps field", "0.29.2", false}, + {"older minor keeps field", "0.28.0", false}, + {"prerelease of boundary keeps field", "0.29.3-SNAPSHOT", false}, + {"tagged dev prerelease keeps field", "v0.31.1-dev", false}, + {"empty version keeps field", "", false}, + {"garbage version keeps field", "not-a-version", false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := shouldSkipSendingDeprecatedRemotePeers(tc.peerVersion) + assert.Equal(t, tc.wantSkip, got, "skip decision for peer version %q", tc.peerVersion) + }) + } +} + // TestEncodeSessionExpiresAt pins the wire encoding the client's // applySessionDeadline depends on: // diff --git a/shared/management/client/client_test.go b/shared/management/client/client_test.go index 53f3a262d..b62317775 100644 --- a/shared/management/client/client_test.go +++ b/shared/management/client/client_test.go @@ -322,15 +322,21 @@ func TestClient_Sync(t *testing.T) { if resp.GetNetbirdConfig() == nil { t.Error("expecting non nil NetbirdConfig got nil") } - if len(resp.GetRemotePeers()) != 1 { - t.Errorf("expecting RemotePeers size %d got %d", 1, len(resp.GetRemotePeers())) + // we test network map peers from 0.29.3 and dev builds + if len(resp.GetRemotePeers()) != 0 { + t.Error("expecting top-level RemotePeers to be empty for v0.29.3+ clients") + } + networkMap := resp.GetNetworkMap() + if len(networkMap.GetRemotePeers()) != 1 { + t.Errorf("expecting RemotePeers size %d got %d", 1, len(networkMap.GetRemotePeers())) return } - if resp.GetRemotePeersIsEmpty() == true { + + if networkMap.GetRemotePeersIsEmpty() { t.Error("expecting RemotePeers property to be false, got true") } - if resp.GetRemotePeers()[0].GetWgPubKey() != remoteKey.PublicKey().String() { - t.Errorf("expecting RemotePeer public key %s got %s", remoteKey.PublicKey().String(), resp.GetRemotePeers()[0].GetWgPubKey()) + if networkMap.GetRemotePeers()[0].GetWgPubKey() != remoteKey.PublicKey().String() { + t.Errorf("expecting RemotePeer public key %s got %s", remoteKey.PublicKey().String(), networkMap.GetRemotePeers()[0].GetWgPubKey()) } case <-time.After(3 * time.Second): t.Error("timeout waiting for test to finish") From 7feda907ca2357b3a6a54d00208ef84bf3a0836d Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 10 Jun 2026 18:55:24 +0200 Subject: [PATCH 15/19] [management] fix L4 service update when no custom port (#6396) This fixes an issue where L4 service update is not possible when proxy clusters don't support custom ports --- .../service/manager/l4_port_test.go | 189 ++++++++++++++++++ .../reverseproxy/service/manager/manager.go | 35 +++- 2 files changed, 219 insertions(+), 5 deletions(-) diff --git a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go index 3485d51fe..c218291ef 100644 --- a/management/internals/modules/reverseproxy/service/manager/l4_port_test.go +++ b/management/internals/modules/reverseproxy/service/manager/l4_port_test.go @@ -488,6 +488,195 @@ func TestUpdate_AllowsPortChange(t *testing.T) { assert.Equal(t, uint16(54321), updated.ListenPort, "explicit port change should be applied") } +func TestUpdate_PreservesPortWhenCustomPortsNotSupported(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc-renamed", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err, "update must not be rejected by the custom-port capability check") + assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved on unsupported cluster") +} + +func TestUpdate_PreservesPortWhenCustomPortsUnknown(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, nil) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc-renamed", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err, "update must not be rejected when cluster capability is unknown") + assert.Equal(t, uint16(12345), updated.ListenPort, "existing listen port should be preserved when capability is unknown") +} + +func TestUpdate_RejectsPortChangeWhenCustomPortsNotSupported(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 12345) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 54321, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.Error(t, err, "explicit port change on update must be rejected on unsupported clusters") + assert.Contains(t, err.Error(), "custom ports not supported on target cluster") +} + +func TestUpdate_TLSPortChangeAllowedWhenNotSupported(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + existing := seedService(t, testStore, "tls-svc", "tls", "app.example.com", testCluster, 443) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tls-svc", + Mode: "tls", + Domain: "app.example.com", + ProxyCluster: testCluster, + ListenPort: 9999, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 8443, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err, "TLS port change uses SNI routing and is exempt from the custom-port check") + assert.Equal(t, uint16(9999), updated.ListenPort, "TLS port change should be applied") +} + +func TestValidateL4PortDiffOnClusterDiff(t *testing.T) { + tests := []struct { + name string + mode string + customPorts *bool + newPort uint16 + oldPort uint16 + wantErr bool + }{ + {"tcp port change unsupported", "tcp", boolPtr(false), 54321, 12345, true}, + {"tcp port change unknown capability", "tcp", nil, 54321, 12345, true}, + {"udp port change unsupported", "udp", boolPtr(false), 54321, 12345, true}, + {"tcp first port assignment unsupported", "tcp", boolPtr(false), 54321, 0, true}, + {"tcp port change supported", "tcp", boolPtr(true), 54321, 12345, false}, + {"tcp port unchanged unsupported", "tcp", boolPtr(false), 12345, 12345, false}, + {"tcp zero port unsupported", "tcp", boolPtr(false), 0, 12345, false}, + {"tls port change unsupported", "tls", boolPtr(false), 9999, 443, false}, + {"http mode ignored", "http", boolPtr(false), 54321, 12345, false}, + {"empty mode ignored", "", boolPtr(false), 54321, 12345, false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + newSvc := &rpservice.Service{Mode: tc.mode, ListenPort: tc.newPort, ProxyCluster: testCluster} + oldSvc := &rpservice.Service{Mode: tc.mode, ListenPort: tc.oldPort, ProxyCluster: testCluster} + + err := validateL4PortDiffOnClusterDiff(tc.customPorts, newSvc, oldSvc) + if tc.wantErr { + assert.Error(t, err, "port diff should be rejected for %s", tc.name) + } else { + assert.NoError(t, err, "port diff should be allowed for %s", tc.name) + } + }) + } +} + +func TestUpdate_PortConflictRejected(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(true)) + ctx := context.Background() + + seedService(t, testStore, "tcp-a", "tcp", "tcp-a."+testCluster, testCluster, 5432) + svcB := seedService(t, testStore, "tcp-b", "tcp", "tcp-b."+testCluster, testCluster, 6543) + + updated := &rpservice.Service{ + ID: svcB.ID, + AccountID: testAccountID, + Name: "tcp-b", + Mode: "tcp", + Domain: "tcp-b." + testCluster, + ProxyCluster: testCluster, + ListenPort: 5432, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.Error(t, err, "updating to a port held by another service should be rejected") + assert.Contains(t, err.Error(), "already in use") +} + +func TestUpdate_AutoAssignsWhenNoPort(t *testing.T) { + mgr, testStore, _ := setupL4Test(t, boolPtr(false)) + ctx := context.Background() + + existing := seedService(t, testStore, "tcp-svc", "tcp", testCluster, testCluster, 0) + + updated := &rpservice.Service{ + ID: existing.ID, + AccountID: testAccountID, + Name: "tcp-svc", + Mode: "tcp", + Domain: testCluster, + ProxyCluster: testCluster, + ListenPort: 0, + Enabled: true, + Targets: []*rpservice.Target{ + {AccountID: testAccountID, TargetId: testPeerID, TargetType: rpservice.TargetTypePeer, Protocol: "tcp", Port: 9090, Enabled: true}, + }, + } + + _, err := mgr.persistServiceUpdate(ctx, testAccountID, updated) + require.NoError(t, err) + assert.True(t, updated.ListenPort >= autoAssignPortMin && updated.ListenPort <= autoAssignPortMax, + "auto-assigned port %d should be in range [%d, %d]", updated.ListenPort, autoAssignPortMin, autoAssignPortMax) + assert.True(t, updated.PortAutoAssigned, "PortAutoAssigned should be set when update triggers auto-assignment") +} + func TestCreateServiceFromPeer_TCP(t *testing.T) { mgr, _, _ := setupL4Test(t, boolPtr(false)) ctx := context.Background() diff --git a/management/internals/modules/reverseproxy/service/manager/manager.go b/management/internals/modules/reverseproxy/service/manager/manager.go index c8ab4f955..e6b006759 100644 --- a/management/internals/modules/reverseproxy/service/manager/manager.go +++ b/management/internals/modules/reverseproxy/service/manager/manager.go @@ -338,7 +338,7 @@ func (m *Manager) persistNewService(ctx context.Context, accountID string, svc * } } - if err := m.ensureL4Port(ctx, transaction, svc, customPorts); err != nil { + if err := m.ensureL4Port(ctx, transaction, svc, customPorts, false); err != nil { return err } @@ -367,11 +367,11 @@ func (m *Manager) clusterCustomPorts(ctx context.Context, svc *service.Service) // ensureL4Port auto-assigns a listen port when needed and validates cluster support. // customPorts must be pre-computed via clusterCustomPorts before entering a transaction. -func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service, customPorts *bool) error { +func (m *Manager) ensureL4Port(ctx context.Context, tx store.Store, svc *service.Service, customPorts *bool, serviceUpdate bool) error { if !service.IsL4Protocol(svc.Mode) { return nil } - if service.IsPortBasedProtocol(svc.Mode) && svc.ListenPort > 0 && (customPorts == nil || !*customPorts) { + if service.IsPortBasedProtocol(svc.Mode) && svc.ListenPort > 0 && !serviceUpdate && (customPorts == nil || !*customPorts) { if svc.Source != service.SourceEphemeral { return status.Errorf(status.InvalidArgument, "custom ports not supported on cluster %s", svc.ProxyCluster) } @@ -465,7 +465,7 @@ func (m *Manager) persistNewEphemeralService(ctx context.Context, accountID, pee return err } - if err := m.ensureL4Port(ctx, transaction, svc, customPorts); err != nil { + if err := m.ensureL4Port(ctx, transaction, svc, customPorts, false); err != nil { return err } @@ -651,12 +651,22 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St m.preserveListenPort(service, existingService) updateInfo.serviceEnabledChanged = existingService.Enabled != service.Enabled - if err := m.ensureL4Port(ctx, transaction, service, customPorts); err != nil { + // if the service is being updated, and we decide in the future to allow mode update, + // we should reconsider the currently assigned port if not 0 for clusters that don't support custom ports + if err := validateL4PortDiffOnClusterDiff(customPorts, service, existingService); err != nil { return err } + + if err := m.ensureL4Port(ctx, transaction, service, customPorts, true); err != nil { + return err + } + + // we can try carrying the previous service port into a new cluster, if this becomes a problem for multiple users, + // we should reconsider adding another check if err := m.checkPortConflict(ctx, transaction, service); err != nil { return err } + if err := transaction.UpdateService(ctx, service); err != nil { return fmt.Errorf("update service: %w", err) } @@ -664,6 +674,21 @@ func (m *Manager) executeServiceUpdate(ctx context.Context, transaction store.St return nil } +// validateL4PortDiffOnClusterDiff checks if custom L4 ports are configured and validates port changes across clusters. +// It ensures no port changes if custom ports are unsupported for a given cluster and protocol mode. +// Returns an error if validation fails, otherwise returns nil. +func validateL4PortDiffOnClusterDiff(customPorts *bool, newSVC, oldSVC *service.Service) error { + if !service.IsPortBasedProtocol(newSVC.Mode) || (customPorts != nil && *customPorts) { + return nil + } + + if newSVC.ListenPort != 0 && newSVC.ListenPort != oldSVC.ListenPort { + return status.Errorf(status.InvalidArgument, "custom ports not supported on target cluster %s", newSVC.ProxyCluster) + } + + return nil +} + // handleDomainChange validates the new domain is free inside the transaction // and applies the pre-resolved cluster (computed outside the tx by // resolveEffectiveCluster). It must NOT call clusterDeriver here: that talks From d7703767d5f211fa41f516340e191b9e609e4f45 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Wed, 10 Jun 2026 21:26:54 +0200 Subject: [PATCH 16/19] [client, proxy] cancel context before stopping engine on embedded client (#6397) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Engine.Start takes syncMsgMux with a deferred unlock (engine.go:445) and parks in receiveSignalEvents → WaitStreamConnected (engine.go:1762), which only wakes on signal-stream connect or client-context cancellation. - When signal never connects, the 30s startup timeout fires and embed.Client.Start's rollback (embed.go:281) called client.Stop() → Engine.Stop, which blocks acquiring syncMsgMux (engine.go:318). The cancel() that would unpark Start was deferred until Start returned — permanent cycle. RemovePeer calls (g43/g385) then queue behind the lifecycle mutex. - Notably, embed.Client.Stop and the daemon's cleanupConnection both cancel before stopping — the startup rollback was the only path that didn't. - Engine.Start takes syncMsgMux with a deferred unlock (engine.go:445) and parks in receiveSignalEvents → WaitStreamConnected (engine.go:1762), which only wakes on signal-stream connect or client-context cancellation. - When signal never connects, the 30s startup timeout fires and embed.Client.Start's rollback (embed.go:281) called client.Stop() → Engine.Stop, which blocks acquiring syncMsgMux (engine.go:318). The cancel() that would unpark Start was deferred until Start returned — permanent cycle. RemovePeer calls (g43/g385) then queue behind the lifecycle mutex. - Notably, embed.Client.Stop and the daemon's cleanupConnection both cancel before stopping — the startup rollback was the only path that didn't. --- client/embed/embed.go | 4 + client/embed/embed_test.go | 168 +++++++++++++++++++++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 client/embed/embed_test.go diff --git a/client/embed/embed.go b/client/embed/embed.go index 04bc60fb8..ff05989f5 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -279,6 +279,10 @@ func (c *Client) Start(startCtx context.Context) error { select { case <-startCtx.Done(): + // Cancel the client context before stopping: Engine.Start blocks on the + // signal stream while holding the engine mutex and only unblocks on + // cancellation. Stopping first would deadlock on that mutex. + cancel() if stopErr := client.Stop(); stopErr != nil { return fmt.Errorf("stop error after context done. Stop error: %w. Context done: %w", stopErr, startCtx.Err()) } diff --git a/client/embed/embed_test.go b/client/embed/embed_test.go new file mode 100644 index 000000000..a2f438975 --- /dev/null +++ b/client/embed/embed_test.go @@ -0,0 +1,168 @@ +package embed + +import ( + "context" + "net" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + + "github.com/netbirdio/netbird/management/internals/controllers/network_map/controller" + "github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel" + "github.com/netbirdio/netbird/management/internals/modules/peers" + "github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager" + "github.com/netbirdio/netbird/management/internals/server/config" + nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc" + mgmt "github.com/netbirdio/netbird/management/server" + "github.com/netbirdio/netbird/management/server/activity" + nbcache "github.com/netbirdio/netbird/management/server/cache" + "github.com/netbirdio/netbird/management/server/groups" + "github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator" + "github.com/netbirdio/netbird/management/server/integrations/port_forwarding" + "github.com/netbirdio/netbird/management/server/job" + "github.com/netbirdio/netbird/management/server/permissions" + "github.com/netbirdio/netbird/management/server/settings" + "github.com/netbirdio/netbird/management/server/store" + "github.com/netbirdio/netbird/management/server/telemetry" + "github.com/netbirdio/netbird/management/server/types" + mgmtProto "github.com/netbirdio/netbird/shared/management/proto" + "github.com/netbirdio/netbird/util" +) + +const testSetupKey = "A2C8E62B-38F5-4553-B31E-DD66C696CEBB" + +// TestClientStartTimeoutRollback reproduces a deadlock between Engine.Start and +// Engine.Stop. The signal endpoint accepts gRPC connections but never serves the +// SignalExchange service, so Engine.Start parks in WaitStreamConnected while +// holding the engine mutex. When the Start context expires, the rollback path +// calls ConnectClient.Stop, which must not block forever acquiring that mutex. +func TestClientStartTimeoutRollback(t *testing.T) { + signalAddr := startBlackholeSignal(t) + mgmAddr := startManagement(t, signalAddr) + + wgPort := 0 + client, err := New(Options{ + DeviceName: "embed-rollback-test", + SetupKey: testSetupKey, + ManagementURL: "http://" + mgmAddr, + WireguardPort: &wgPort, + }) + require.NoError(t, err, "embed client creation must succeed") + + startCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + startErr := make(chan error, 1) + go func() { + startErr <- client.Start(startCtx) + }() + + select { + case err := <-startErr: + require.ErrorIs(t, err, context.DeadlineExceeded) + case <-time.After(60 * time.Second): + t.Fatal("client.Start did not return after its context expired: Engine.Stop deadlocked against Engine.Start waiting for the signal stream") + } +} + +// startBlackholeSignal starts a gRPC server without the SignalExchange service +// registered. Connections succeed, but the signal stream can never be +// established, which keeps Engine.Start parked in WaitStreamConnected. +func startBlackholeSignal(t *testing.T) string { + t.Helper() + + lis, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + + s := grpc.NewServer() + go func() { + if err := s.Serve(lis); err != nil { + t.Error(err) + } + }() + t.Cleanup(s.Stop) + + return lis.Addr().String() +} + +func startManagement(t *testing.T, signalAddr string) string { + t.Helper() + + cfg := &config.Config{ + Stuns: []*config.Host{}, + TURNConfig: &config.TURNConfig{}, + Relay: &config.Relay{ + Addresses: []string{"127.0.0.1:1234"}, + CredentialsTTL: util.Duration{Duration: time.Hour}, + Secret: "222222222222222222", + }, + Signal: &config.Host{ + Proto: "http", + URI: signalAddr, + }, + Datadir: t.TempDir(), + HttpConfig: nil, + } + + lis, err := net.Listen("tcp", "localhost:0") + require.NoError(t, err) + + s := grpc.NewServer() + + testStore, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "../testdata/store.sql", cfg.Datadir) + require.NoError(t, err) + t.Cleanup(cleanUp) + + eventStore := &activity.InMemoryEventStore{} + + permissionsManager := permissions.NewManager(testStore) + peersManager := peers.NewManager(testStore, permissionsManager) + jobManager := job.NewJobManager(nil, testStore, peersManager) + + cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100) + require.NoError(t, err) + + iv, err := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore) + require.NoError(t, err) + metrics, err := telemetry.NewDefaultAppMetrics(context.Background()) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + t.Cleanup(ctrl.Finish) + settingsMockManager := settings.NewMockManager(ctrl) + settingsMockManager.EXPECT(). + GetSettings(gomock.Any(), gomock.Any(), gomock.Any()). + Return(&types.Settings{}, nil). + AnyTimes() + settingsMockManager.EXPECT(). + GetExtraSettings(gomock.Any(), gomock.Any()). + Return(&types.ExtraSettings{}, nil). + AnyTimes() + + groupsManager := groups.NewManagerMock() + + updateManager := update_channel.NewPeersUpdateManager(metrics) + requestBuffer := mgmt.NewAccountRequestBuffer(context.Background(), testStore) + networkMapController := controller.NewController(context.Background(), testStore, metrics, updateManager, requestBuffer, mgmt.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(testStore, peersManager), cfg) + accountManager, err := mgmt.BuildManager(context.Background(), cfg, testStore, networkMapController, jobManager, nil, "", eventStore, nil, false, iv, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManager, false, cacheStore) + require.NoError(t, err) + + secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(updateManager, cfg.TURNConfig, cfg.Relay, settingsMockManager, groupsManager) + require.NoError(t, err) + + mgmtServer, err := nbgrpc.NewServer(cfg, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &mgmt.MockIntegratedValidator{}, networkMapController, nil, nil) + require.NoError(t, err) + mgmtProto.RegisterManagementServiceServer(s, mgmtServer) + + go func() { + if err := s.Serve(lis); err != nil { + t.Error(err) + } + }() + t.Cleanup(s.Stop) + + return lis.Addr().String() +} From 8ff3b06cf1295661bbb293a73733d06b05b49959 Mon Sep 17 00:00:00 2001 From: Maycon Santos Date: Fri, 12 Jun 2026 10:24:15 +0200 Subject: [PATCH 17/19] [client] Index peer tunnel IPs for faster PeerStateByIP lookup (#6412) * [client] Index peer tunnel IPs for O(1) PeerStateByIP lookup Replace the linear scan over all peers with an ipToKey map maintained by AddPeer/RemovePeer, covering both IPv4 and IPv6 tunnel addresses. Offline peers are intentionally no longer resolvable by IP: only active peers can carry traffic, so IdentityForIP and the DNS disconnected-peer filter now treat them as unknown, same as foreign IPs. Skip the DNS answer filter for single-record responses; dropping the only answer was always restored by the empty-answer escape hatch, so the fast path is behavior-neutral. * Ensure `ipToKey` entries are only removed if they match the peer being deleted, preventing accidental removal of unrelated mappings. --- client/embed/embed.go | 4 +-- client/internal/dns/local/local.go | 2 +- client/internal/dns/local/local_test.go | 11 +++++++ client/internal/peer/conn_status.go | 1 - client/internal/peer/status.go | 40 ++++++++++++++---------- client/internal/peer/status_test.go | 41 +++++++++++++++++-------- 6 files changed, 67 insertions(+), 32 deletions(-) diff --git a/client/embed/embed.go b/client/embed/embed.go index ff05989f5..0e8991be2 100644 --- a/client/embed/embed.go +++ b/client/embed/embed.go @@ -446,8 +446,8 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession, // IdentityForIP looks up a remote peer by its tunnel IP using the // embedded client's status recorder. Returns the peer's WireGuard public -// key and FQDN. ok=false means the IP isn't in this client's peer -// roster — callers should treat that as "unknown peer". +// key and FQDN. ok=false means the IP doesn't belong to an active peer +// — offline roster peers are treated as unknown, same as foreign IPs. func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) { if !ip.IsValid() || c.recorder == nil { return "", "", false diff --git a/client/internal/dns/local/local.go b/client/internal/dns/local/local.go index d13aa672e..d0268186c 100644 --- a/client/internal/dns/local/local.go +++ b/client/internal/dns/local/local.go @@ -482,7 +482,7 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16, // completely when every proxy peer is offline (the upstream may still // be reachable some other way, or the peerstore may be stale). func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR { - if len(records) == 0 { + if len(records) < 2 { return records } d.mu.RLock() diff --git a/client/internal/dns/local/local_test.go b/client/internal/dns/local/local_test.go index fdf7f2659..9b7dac231 100644 --- a/client/internal/dns/local/local_test.go +++ b/client/internal/dns/local/local_test.go @@ -2738,6 +2738,17 @@ func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) { connByIP: nil, wantInOrder: []string{"100.64.0.10", "100.64.0.11"}, }, + { + // A single answer is never filtered: dropping it would only + // trigger the empty-answer escape hatch, so the fast path + // returns it untouched. + name: "single disconnected answer passes through", + records: []nbdns.SimpleRecord{disconnectedRec}, + connByIP: map[string]ipState{ + "100.64.0.11": {known: true, connected: false}, + }, + wantInOrder: []string{"100.64.0.11"}, + }, } for _, tc := range tests { diff --git a/client/internal/peer/conn_status.go b/client/internal/peer/conn_status.go index b43e245f3..d6ad37b70 100644 --- a/client/internal/peer/conn_status.go +++ b/client/internal/peer/conn_status.go @@ -26,7 +26,6 @@ type connStatusInputs struct { iceInProgress bool // a negotiation is currently in flight } - // ConnStatus describe the status of a peer's connection type ConnStatus int32 diff --git a/client/internal/peer/status.go b/client/internal/peer/status.go index b6c6c14ac..31e0d6e25 100644 --- a/client/internal/peer/status.go +++ b/client/internal/peer/status.go @@ -193,6 +193,7 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState { type Status struct { mux sync.RWMutex peers map[string]State + ipToKey map[string]string changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription signalState bool signalError error @@ -231,6 +232,7 @@ type Status struct { func NewRecorder(mgmAddress string) *Status { return &Status{ peers: make(map[string]State), + ipToKey: make(map[string]string), changeNotify: make(map[string]map[string]*StatusChangeSubscription), eventStreams: make(map[string]chan *proto.SystemEvent), eventQueue: NewEventQueue(eventQueueSize), @@ -282,6 +284,12 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string) Mux: new(sync.RWMutex), } d.peerListChangedForNotification = true + if ipv6 != "" { + d.ipToKey[ipv6] = peerPubKey + } + if ip != "" { + d.ipToKey[ip] = peerPubKey + } return nil } @@ -311,28 +319,22 @@ func (d *Status) PeerByIP(ip string) (string, bool) { // PeerStateByIP returns the full peer State for the given tunnel IP. // Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel -// address so dual-stack peers are reachable on either family. Searches -// both d.peers and d.offlinePeers — peers that have been moved into -// the offline slice by ReplaceOfflinePeers are still part of the -// account's roster and callers (DNS filter, embed.Client.IdentityForIP) -// need to recognise them rather than treating them as unknown. Returns -// the zero State and false when no peer matches or the input is empty. +// address so dual-stack peers are reachable on either family. Only +// active peers are matched; peers moved into the offline slice by +// ReplaceOfflinePeers are intentionally treated as unknown. func (d *Status) PeerStateByIP(ip string) (State, bool) { if ip == "" { return State{}, false } d.mux.RLock() defer d.mux.RUnlock() - - for _, state := range d.peers { - if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) { - return state, true - } + key, ok := d.ipToKey[ip] + if !ok { + return State{}, false } - for _, state := range d.offlinePeers { - if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) { - return state, true - } + state, ok := d.peers[key] + if ok { + return state, true } return State{}, false } @@ -342,12 +344,18 @@ func (d *Status) RemovePeer(peerPubKey string) error { d.mux.Lock() defer d.mux.Unlock() - _, ok := d.peers[peerPubKey] + p, ok := d.peers[peerPubKey] if !ok { return errors.New("no peer with to remove") } delete(d.peers, peerPubKey) + if mappedKey, exists := d.ipToKey[p.IP]; exists && mappedKey == peerPubKey { + delete(d.ipToKey, p.IP) + } + if mappedKey, exists := d.ipToKey[p.IPv6]; exists && mappedKey == peerPubKey { + delete(d.ipToKey, p.IPv6) + } d.peerListChangedForNotification = true return nil } diff --git a/client/internal/peer/status_test.go b/client/internal/peer/status_test.go index 97fb32c03..17ed47cd3 100644 --- a/client/internal/peer/status_test.go +++ b/client/internal/peer/status_test.go @@ -90,12 +90,11 @@ func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) { req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key") } -// TestStatus_PeerStateByIP_MatchesOfflinePeers covers peers that have -// been moved into the offline slice via ReplaceOfflinePeers. Callers -// (DNS filter, embed.Client.IdentityForIP) need to treat them as known -// rather than unknown — otherwise authentication / DNS filtering treats -// known-but-offline peers as foreign IPs. -func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) { +// TestStatus_PeerStateByIP_IgnoresOfflinePeers documents that peers +// moved into the offline slice via ReplaceOfflinePeers are intentionally +// not resolvable by IP: only active peers can carry traffic, so callers +// (DNS filter, embed.Client.IdentityForIP) treat them as unknown. +func TestStatus_PeerStateByIP_IgnoresOfflinePeers(t *testing.T) { status := NewRecorder("https://mgm") req := require.New(t) @@ -103,13 +102,31 @@ func TestStatus_PeerStateByIP_MatchesOfflinePeers(t *testing.T) { {PubKey: "pk-offline", FQDN: "offline.netbird", IP: "100.64.0.20", IPv6: "fd00::20"}, }) - state, ok := status.PeerStateByIP("100.64.0.20") - req.True(ok, "offline peer must resolve by IPv4 tunnel address") - req.Equal("pk-offline", state.PubKey, "matching state must carry the offline peer's pub key") + _, ok := status.PeerStateByIP("100.64.0.20") + req.False(ok, "offline peer must not resolve by IPv4 tunnel address") - state, ok = status.PeerStateByIP("fd00::20") - req.True(ok, "offline peer must resolve by IPv6 tunnel address") - req.Equal("pk-offline", state.PubKey, "IPv6 match must carry the offline peer's pub key") + _, ok = status.PeerStateByIP("fd00::20") + req.False(ok, "offline peer must not resolve by IPv6 tunnel address") +} + +// TestStatus_PeerStateByIP_RemovedPeer verifies RemovePeer drops the +// IP index entries for both address families. +func TestStatus_PeerStateByIP_RemovedPeer(t *testing.T) { + status := NewRecorder("https://mgm") + req := require.New(t) + + req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1")) + + _, ok := status.PeerStateByIP("100.64.0.10") + req.True(ok, "active peer must resolve before removal") + + req.NoError(status.RemovePeer("pk-1")) + + _, ok = status.PeerStateByIP("100.64.0.10") + req.False(ok, "removed peer must not resolve by IPv4 tunnel address") + + _, ok = status.PeerStateByIP("fd00::1") + req.False(ok, "removed peer must not resolve by IPv6 tunnel address") } func TestStatus_UpdatePeerFQDN(t *testing.T) { From 2bcea9d582dd3dce0df05e16ae82a5aa10ef9999 Mon Sep 17 00:00:00 2001 From: Riccardo Manfrin <3090891+riccardomanfrin@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:28:49 +0200 Subject: [PATCH 18/19] [client] add MDM configuration profile support (Windows registry + macOS plist) (#6374) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Initial scaffolding * Applies MDM override * Unit tests * Helpers business logic * Return error if trying to modify any config that is gated by MDM * Add ManagedFields to returned config over GetConfig * Adds initial 101 MDM policy business logic testing * gRPC MDM changes * MDM Name scoping for clarity * Implements windows loading of MDM policy * Adds missing WGPort config * Cleanup setupKey to align to linear * Align split tunnel code * Adds some log * Prefix every log with MDM * Adds debug config cobra command This can be useful for troubleshooting and checking config now that its resolution is not trivial defaults > config > env cars > CLI/UI > MDM * Adds MDM 1m diff checker & reloader * Adds also up/start after cancel * Publishes event for UI to sync upon MDM changes * Add events to resync UI to actual config This also provide fixup for UI no aligning to changed config when coming from cli up with config flags. * UI behavior conflicts relaxation UI sends full config snapshot with all values. It doesn't make sense to block it if the values are aligned with the values constrained by the MDM policy. It's just simplier to allow values that are compliant. (this goes for the CLI as well at this point) * Lock toggle Settngs * Advanced Settings locking * Fixup presharedkey * Apply MDM locks * Toggle gray in/out for Advanced Settings * Adds support for disabling of Profiles and UpdateSettings feature flags * Adds Gate Login as well when --disable-update-settings=true is given to service This commit tries to settle things with an old PR-4237 which had relaxed the case where the SetConfig returned an `Unavailable` code error. Under this circumnstance the PR allowed the upFunc to just emit a warning and progress further with the login gRPC. Since the login call is consuming the --management-url coming from the `up` command, it might be possible to abuse the "Unavailable" code to inject a management URL that is different from the configured one even though the --disable-update-settings is set to true (?) * Evaluate disable-update-settings errors only when there's an actual override * [UI] Fixup advanced Settings * [UI] Fixup for preshared key * [UI] Fixup for profile enable/disable toggle We need to align the initial state to evaluate the delta in case. The initial state has to be "true" since the profile starts visible. Then we receive MDM and transition the cache bool value to the actual MDM imposed state * Enforces disable networks * [UI] Aligns to "enable/disable once on change only" * Fixup: MDM wins. always * Removes --disable-advanced-settings It was a typo in our meetings. the actual thing is --disable-update-settings * [PROTO] Removes --disable-advanced-settings * [UI] Removes --disable-advanced-settings * Pins feat profile retrieval to notif event * [UI] Fix for "hide" not working when propagating to parent with children * Adds dep for reading plist files * Introduces support for darwing plist loading * Tests MDM config reload via ticker * [PROVISIONING] ADMX/ADML/PS/bash scripts/templates * CI fixes - Add docstrings to `mdm_integration` - refactor for cognitive complexity - mod tidy * Linting * Add docstrings to `mdm_integration` * nil,nil is no policy and no error. Allow it * nil,nil is no policy and no error. Allow it * exclude MDM profile adminstrated keys data from debug bundle * Fixes Rosenpass left disable after MDM unlock * Partial revert coderabbit added docstrings * Renaming fix * Avoid locking on clientRunning bool when the connection is aborted for whatever reason We want to just signal this through the giveUpChan, we will manage the signal from the waiter side and in case set it to false there. THis way we avoid locking, which should allow the MDM down+wait_for_term_chan_signal_+up procedure clientRunning is used to signal two different conditions here: 1. the initialization procedure is over (we have an engine) 2. the connection being up (or being attempted) Probably these two functionalities should not alias, and the failure of the second condition (because of any error) should just drive a reconnection (currently it's not happening, and we silently go idle). OR, mor probably, the two things are the SAME and there should not exist a case where we did the "Up" initialization and connection attempt but we are not still attempting it. * Moves test helper at te very bottom * Addresses github comments * No lock no copy * Prevents engine not stopping within 10 secs from being paired by another instance We instead juts SKIP updating the policy, so 1. the MDM ticker will kick in 1 minute time, 2. find the policy misaligned, 3. enter the onMDMPolicyChange, 4. find the s.clientRunning == true (because it is set to false only in server cleanupConnection, and not by s.actCancel()) 5. call s.actCancel() again if not nil 6. immediately return from <-s.clientGiveUpChan 7. finally call s.restartEngineForMDMLocked() * Since we ARE running there should be a config If the config was cancelled midflight, connect will abort later on * DisableAutoConnect should not stop a running connection. DisableAutoConnect should just avoid the connection attempts *when the service starts*. If we are started and we are up and running, DisableAutoConnect should not kick in. Another PR will follow about this topic * Removes unused vars * Moves callback into Run method arg * align comment to removal of DisableAutoConnect DisableAutoConnect should just avoid the connection attempts *when the service starts*. If we are started and we are up and running, DisableAutoConnect should not kick in * Removes unused managed_fields data. This was initially used to drive the UI but approach changed to reload config/features upon notifications which makes this data redundant. * Reorder stuff * Unexport unrequired vars/functions PoliciesEqual → policiesEqual AllKeys → allKeys * Adds list of MDM managed fields in the debug bundle --- client/cmd/debug.go | 69 +++ client/cmd/root.go | 15 +- client/internal/debug/debug.go | 8 + client/internal/debug/debug_test.go | 1 + client/internal/profilemanager/config.go | 107 ++++- .../profilemanager/config_mdm_test.go | 152 +++++++ client/mdm/canonical_loaders.go | 50 +++ client/mdm/policy.go | 247 +++++++++++ client/mdm/policy_darwin.go | 90 ++++ client/mdm/policy_mobile.go | 14 + client/mdm/policy_other.go | 14 + client/mdm/policy_test.go | 160 +++++++ client/mdm/policy_windows.go | 108 +++++ client/mdm/ticker.go | 129 ++++++ client/mdm/ticker_test.go | 100 +++++ client/proto/daemon.pb.go | 350 +++++++++------ client/proto/daemon.proto | 16 + client/server/mdm.go | 419 ++++++++++++++++++ client/server/network.go | 6 +- client/server/server.go | 223 ++++++++-- client/server/server_connect_test.go | 12 +- client/server/setconfig_mdm_test.go | 198 +++++++++ client/ui/client_ui.go | 305 ++++++++++--- client/ui/profile.go | 48 +- docs/io.netbird.client.plist | 126 ++++++ docs/netbird-macos.mobileconfig | 159 +++++++ docs/netbird-macos.sh | 189 ++++++++ docs/netbird-policy.reg | Bin 0 -> 1418 bytes docs/netbird-policy.reg.ps1 | 94 ++++ docs/netbird.adml | 95 ++++ docs/netbird.admx | 223 ++++++++++ go.mod | 1 + go.sum | 4 + 33 files changed, 3476 insertions(+), 256 deletions(-) create mode 100644 client/internal/profilemanager/config_mdm_test.go create mode 100644 client/mdm/canonical_loaders.go create mode 100644 client/mdm/policy.go create mode 100644 client/mdm/policy_darwin.go create mode 100644 client/mdm/policy_mobile.go create mode 100644 client/mdm/policy_other.go create mode 100644 client/mdm/policy_test.go create mode 100644 client/mdm/policy_windows.go create mode 100644 client/mdm/ticker.go create mode 100644 client/mdm/ticker_test.go create mode 100644 client/server/mdm.go create mode 100644 client/server/setconfig_mdm_test.go create mode 100644 docs/io.netbird.client.plist create mode 100644 docs/netbird-macos.mobileconfig create mode 100644 docs/netbird-macos.sh create mode 100644 docs/netbird-policy.reg create mode 100644 docs/netbird-policy.reg.ps1 create mode 100644 docs/netbird.adml create mode 100644 docs/netbird.admx diff --git a/client/cmd/debug.go b/client/cmd/debug.go index 02a742b28..bc7b0e98c 100644 --- a/client/cmd/debug.go +++ b/client/cmd/debug.go @@ -3,12 +3,14 @@ package cmd import ( "context" "fmt" + "os/user" "strings" "time" log "github.com/sirupsen/logrus" "github.com/spf13/cobra" "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/types/known/durationpb" "github.com/netbirdio/netbird/client/internal" @@ -85,6 +87,73 @@ var persistenceCmd = &cobra.Command{ RunE: setSyncResponsePersistence, } +var debugConfigCmd = &cobra.Command{ + Use: "config", + Example: " netbird debug config", + Short: "Dump the effective configuration", + Long: "Prints the daemon's resolved configuration (after applying defaults, file, env, CLI input, and MDM policy overrides) as JSON. Includes the list of MDM-managed fields.", + RunE: debugConfigDump, +} + +// debugConfigDump implements `netbird debug config`. It resolves the +// active profile, queries the daemon for the effective configuration +// via GetConfig, and prints the resulting GetConfigResponse as JSON +// (via protojson with EmitUnpopulated=true so the output is stable +// across runs and includes zero-valued fields). +// +// Useful for verifying MDM enforcement end-to-end: the response's +// mDMManagedFields array is the single source of truth for "which +// fields is the daemon currently enforcing from the MDM source", and +// every config field side-by-side with that list confirms the merge +// result. Secrets in the response (e.g. PreSharedKey) are already +// redacted by the daemon-side handler. +func debugConfigDump(cmd *cobra.Command, _ []string) error { + pm := profilemanager.NewProfileManager() + activeProf, err := pm.GetActiveProfile() + if err != nil { + return fmt.Errorf("get active profile: %v", err) + } + currUser, err := user.Current() + if err != nil { + return fmt.Errorf("get current user: %v", err) + } + + conn, err := getClient(cmd) + if err != nil { + return err + } + defer func() { + if err := conn.Close(); err != nil { + log.Errorf(errCloseConnection, err) + } + }() + + client := proto.NewDaemonServiceClient(conn) + resp, err := client.GetConfig(cmd.Context(), &proto.GetConfigRequest{ + ProfileName: activeProf.Name, + Username: currUser.Username, + }) + if err != nil { + return fmt.Errorf("failed to get config: %v", status.Convert(err).Message()) + } + + // Use protojson so well-known fields render correctly; emit defaults so + // the operator sees every field even when zero/empty. + m := protojson.MarshalOptions{Multiline: true, Indent: " ", EmitUnpopulated: true} + out, err := m.Marshal(resp) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + cmd.Println(string(out)) + return nil +} + +// debugBundle requests the daemon to create a debug bundle and prints +// the resulting local file path and, if uploaded, the uploaded file +// key. It uses the package flags (anonymize, system info, log file +// count, CLI version, optional upload URL) to configure the bundle +// request. Returns an error if the RPC fails or if the daemon reports +// an upload failure reason. func debugBundle(cmd *cobra.Command, _ []string) error { conn, err := getClient(cmd) if err != nil { diff --git a/client/cmd/root.go b/client/cmd/root.go index 5c9e1ff8a..b1d960bec 100644 --- a/client/cmd/root.go +++ b/client/cmd/root.go @@ -95,7 +95,9 @@ var ( } ) -// Execute executes the root command. +// Execute runs the appropriate Cobra command for the CLI. +// If the process is the update binary it delegates to updateCmd; otherwise it runs the root command. +// It returns any error produced during command execution. func Execute() error { if isUpdateBinary() { return updateCmd.Execute() @@ -103,6 +105,16 @@ func Execute() error { return rootCmd.Execute() } +// init initialises package-level defaults and configures the root +// Cobra command tree. Sets platform-specific config / log directory +// paths (including legacy Wiretrustee fallbacks) and a default daemon +// address; registers persistent CLI flags (daemon address, +// management / admin URLs, logging, setup key (file and inline, +// mutually exclusive), preshared key, hostname, anonymise, config +// path); attaches top-level and nested subcommands to the root +// command; and registers `up`-specific persistent flags (external IP +// maps, custom DNS resolver address, Rosenpass options, auto-connect +// disabling, lazy connection). func init() { defaultConfigPathDir = "/etc/netbird/" defaultLogFileDir = "/var/log/netbird/" @@ -168,6 +180,7 @@ func init() { logCmd.AddCommand(logLevelCmd) debugCmd.AddCommand(forCmd) debugCmd.AddCommand(persistenceCmd) + debugCmd.AddCommand(debugConfigCmd) // kubernetes commands rootCmd.AddCommand(kubernetesCmd) diff --git a/client/internal/debug/debug.go b/client/internal/debug/debug.go index 9ab18dd80..05501320c 100644 --- a/client/internal/debug/debug.go +++ b/client/internal/debug/debug.go @@ -516,6 +516,14 @@ func (g *BundleGenerator) addConfig() error { } } + // Surface the set of MDM-enforced keys so a support engineer reading + // the bundle can tell which field values are user-set vs MDM-overridden. + // Same semantics as the mDMManagedFields list returned by the + // GetConfig RPC consumed by `netbird debug config`. + if managed := g.internalConfig.Policy().ManagedKeys(); len(managed) > 0 { + configContent.WriteString(fmt.Sprintf("MDMManagedFields: %v\n", managed)) + } + configReader := strings.NewReader(configContent.String()) if err := g.addFileToZip(configReader, "config.txt"); err != nil { return fmt.Errorf("add config file to zip: %w", err) diff --git a/client/internal/debug/debug_test.go b/client/internal/debug/debug_test.go index 39b972244..76df588a5 100644 --- a/client/internal/debug/debug_test.go +++ b/client/internal/debug/debug_test.go @@ -843,6 +843,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) { "PreSharedKey": "sensitive: WireGuard pre-shared key", "SSHKey": "sensitive: SSH private key", "ClientCertKeyPair": "non-config: parsed cert pair, not serialized", + "policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields", } mURL, _ := url.Parse("https://api.example.com:443") diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index cd5bc0680..b0c7fd470 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -22,6 +22,7 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/internal/routemanager/dynamic" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/ssh" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" @@ -57,6 +58,10 @@ var DefaultInterfaceBlacklist = []string{ "Tailscale", "tailscale", "docker", "veth", "br-", "lo", } +// loadMDMPolicy is the package-level indirection used by apply() to read the +// active MDM policy. Tests override this to inject a fake policy. +var loadMDMPolicy = mdm.LoadPolicy + // ConfigInput carries configuration changes to the client type ConfigInput struct { ManagementURL string @@ -174,6 +179,23 @@ type Config struct { LazyConnectionEnabled bool MTU uint16 + + // policy is the MDM policy that produced the currently-set values for + // any MDM-enforced fields. Set by applyMDMPolicy at the tail of apply() + // and reset on every apply() invocation. Never persisted to disk. + // Callers query enforcement state via Policy() and the mdm.Policy API + // (HasKey, ManagedKeys, IsEmpty). + policy *mdm.Policy `json:"-"` +} + +// Policy returns the MDM policy applied to this Config. Returns a non-nil +// empty Policy when MDM enforcement is inactive; callers can always invoke +// HasKey / ManagedKeys / IsEmpty without a nil check. +func (config *Config) Policy() *mdm.Policy { + if config == nil || config.policy == nil { + return mdm.NewPolicy(nil) + } + return config.policy } var ConfigDirOverride string @@ -612,10 +634,93 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) { updated = true } + // MDM is the last override layer: any key present in the policy + // supersedes defaults, on-disk config, env vars and CLI input. + config.applyMDMPolicy(loadMDMPolicy()) + return updated, nil } -// parseURL parses and validates a service URL +// applyMDMPolicy overlays MDM-supplied values on top of the resolved Config. +// The provided Policy is also stored on the Config so callers can later query +// which fields are enforced. Invalid values (e.g. malformed URLs) are logged +// and skipped to avoid bricking the client; the field keeps its previous +// resolved value but is still marked as managed (Policy.HasKey returns true +// for the key, so per-field rejection of user writes still applies). +func (config *Config) applyMDMPolicy(policy *mdm.Policy) { + config.policy = policy + if policy.IsEmpty() { + return + } + + // Helper: log the application of a single MDM-managed key. Values for + // keys in mdm.SecretKeys are redacted. + logApplied := func(key string, displayValue any) { + if _, secret := mdm.SecretKeys[key]; secret { + log.Infof("MDM override %s = ********** (secret)", key) + return + } + log.Infof("MDM override %s = %v", key, displayValue) + } + + if v, ok := policy.GetString(mdm.KeyManagementURL); ok { + if u, err := parseURL("Management URL", v); err != nil { + log.Warnf("MDM management URL %q invalid: %v; keeping previous value", v, err) + } else { + config.ManagementURL = u + logApplied(mdm.KeyManagementURL, u.String()) + } + } + + if v, ok := policy.GetString(mdm.KeyPreSharedKey); ok { + // Defensive: refuse the redaction mask in case it round-tripped + // through a manifest by mistake. + if !isPreSharedKeyHidden(&v) { + config.PreSharedKey = v + logApplied(mdm.KeyPreSharedKey, "") + } + } + + // applyBool collapses the per-key "read + set + log" boilerplate + // for every plain bool MDM key into a single helper. Keeps the + // outer function's cognitive complexity below SonarCube's + // threshold; functional behaviour is identical to the inlined + // branches it replaces. + applyBool := func(key string, setter func(bool)) { + v, ok := policy.GetBool(key) + if !ok { + return + } + setter(v) + logApplied(key, v) + } + + applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv }) + applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v }) + applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v }) + applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v }) + applyBool(mdm.KeyDisableAutoConnect, func(v bool) { config.DisableAutoConnect = v }) + applyBool(mdm.KeyRosenpassEnabled, func(v bool) { config.RosenpassEnabled = v }) + applyBool(mdm.KeyRosenpassPermissive, func(v bool) { config.RosenpassPermissive = v }) + + if v, ok := policy.GetInt(mdm.KeyWireguardPort); ok { + // REG_DWORD is 32-bit; UDP port range is 1-65535. Clamp at the + // upper bound and reject obviously-invalid values to avoid the + // engine binding to an unusable port if the admin pushes garbage. + if v >= 1 && v <= 65535 { + config.WgPort = int(v) + logApplied(mdm.KeyWireguardPort, v) + } else { + log.Warnf("MDM wireguard port %d out of range [1,65535]; keeping previous value", v) + } + } +} + +// parseURL parses and validates the URL for the named service. The URL +// must use the http or https scheme; if no port is present, ":443" is +// appended for https or ":80" for http. The serviceName parameter is +// used to contextualise error messages. On success returns the parsed +// *url.URL; on failure returns a non-nil error. func parseURL(serviceName, serviceURL string) (*url.URL, error) { parsedMgmtURL, err := url.ParseRequestURI(serviceURL) if err != nil { diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go new file mode 100644 index 000000000..6a201235e --- /dev/null +++ b/client/internal/profilemanager/config_mdm_test.go @@ -0,0 +1,152 @@ +package profilemanager + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/mdm" +) + +// withMDMPolicy temporarily overrides the package-level loadMDMPolicy hook so +// apply() observes the supplied Policy. The original loader is restored at +// test cleanup. +func withMDMPolicy(t *testing.T, policy *mdm.Policy) { + t.Helper() + prev := loadMDMPolicy + loadMDMPolicy = func() *mdm.Policy { return policy } + t.Cleanup(func() { loadMDMPolicy = prev }) +} + +func TestApply_MDMEmpty_NoEnforcement(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(nil)) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.True(t, cfg.Policy().IsEmpty(), "no MDM source ⇒ empty Policy") + assert.False(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) + assert.Empty(t, cfg.Policy().ManagedKeys()) + + // Default management URL still resolves. + assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) +} + +func TestApply_MDMOnly_OverridesDefaults(t *testing.T) { + const mdmURL = "https://corp.mdm.example.com:443" + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: mdmURL, + mdm.KeyDisableClientRoutes: true, + mdm.KeyBlockInbound: true, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.Equal(t, mdmURL, cfg.ManagementURL.String()) + assert.True(t, cfg.DisableClientRoutes) + assert.True(t, cfg.BlockInbound) + + assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyBlockInbound)) + assert.False(t, cfg.Policy().HasKey(mdm.KeyAllowServerSSH)) +} + +func TestApply_MDMBeatsCLIInput(t *testing.T) { + const mdmURL = "https://mdm.example.com:443" + const cliURL = "https://cli.example.com:443" + + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: mdmURL, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + ManagementURL: cliURL, + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + // MDM wins over CLI-supplied management URL. + assert.Equal(t, mdmURL, cfg.ManagementURL.String()) + assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) +} + +func TestApply_MDMInvalidURL_KeepsPreviousValue(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "not-a-url", + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Invalid MDM URL is logged and skipped: default URL stays in place + // to keep the client functional. + assert.Equal(t, DefaultManagementURL, cfg.ManagementURL.String()) + + // But the key is still considered MDM-managed (admin intent is to + // enforce, daemon rejects user writes to this field — phase-1 scaffolding + // reflects this by keeping Policy.HasKey true even on parse failure). + assert.True(t, cfg.Policy().HasKey(mdm.KeyManagementURL)) +} + +func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "config.json") + + // Seed without MDM. + withMDMPolicy(t, mdm.NewPolicy(nil)) + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: tmp, + DisableClientRoutes: boolPtr(false), + RosenpassEnabled: boolPtr(false), + }) + require.NoError(t, err) + + // Now enable MDM enforcement for these keys. + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyDisableClientRoutes: true, + mdm.KeyRosenpassEnabled: true, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp}) + require.NoError(t, err) + require.NotNil(t, cfg) + + assert.True(t, cfg.DisableClientRoutes, "MDM override should flip on-disk false to true") + assert.True(t, cfg.RosenpassEnabled) + assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableClientRoutes)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled)) +} + +func TestApply_MDMPreSharedKeyRedactionSentinelRejected(t *testing.T) { + const maskSentinel = "**********" + + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyPreSharedKey: maskSentinel, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: filepath.Join(t.TempDir(), "config.json"), + }) + require.NoError(t, err) + require.NotNil(t, cfg) + + // Mask sentinel must not be persisted as the actual PSK. + assert.NotEqual(t, maskSentinel, cfg.PreSharedKey) + // Key still marked managed so user writes are still rejected. + assert.True(t, cfg.Policy().HasKey(mdm.KeyPreSharedKey)) +} + +func boolPtr(b bool) *bool { return &b } diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go new file mode 100644 index 000000000..6e7ab19cb --- /dev/null +++ b/client/mdm/canonical_loaders.go @@ -0,0 +1,50 @@ +//go:build windows || darwin + +package mdm + +import "strings" + +// allKeys is the set of recognised MDM keys. Unknown keys in a managed +// configuration are ignored but logged. Lives in this build-tagged file +// (windows || darwin) because only desktop loaders need the +// canonicalisation table that consumes it; including it unconditionally +// would trigger the `unused` golangci-lint check on platforms that +// don't import canonical_loaders.go. +var allKeys = []string{ + KeyManagementURL, + KeyDisableUpdateSettings, + KeyDisableProfiles, + KeyDisableNetworks, + KeyDisableClientRoutes, + KeyDisableServerRoutes, + KeyBlockInbound, + KeyDisableMetricsCollection, + KeyAllowServerSSH, + KeyDisableAutoConnect, + KeyPreSharedKey, + KeyRosenpassEnabled, + KeyRosenpassPermissive, + KeyWireguardPort, + KeySplitTunnelMode, + KeySplitTunnelApps, +} + +// canonicalKey maps the lowercase form of a managed-config value name to +// its canonical mdm.Key* form. Admins commonly write PascalCase value +// names in ADMX / Group Policy ("ManagementURL"); the iOS/AppConfig and +// macOS plist conventions are camelCase ("managementURL"); both must +// resolve to the same Policy lookup. +// +// Lives in a desktop-loader-only file (build tag `windows || darwin`) +// because no other build path consumes it. Linux / FreeBSD / mobile +// builds don't ship a platform loader that reads arbitrary-case key +// names, so they don't need the canonicalisation table — and including +// the var unconditionally would trigger the `unused` golangci-lint +// check on those platforms. +var canonicalKey = func() map[string]string { + m := make(map[string]string, len(allKeys)) + for _, k := range allKeys { + m[strings.ToLower(k)] = k + } + return m +}() diff --git a/client/mdm/policy.go b/client/mdm/policy.go new file mode 100644 index 000000000..109fb322e --- /dev/null +++ b/client/mdm/policy.go @@ -0,0 +1,247 @@ +// Package mdm reads MDM-managed configuration from platform-native sources +// (plist on macOS, registry on Windows, UserDefaults on iOS, +// RestrictionsManager on Android). The returned Policy is consumed by +// profilemanager.Config.apply() as the highest-priority override layer. +// +// An empty Policy (no source present, or source present with zero keys) +// means no MDM enforcement is active and the client behaves as if the +// feature did not exist. +package mdm + +import ( + "sort" + "strconv" + + log "github.com/sirupsen/logrus" +) + +// Well-known policy keys. Names mirror the corresponding ConfigInput Go field +// names (lowerCamelCase) so the daemon can map a Policy key directly to a +// configuration field. +const ( + KeyManagementURL = "managementURL" + KeyDisableUpdateSettings = "disableUpdateSettings" + KeyDisableProfiles = "disableProfiles" + KeyDisableNetworks = "disableNetworks" + KeyDisableClientRoutes = "disableClientRoutes" + KeyDisableServerRoutes = "disableServerRoutes" + KeyBlockInbound = "blockInbound" + KeyDisableMetricsCollection = "disableMetricsCollection" + KeyAllowServerSSH = "allowServerSSH" + KeyDisableAutoConnect = "disableAutoConnect" + KeyPreSharedKey = "preSharedKey" + KeyRosenpassEnabled = "rosenpassEnabled" + KeyRosenpassPermissive = "rosenpassPermissive" + KeyWireguardPort = "wireguardPort" + + // Split tunnel is modeled as a single conceptual policy with two + // registry/plist values. KeySplitTunnelMode is the discriminator + // ("allow" or "disallow"); KeySplitTunnelApps is a comma-separated + // list of package names. The values are mutually exclusive by + // construction — only one mode can be set at a time. + KeySplitTunnelMode = "splitTunnelMode" + KeySplitTunnelApps = "splitTunnelApps" +) + +// Split-tunnel mode literals (KeySplitTunnelMode values). +const ( + SplitTunnelModeAllow = "allow" + SplitTunnelModeDisallow = "disallow" +) + +// SecretKeys lists keys whose values must be redacted in logs. +var SecretKeys = map[string]struct{}{ + KeyPreSharedKey: {}, +} + +// boolStringLiterals enumerates the textual boolean encodings the +// platform loaders may produce (Windows REG_SZ "true", iOS / Android +// managed-config booleans-as-strings, etc.). Lookup keeps GetBool flat +// (no nested switch on the string case). +var boolStringLiterals = map[string]bool{ + "true": true, + "1": true, + "yes": true, + "false": false, + "0": false, + "no": false, +} + + +// Policy holds MDM-managed settings read from the platform source. A nil or +// empty Policy means no enforcement is active. +type Policy struct { + values map[string]any +} + +// NewPolicy constructs a Policy from a key→value map. Pass nil or an +// empty map to construct an empty (no-enforcement) Policy. The returned +// *Policy is always non-nil. +func NewPolicy(values map[string]any) *Policy { + if values == nil { + values = map[string]any{} + } + return &Policy{values: values} +} + +// LoadPolicy reads the platform-native MDM configuration. Returns an +// empty (but non-nil) Policy when no source is present, the source is +// empty, or the platform is unsupported. +// +// Diagnostic logging differentiates the three states: +// - source absent / unsupported platform: trace log only +// - source present, zero keys: info "MDM enrolled (no managed keys)" +// - source present, N keys: info "MDM enrolled with N managed keys: [...]" +func LoadPolicy() *Policy { + values, err := loadPlatformPolicy() + if err != nil { + log.Tracef("MDM policy load: %v", err) + return &Policy{values: map[string]any{}} + } + if values == nil { + return &Policy{values: map[string]any{}} + } + if len(values) == 0 { + log.Info("MDM enrolled (no managed keys)") + } else { + log.Infof("MDM enrolled with %d managed key(s): %v", len(values), sortedKeys(values)) + } + return &Policy{values: values} +} + +// IsEmpty reports whether the Policy has no managed keys. +func (p *Policy) IsEmpty() bool { + return p == nil || len(p.values) == 0 +} + +// HasKey reports whether the given key is MDM-managed. +func (p *Policy) HasKey(key string) bool { + if p == nil { + return false + } + _, ok := p.values[key] + return ok +} + +// ManagedKeys returns the sorted list of managed key names. Returns an empty +// slice (not nil) on an empty Policy. +func (p *Policy) ManagedKeys() []string { + if p == nil { + return []string{} + } + return sortedKeys(p.values) +} + +// GetString returns the managed value for key coerced to string, and whether +// the key was set. A non-string value returns ("", false). +func (p *Policy) GetString(key string) (string, bool) { + if p == nil { + return "", false + } + v, ok := p.values[key] + if !ok { + return "", false + } + s, ok := v.(string) + if !ok || s == "" { + return "", false + } + return s, true +} + +// GetBool returns the managed value for key coerced to bool, and whether the +// key was set. Accepts native bool and string literals "true"/"false"/"1"/"0". +func (p *Policy) GetBool(key string) (bool, bool) { + if p == nil { + return false, false + } + v, ok := p.values[key] + if !ok { + return false, false + } + switch t := v.(type) { + case bool: + return t, true + case string: + b, known := boolStringLiterals[t] + return b, known + case int: + return t != 0, true + case int64: + return t != 0, true + } + return false, false +} + +// GetInt returns the managed value for key as int64, and whether the key +// was set. Accepts native int / int64 (as produced by the Windows registry +// loader for REG_DWORD/REG_QWORD) and numeric strings (decimal). +func (p *Policy) GetInt(key string) (int64, bool) { + if p == nil { + return 0, false + } + v, ok := p.values[key] + if !ok { + return 0, false + } + switch t := v.(type) { + case int64: + return t, true + case int: + return int64(t), true + case int32: + return int64(t), true + case uint64: + return int64(t), true + case float64: + return int64(t), true + case string: + if n, err := strconv.ParseInt(t, 10, 64); err == nil { + return n, true + } + } + return 0, false +} + +// GetStringSlice returns the managed value for key as []string, and whether +// the key was set. Accepts []string, []any (of strings), and a single string +// (treated as a one-element list). +func (p *Policy) GetStringSlice(key string) ([]string, bool) { + if p == nil { + return nil, false + } + v, ok := p.values[key] + if !ok { + return nil, false + } + switch t := v.(type) { + case []string: + return append([]string(nil), t...), true + case []any: + out := make([]string, 0, len(t)) + for _, item := range t { + s, ok := item.(string) + if !ok { + return nil, false + } + out = append(out, s) + } + return out, true + case string: + return []string{t}, true + } + return nil, false +} + +// sortedKeys returns the keys of m as a deterministic, lexicographically +// sorted slice. Used internally by Policy.ManagedKeys and LoadPolicy's +// diagnostic log line so callers see a stable key order across runs +// regardless of Go's randomised map iteration. +func sortedKeys(m map[string]any) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} diff --git a/client/mdm/policy_darwin.go b/client/mdm/policy_darwin.go new file mode 100644 index 000000000..57aa1168c --- /dev/null +++ b/client/mdm/policy_darwin.go @@ -0,0 +1,90 @@ +//go:build darwin && !ios + +package mdm + +import ( + "errors" + "fmt" + "io/fs" + "os" + "strings" + + log "github.com/sirupsen/logrus" + "howett.net/plist" +) + +// policyPlistPath is the well-known location where macOS writes the +// device-level mandatory MDM payload for NetBird. The path is fixed by +// Apple convention: when an MDM provider (Jamf / Kandji / Mosyle / +// Intune for Mac / Workspace ONE) pushes a Configuration Profile that +// contains a com.apple.ManagedClient.preferences payload targeting the +// bundle id io.netbird.client, the OS materializes the payload here. +// +// Read-only — only the OS (root) is supposed to write this file. The +// loader sanity-checks the file mode and refuses to honour a world- +// writable plist, as a defense against tampered installs. +const policyPlistPath = "/Library/Managed Preferences/io.netbird.client.plist" + +// loadPlatformPolicy reads the MDM-managed configuration from the macOS +// managed-preferences plist at policyPlistPath. Returns: +// - (nil, nil) when the plist is absent (device not MDM-enrolled for +// NetBird, or admin has not yet pushed a payload) +// - (map, nil) with N entries when N managed values are present +// (N may be 0 — empty plist still signals enrollment to the caller) +// - (nil, err) on permission / parse / safety errors (including +// refusal to read a world-writable plist) +// +// Top-level plist keys are canonicalised case-insensitively to the +// package's internal mdm.Key* names; unknown keys are logged and +// skipped so a stray entry in the payload does not block startup. +// Native plist value types map naturally onto the Policy accessor +// expectations (GetString / GetBool / GetInt / GetStringSlice). +func loadPlatformPolicy() (map[string]any, error) { + f, err := os.Open(policyPlistPath) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + // Not enrolled for NetBird. Caller treats nil as + // "no MDM source present". + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + return nil, nil + } + return nil, fmt.Errorf("open %s: %w", policyPlistPath, err) + } + defer func() { + if closeErr := f.Close(); closeErr != nil { + log.Warnf("MDM close plist %s: %v", policyPlistPath, closeErr) + } + }() + + info, err := f.Stat() + if err != nil { + return nil, fmt.Errorf("stat %s: %w", policyPlistPath, err) + } + // World-writable plist => tampered install. Refuse rather than + // honour potentially attacker-controlled policy values. + if info.Mode().Perm()&0o002 != 0 { + return nil, fmt.Errorf("refusing to read world-writable MDM source %s (mode %o)", + policyPlistPath, info.Mode().Perm()) + } + + raw := make(map[string]any) + if err := plist.NewDecoder(f).Decode(&raw); err != nil { + return nil, fmt.Errorf("decode plist %s: %w", policyPlistPath, err) + } + + out := make(map[string]any, len(raw)) + for name, val := range raw { + // macOS / AppConfig conventions both use camelCase for managed + // preferences keys; canonicalize to the mdm.Key* form so a key + // written as "ManagementURL" (PascalCase, rare on macOS but + // possible if the admin reused an ADMX-style name) still + // resolves. + canonical, known := canonicalKey[strings.ToLower(name)] + if !known { + log.Warnf("MDM ignoring unknown plist key %s: %s", policyPlistPath, name) + continue + } + out[canonical] = val + } + return out, nil +} diff --git a/client/mdm/policy_mobile.go b/client/mdm/policy_mobile.go new file mode 100644 index 000000000..ec25d4bb1 --- /dev/null +++ b/client/mdm/policy_mobile.go @@ -0,0 +1,14 @@ +//go:build ios || android + +package mdm + +// loadPlatformPolicy is unused on mobile: the native layer (Swift on iOS, +// Kotlin/Java on Android) reads the OS managed-config store and pushes the +// resulting dictionary in-process via a gomobile entry point that lands in +// Phase 5 / Phase 6. The stub keeps the package compilable for mobile +// builds and returns (nil, nil) — the platform-absent sentinel that +// LoadPolicy in policy.go treats as "no MDM source present". +func loadPlatformPolicy() (map[string]any, error) { + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + return nil, nil +} diff --git a/client/mdm/policy_other.go b/client/mdm/policy_other.go new file mode 100644 index 000000000..f4263afa2 --- /dev/null +++ b/client/mdm/policy_other.go @@ -0,0 +1,14 @@ +//go:build !windows && !darwin && !ios && !android + +package mdm + +// loadPlatformPolicy returns no policy on platforms without an MDM channel +// (Linux, FreeBSD). MDM enforcement is off and the client behaves as if +// the feature did not exist. Returns (nil, nil) — the platform-absent +// sentinel the caller (LoadPolicy in policy.go) treats as "no MDM +// source present"; an error here would just translate to the same +// outcome with an extra log line. +func loadPlatformPolicy() (map[string]any, error) { + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + return nil, nil +} diff --git a/client/mdm/policy_test.go b/client/mdm/policy_test.go new file mode 100644 index 000000000..47a6ed2c9 --- /dev/null +++ b/client/mdm/policy_test.go @@ -0,0 +1,160 @@ +package mdm + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPolicy_NilSafe(t *testing.T) { + var p *Policy + assert.True(t, p.IsEmpty()) + assert.False(t, p.HasKey(KeyManagementURL)) + assert.Empty(t, p.ManagedKeys()) + + _, ok := p.GetString(KeyManagementURL) + assert.False(t, ok) + _, ok = p.GetBool(KeyDisableProfiles) + assert.False(t, ok) + _, ok = p.GetStringSlice(KeySplitTunnelApps) + assert.False(t, ok) +} + +func TestPolicy_Empty(t *testing.T) { + p := NewPolicy(nil) + require.NotNil(t, p) + assert.True(t, p.IsEmpty()) + assert.False(t, p.HasKey(KeyManagementURL)) + assert.Empty(t, p.ManagedKeys()) +} + +func TestPolicy_HasKey(t *testing.T) { + p := NewPolicy(map[string]any{ + KeyManagementURL: "https://corp.example.com", + KeyDisableProfiles: true, + }) + assert.False(t, p.IsEmpty()) + assert.True(t, p.HasKey(KeyManagementURL)) + assert.True(t, p.HasKey(KeyDisableProfiles)) + assert.False(t, p.HasKey(KeyPreSharedKey)) +} + +func TestPolicy_ManagedKeysSorted(t *testing.T) { + p := NewPolicy(map[string]any{ + KeyDisableProfiles: true, + KeyManagementURL: "https://x", + KeyAllowServerSSH: false, + }) + got := p.ManagedKeys() + assert.Equal(t, []string{KeyAllowServerSSH, KeyDisableProfiles, KeyManagementURL}, got) +} + +func TestPolicy_GetString(t *testing.T) { + p := NewPolicy(map[string]any{ + KeyManagementURL: "https://corp.example.com", + KeyDisableProfiles: true, // wrong type for GetString + KeyPreSharedKey: "", // empty rejected + }) + v, ok := p.GetString(KeyManagementURL) + assert.True(t, ok) + assert.Equal(t, "https://corp.example.com", v) + + _, ok = p.GetString(KeyDisableProfiles) + assert.False(t, ok, "non-string value must not be reported as string") + + _, ok = p.GetString(KeyPreSharedKey) + assert.False(t, ok, "empty string treated as unset") + + _, ok = p.GetString("nonexistent") + assert.False(t, ok) +} + +func TestPolicy_GetBool(t *testing.T) { + cases := []struct { + name string + raw any + want bool + ok bool + }{ + {"native true", true, true, true}, + {"native false", false, false, true}, + {"string true", "true", true, true}, + {"string false", "false", false, true}, + {"string 1", "1", true, true}, + {"string 0", "0", false, true}, + {"string yes", "yes", true, true}, + {"string no", "no", false, true}, + {"int nonzero", 1, true, true}, + {"int zero", 0, false, true}, + {"int64 nonzero", int64(2), true, true}, + {"int64 zero", int64(0), false, true}, + {"string garbage", "maybe", false, false}, + {"float unsupported", 1.0, false, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + p := NewPolicy(map[string]any{KeyDisableProfiles: c.raw}) + got, ok := p.GetBool(KeyDisableProfiles) + assert.Equal(t, c.ok, ok) + if c.ok { + assert.Equal(t, c.want, got) + } + }) + } + + _, ok := NewPolicy(nil).GetBool(KeyDisableProfiles) + assert.False(t, ok) +} + +func TestPolicy_GetStringSlice(t *testing.T) { + t.Run("native string slice", func(t *testing.T) { + p := NewPolicy(map[string]any{ + KeySplitTunnelApps: []string{"com.a", "com.b"}, + }) + got, ok := p.GetStringSlice(KeySplitTunnelApps) + assert.True(t, ok) + assert.Equal(t, []string{"com.a", "com.b"}, got) + }) + + t.Run("any slice of strings", func(t *testing.T) { + p := NewPolicy(map[string]any{ + KeySplitTunnelApps: []any{"com.a", "com.b"}, + }) + got, ok := p.GetStringSlice(KeySplitTunnelApps) + assert.True(t, ok) + assert.Equal(t, []string{"com.a", "com.b"}, got) + }) + + t.Run("single string lifts to one-element slice", func(t *testing.T) { + p := NewPolicy(map[string]any{ + KeySplitTunnelApps: "com.a", + }) + got, ok := p.GetStringSlice(KeySplitTunnelApps) + assert.True(t, ok) + assert.Equal(t, []string{"com.a"}, got) + }) + + t.Run("mixed any slice rejected", func(t *testing.T) { + p := NewPolicy(map[string]any{ + KeySplitTunnelApps: []any{"com.a", 1}, + }) + _, ok := p.GetStringSlice(KeySplitTunnelApps) + assert.False(t, ok) + }) + + t.Run("missing key", func(t *testing.T) { + p := NewPolicy(nil) + _, ok := p.GetStringSlice(KeySplitTunnelApps) + assert.False(t, ok) + }) +} + +func TestLoadPolicy_PlatformStubReturnsEmpty(t *testing.T) { + // loadPlatformPolicy is a stub on every OS for Phase 1. LoadPolicy must + // degrade gracefully and never return nil. + p := LoadPolicy() + require.NotNil(t, p) + assert.True(t, p.IsEmpty()) + assert.Empty(t, p.ManagedKeys()) +} diff --git a/client/mdm/policy_windows.go b/client/mdm/policy_windows.go new file mode 100644 index 000000000..0c2629f98 --- /dev/null +++ b/client/mdm/policy_windows.go @@ -0,0 +1,108 @@ +//go:build windows + +package mdm + +import ( + "errors" + "fmt" + "strings" + + log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows/registry" +) + +// policyRegistryPath is the well-known MDM policy registry key for NetBird. +// Admins push values here through Group Policy, Intune ADMX ingestion, an +// Intune custom Registry CSP profile, or `reg add` during MSI deployment. +// Listed in the project's docs/mdm/netbird.admx schema. +const policyRegistryPath = `Software\Policies\NetBird` + +// readRegistryValue reads a single value under policyRegistryPath and, +// on success, stores the type-coerced result in out[canonical]. Type +// coercion mirrors loadPlatformPolicy's documented mapping: +// - REG_SZ / REG_EXPAND_SZ -> string (REG_EXPAND_SZ is expanded by the API) +// - REG_DWORD / REG_QWORD -> int64 +// - REG_MULTI_SZ -> []string +// +// Unsupported value types and per-value read failures are logged at +// warn level and skipped — one malformed value must not block the +// surrounding loop. Extracted from loadPlatformPolicy to keep that +// function's cognitive complexity in check. +func readRegistryValue(k registry.Key, name, canonical string, out map[string]any) { + _, valType, err := k.GetValue(name, nil) + if err != nil { + log.Warnf("MDM stat %s\\%s: %v", policyRegistryPath, name, err) + return + } + switch valType { + case registry.SZ, registry.EXPAND_SZ: + if v, _, err := k.GetStringValue(name); err == nil { + out[canonical] = v + } else { + log.Warnf("MDM read string %s\\%s: %v", policyRegistryPath, name, err) + } + case registry.DWORD, registry.QWORD: + if v, _, err := k.GetIntegerValue(name); err == nil { + // uint64 from the registry API; Policy.GetBool / GetInt + // helpers consume int64, so narrow safely. + out[canonical] = int64(v) + } else { + log.Warnf("MDM read int %s\\%s: %v", policyRegistryPath, name, err) + } + case registry.MULTI_SZ: + if v, _, err := k.GetStringsValue(name); err == nil { + out[canonical] = v + } else { + log.Warnf("MDM read multi-string %s\\%s: %v", policyRegistryPath, name, err) + } + default: + log.Warnf("MDM ignoring unsupported registry value type %d at %s\\%s", + valType, policyRegistryPath, name) + } +} + +// loadPlatformPolicy reads the MDM-managed configuration from the +// Windows registry under HKLM\Software\Policies\NetBird. Returns: +// - (nil, nil) when the key is absent (device not MDM-enrolled for NetBird) +// - (map, nil) with N entries when N managed values are set (N may be 0) +// - (nil, err) on open / enumerate registry errors +// +// Per-value type coercion + skip-on-error is delegated to +// readRegistryValue. Unknown value names are logged and skipped so a +// malformed deployment does not block startup. +func loadPlatformPolicy() (map[string]any, error) { + k, err := registry.OpenKey(registry.LOCAL_MACHINE, policyRegistryPath, registry.QUERY_VALUE) + if err != nil { + if errors.Is(err, registry.ErrNotExist) { + // Not enrolled. Caller treats nil as "no MDM source present". + //nolint:nilnil // (nil, nil) is the documented platform-absent sentinel; see LoadPolicy. + return nil, nil + } + return nil, fmt.Errorf("open %s: %w", policyRegistryPath, err) + } + defer func() { + if closeErr := k.Close(); closeErr != nil { + log.Warnf("MDM close registry key %s: %v", policyRegistryPath, closeErr) + } + }() + + names, err := k.ReadValueNames(-1) + if err != nil { + return nil, fmt.Errorf("enumerate values of %s: %w", policyRegistryPath, err) + } + + out := make(map[string]any, len(names)) + for _, name := range names { + // Canonicalize the registry value name against the known MDM key + // set so Policy.HasKey lookups (which use the canonical names) + // succeed regardless of the casing used by the admin's ADMX or + // `reg add` command. + canonical, known := canonicalKey[strings.ToLower(name)] + if !known { + log.Warnf("MDM ignoring unknown registry value %s\\%s", policyRegistryPath, name) + continue + } + readRegistryValue(k, name, canonical, out) + } + return out, nil +} diff --git a/client/mdm/ticker.go b/client/mdm/ticker.go new file mode 100644 index 000000000..abd6ae233 --- /dev/null +++ b/client/mdm/ticker.go @@ -0,0 +1,129 @@ +package mdm + +import ( + "context" + "reflect" + "sort" + "time" + + log "github.com/sirupsen/logrus" +) + +// DefaultReloadInterval is the production cadence at which the desktop daemon +// re-reads the OS-native MDM policy. Picked to balance responsiveness against +// registry/plist I/O overhead. Mobile builds use OS-side notifications +// instead, hence anticipating the ticker mechanism entirely. +const DefaultReloadInterval = 1 * time.Minute + +// policyLoader is the indirection through which the ticker reads the +// OS-native policy, both for the initial observation and on every tick. +// Production points it at LoadPolicy; tests in this package override it to +// feed a scripted sequence of policies without touching the real OS store. +var policyLoader = LoadPolicy + +// Ticker periodically re-reads the OS-native MDM policy via LoadPolicy and +// invokes the onChange callback (supplied to Run) whenever the observed +// Policy diverges from the last observation (added / removed / changed +// keys). Launch with Run from a goroutine; cancel the supplied context +// to stop. +type Ticker struct { + interval time.Duration + prev *Policy +} + +// NewTicker constructs a Ticker that will re-read the OS-native policy +// every reloadInterval once Run is called. +// The initial snapshot is populated by calling policyLoader at +// construction time so the first tick only fires +// onChange when the policy actually changed since boot — without +// this baseline the first tick would report every currently-managed +// key as "added" and trigger a spurious engine restart. +func NewTicker(reloadInterval time.Duration) *Ticker { + return &Ticker{ + interval: reloadInterval, + prev: policyLoader(), + } +} + +// Run blocks until ctx is cancelled, polling the OS-native policy store at +// the configured cadence and emitting log lines + onChange callback on +// every observed diff. onChange must be non-nil. +func (t *Ticker) Run(ctx context.Context, onChange func(prev, curr *Policy) error) { + tk := time.NewTicker(t.interval) + defer tk.Stop() + log.Infof("MDM policy reload ticker started (interval=%s)", t.interval) + for { + select { + case <-ctx.Done(): + log.Info("MDM policy reload ticker stopped") + return + case <-tk.C: + curr := policyLoader() + if policiesEqual(t.prev, curr) { + continue + } + added, removed, changed := diffPolicies(t.prev, curr) + log.Infof("MDM policy changed: added=%v removed=%v changed=%v", + added, removed, changed) + prev := t.prev + if err := onChange(prev, curr); err != nil { + log.Errorf("MDM policy change handler failed (retrying in 1 minute): %v", err) + continue + } + t.prev = curr + } + } +} + +// policiesEqual reports whether two Policy instances carry the same +// managed key set with identical values. Nil and empty policies +// compare equal; one-nil/one-non-empty compare not equal; otherwise +// the underlying values maps are compared with reflect.DeepEqual. +func policiesEqual(a, b *Policy) bool { + if a.IsEmpty() && b.IsEmpty() { + return true + } + if a == nil || b == nil { + return false + } + return reflect.DeepEqual(a.values, b.values) +} + +// diffPolicies returns the keys added in curr, removed from prev, and +// whose values changed between prev and curr. Each slice is sorted +// lexicographically for stable log output; value differences are +// determined with reflect.DeepEqual. +func diffPolicies(prev, curr *Policy) (added, removed, changed []string) { + prevKVs := mapOf(prev) + currKVs := mapOf(curr) + for k := range currKVs { + if _, ok := prevKVs[k]; !ok { + added = append(added, k) + } else if !reflect.DeepEqual(prevKVs[k], currKVs[k]) { + changed = append(changed, k) + } + } + for k := range prevKVs { + if _, ok := currKVs[k]; !ok { + removed = append(removed, k) + } + } + sort.Strings(added) + sort.Strings(removed) + sort.Strings(changed) + return added, removed, changed +} + +// mapOf returns a (possibly empty, never nil) copy of the underlying +// values map of a Policy so callers outside this package can compare +// keys/values across the type boundary. Returns an empty map on nil p. +func mapOf(p *Policy) map[string]any { + if p == nil { + return map[string]any{} + } + out := make(map[string]any, len(p.values)) + for k, v := range p.values { + out[k] = v + } + return out +} diff --git a/client/mdm/ticker_test.go b/client/mdm/ticker_test.go new file mode 100644 index 000000000..17f3cfc2f --- /dev/null +++ b/client/mdm/ticker_test.go @@ -0,0 +1,100 @@ +package mdm + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// testReloadInterval for speeding up the ticker cadence under `go test` +const testReloadInterval = 1 * time.Second + +// withPolicyLoader overrides the package-level policyLoader for the duration +// of the test so the ticker observes a scripted policy instead of the real +// OS-native store. The original loader is restored on cleanup. +func withPolicyLoader(t *testing.T, fn func() *Policy) { + t.Helper() + prev := policyLoader + policyLoader = fn + t.Cleanup(func() { policyLoader = prev }) +} + +func TestTicker_FiresOnChangeWithDelta(t *testing.T) { + var mu sync.Mutex + current := NewPolicy(nil) // initial observation: empty (no enforcement) + withPolicyLoader(t, func() *Policy { + mu.Lock() + defer mu.Unlock() + return current + }) + + type change struct{ prev, curr *Policy } + changes := make(chan change, 1) + tk := NewTicker(testReloadInterval) + require.Equal(t, testReloadInterval, tk.interval) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + tk.Run(ctx, func(prev, curr *Policy) error { + select { + case changes <- change{prev, curr}: + default: + } + return nil + }) + close(done) + }() + // Stop Run and wait for it to exit before returning, so the policyLoader + // restore in t.Cleanup can't race the ticker goroutine still reading it. + defer func() { cancel(); <-done }() + + // Flip the OS-observed policy from empty to one managed key. The next + // tick must detect the diff and invoke onChange. + mu.Lock() + current = NewPolicy(map[string]any{KeyManagementURL: "https://mdm.example.com:443"}) + mu.Unlock() + + select { + case c := <-changes: + assert.True(t, c.prev.IsEmpty(), "prev should be the initial empty policy") + assert.True(t, c.curr.HasKey(KeyManagementURL), "curr should carry the newly-pushed managed key") + case <-time.After(5 * time.Second): + t.Fatal("onChange not invoked within 5s; ticker should fire every 1s under test") + } +} + +func TestTicker_NoCallbackWhenPolicyUnchanged(t *testing.T) { + withPolicyLoader(t, func() *Policy { + return NewPolicy(map[string]any{KeyBlockInbound: true}) + }) + + fired := make(chan struct{}, 1) + tk := NewTicker(testReloadInterval) + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + go func() { + tk.Run(ctx, func(_, _ *Policy) error { + select { + case fired <- struct{}{}: + default: + } + return nil + }) + close(done) + }() + defer func() { cancel(); <-done }() + + // Over ~2 ticks at the 1s test cadence the policy never changes, so the + // diff guard must suppress the callback entirely. + select { + case <-fired: + t.Fatal("onChange fired despite an unchanged policy") + case <-time.After(2500 * time.Millisecond): + } +} diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 79fa1418a..70d9e8212 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -1191,8 +1191,14 @@ type GetConfigResponse struct { DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // mDMManagedFields lists the names of configuration keys whose value is + // currently enforced by an MDM policy. Names match mdm.Key* constants + // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should + // render the corresponding inputs as read-only and display a "managed + // by MDM" indicator. + MDMManagedFields []string `protobuf:"bytes,28,rep,name=mDMManagedFields,proto3" json:"mDMManagedFields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *GetConfigResponse) Reset() { @@ -1414,6 +1420,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool { return false } +func (x *GetConfigResponse) GetMDMManagedFields() []string { + if x != nil { + return x.MDMManagedFields + } + return nil +} + // PeerState contains the latest state of a peer type PeerState struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -4961,6 +4974,55 @@ func (x *GetFeaturesResponse) GetDisableNetworks() bool { return false } +// MDMManagedFieldsViolation is attached as a gRPC error detail on a +// FailedPrecondition status returned from SetConfig (and similar mutating +// RPCs) when the caller tries to modify one or more MDM-enforced fields. +// The fields list contains the offending key names; the entire request is +// rejected (no partial apply). +type MDMManagedFieldsViolation struct { + state protoimpl.MessageState `protogen:"open.v1"` + Fields []string `protobuf:"bytes,1,rep,name=fields,proto3" json:"fields,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *MDMManagedFieldsViolation) Reset() { + *x = MDMManagedFieldsViolation{} + mi := &file_daemon_proto_msgTypes[71] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *MDMManagedFieldsViolation) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MDMManagedFieldsViolation) ProtoMessage() {} + +func (x *MDMManagedFieldsViolation) ProtoReflect() protoreflect.Message { + mi := &file_daemon_proto_msgTypes[71] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MDMManagedFieldsViolation.ProtoReflect.Descriptor instead. +func (*MDMManagedFieldsViolation) Descriptor() ([]byte, []int) { + return file_daemon_proto_rawDescGZIP(), []int{71} +} + +func (x *MDMManagedFieldsViolation) GetFields() []string { + if x != nil { + return x.Fields + } + return nil +} + type TriggerUpdateRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4969,7 +5031,7 @@ type TriggerUpdateRequest struct { func (x *TriggerUpdateRequest) Reset() { *x = TriggerUpdateRequest{} - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[72] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -4981,7 +5043,7 @@ func (x *TriggerUpdateRequest) String() string { func (*TriggerUpdateRequest) ProtoMessage() {} func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[71] + mi := &file_daemon_proto_msgTypes[72] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -4994,7 +5056,7 @@ func (x *TriggerUpdateRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateRequest.ProtoReflect.Descriptor instead. func (*TriggerUpdateRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{71} + return file_daemon_proto_rawDescGZIP(), []int{72} } type TriggerUpdateResponse struct { @@ -5007,7 +5069,7 @@ type TriggerUpdateResponse struct { func (x *TriggerUpdateResponse) Reset() { *x = TriggerUpdateResponse{} - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[73] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5019,7 +5081,7 @@ func (x *TriggerUpdateResponse) String() string { func (*TriggerUpdateResponse) ProtoMessage() {} func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[72] + mi := &file_daemon_proto_msgTypes[73] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5032,7 +5094,7 @@ func (x *TriggerUpdateResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use TriggerUpdateResponse.ProtoReflect.Descriptor instead. func (*TriggerUpdateResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{72} + return file_daemon_proto_rawDescGZIP(), []int{73} } func (x *TriggerUpdateResponse) GetSuccess() bool { @@ -5060,7 +5122,7 @@ type GetPeerSSHHostKeyRequest struct { func (x *GetPeerSSHHostKeyRequest) Reset() { *x = GetPeerSSHHostKeyRequest{} - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[74] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5072,7 +5134,7 @@ func (x *GetPeerSSHHostKeyRequest) String() string { func (*GetPeerSSHHostKeyRequest) ProtoMessage() {} func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[73] + mi := &file_daemon_proto_msgTypes[74] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5085,7 +5147,7 @@ func (x *GetPeerSSHHostKeyRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyRequest.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{73} + return file_daemon_proto_rawDescGZIP(), []int{74} } func (x *GetPeerSSHHostKeyRequest) GetPeerAddress() string { @@ -5112,7 +5174,7 @@ type GetPeerSSHHostKeyResponse struct { func (x *GetPeerSSHHostKeyResponse) Reset() { *x = GetPeerSSHHostKeyResponse{} - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[75] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5124,7 +5186,7 @@ func (x *GetPeerSSHHostKeyResponse) String() string { func (*GetPeerSSHHostKeyResponse) ProtoMessage() {} func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[74] + mi := &file_daemon_proto_msgTypes[75] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5137,7 +5199,7 @@ func (x *GetPeerSSHHostKeyResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetPeerSSHHostKeyResponse.ProtoReflect.Descriptor instead. func (*GetPeerSSHHostKeyResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{74} + return file_daemon_proto_rawDescGZIP(), []int{75} } func (x *GetPeerSSHHostKeyResponse) GetSshHostKey() []byte { @@ -5179,7 +5241,7 @@ type RequestJWTAuthRequest struct { func (x *RequestJWTAuthRequest) Reset() { *x = RequestJWTAuthRequest{} - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[76] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5191,7 +5253,7 @@ func (x *RequestJWTAuthRequest) String() string { func (*RequestJWTAuthRequest) ProtoMessage() {} func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[75] + mi := &file_daemon_proto_msgTypes[76] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5204,7 +5266,7 @@ func (x *RequestJWTAuthRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthRequest.ProtoReflect.Descriptor instead. func (*RequestJWTAuthRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{75} + return file_daemon_proto_rawDescGZIP(), []int{76} } func (x *RequestJWTAuthRequest) GetHint() string { @@ -5237,7 +5299,7 @@ type RequestJWTAuthResponse struct { func (x *RequestJWTAuthResponse) Reset() { *x = RequestJWTAuthResponse{} - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5249,7 +5311,7 @@ func (x *RequestJWTAuthResponse) String() string { func (*RequestJWTAuthResponse) ProtoMessage() {} func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[76] + mi := &file_daemon_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5262,7 +5324,7 @@ func (x *RequestJWTAuthResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use RequestJWTAuthResponse.ProtoReflect.Descriptor instead. func (*RequestJWTAuthResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{76} + return file_daemon_proto_rawDescGZIP(), []int{77} } func (x *RequestJWTAuthResponse) GetVerificationURI() string { @@ -5327,7 +5389,7 @@ type WaitJWTTokenRequest struct { func (x *WaitJWTTokenRequest) Reset() { *x = WaitJWTTokenRequest{} - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5339,7 +5401,7 @@ func (x *WaitJWTTokenRequest) String() string { func (*WaitJWTTokenRequest) ProtoMessage() {} func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[77] + mi := &file_daemon_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5352,7 +5414,7 @@ func (x *WaitJWTTokenRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenRequest.ProtoReflect.Descriptor instead. func (*WaitJWTTokenRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{77} + return file_daemon_proto_rawDescGZIP(), []int{78} } func (x *WaitJWTTokenRequest) GetDeviceCode() string { @@ -5384,7 +5446,7 @@ type WaitJWTTokenResponse struct { func (x *WaitJWTTokenResponse) Reset() { *x = WaitJWTTokenResponse{} - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5396,7 +5458,7 @@ func (x *WaitJWTTokenResponse) String() string { func (*WaitJWTTokenResponse) ProtoMessage() {} func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[78] + mi := &file_daemon_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5409,7 +5471,7 @@ func (x *WaitJWTTokenResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use WaitJWTTokenResponse.ProtoReflect.Descriptor instead. func (*WaitJWTTokenResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{78} + return file_daemon_proto_rawDescGZIP(), []int{79} } func (x *WaitJWTTokenResponse) GetToken() string { @@ -5442,7 +5504,7 @@ type StartCPUProfileRequest struct { func (x *StartCPUProfileRequest) Reset() { *x = StartCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5454,7 +5516,7 @@ func (x *StartCPUProfileRequest) String() string { func (*StartCPUProfileRequest) ProtoMessage() {} func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[79] + mi := &file_daemon_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5467,7 +5529,7 @@ func (x *StartCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StartCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{79} + return file_daemon_proto_rawDescGZIP(), []int{80} } // StartCPUProfileResponse confirms CPU profiling has started @@ -5479,7 +5541,7 @@ type StartCPUProfileResponse struct { func (x *StartCPUProfileResponse) Reset() { *x = StartCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5491,7 +5553,7 @@ func (x *StartCPUProfileResponse) String() string { func (*StartCPUProfileResponse) ProtoMessage() {} func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[80] + mi := &file_daemon_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5504,7 +5566,7 @@ func (x *StartCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StartCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{80} + return file_daemon_proto_rawDescGZIP(), []int{81} } // StopCPUProfileRequest for stopping CPU profiling @@ -5516,7 +5578,7 @@ type StopCPUProfileRequest struct { func (x *StopCPUProfileRequest) Reset() { *x = StopCPUProfileRequest{} - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[82] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5528,7 +5590,7 @@ func (x *StopCPUProfileRequest) String() string { func (*StopCPUProfileRequest) ProtoMessage() {} func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[81] + mi := &file_daemon_proto_msgTypes[82] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5541,7 +5603,7 @@ func (x *StopCPUProfileRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileRequest.ProtoReflect.Descriptor instead. func (*StopCPUProfileRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{81} + return file_daemon_proto_rawDescGZIP(), []int{82} } // StopCPUProfileResponse confirms CPU profiling has stopped @@ -5553,7 +5615,7 @@ type StopCPUProfileResponse struct { func (x *StopCPUProfileResponse) Reset() { *x = StopCPUProfileResponse{} - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[83] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5565,7 +5627,7 @@ func (x *StopCPUProfileResponse) String() string { func (*StopCPUProfileResponse) ProtoMessage() {} func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[82] + mi := &file_daemon_proto_msgTypes[83] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5578,7 +5640,7 @@ func (x *StopCPUProfileResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopCPUProfileResponse.ProtoReflect.Descriptor instead. func (*StopCPUProfileResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{82} + return file_daemon_proto_rawDescGZIP(), []int{83} } type InstallerResultRequest struct { @@ -5589,7 +5651,7 @@ type InstallerResultRequest struct { func (x *InstallerResultRequest) Reset() { *x = InstallerResultRequest{} - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[84] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5601,7 +5663,7 @@ func (x *InstallerResultRequest) String() string { func (*InstallerResultRequest) ProtoMessage() {} func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[83] + mi := &file_daemon_proto_msgTypes[84] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5614,7 +5676,7 @@ func (x *InstallerResultRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultRequest.ProtoReflect.Descriptor instead. func (*InstallerResultRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{83} + return file_daemon_proto_rawDescGZIP(), []int{84} } type InstallerResultResponse struct { @@ -5627,7 +5689,7 @@ type InstallerResultResponse struct { func (x *InstallerResultResponse) Reset() { *x = InstallerResultResponse{} - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[85] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5639,7 +5701,7 @@ func (x *InstallerResultResponse) String() string { func (*InstallerResultResponse) ProtoMessage() {} func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[84] + mi := &file_daemon_proto_msgTypes[85] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5652,7 +5714,7 @@ func (x *InstallerResultResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use InstallerResultResponse.ProtoReflect.Descriptor instead. func (*InstallerResultResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{84} + return file_daemon_proto_rawDescGZIP(), []int{85} } func (x *InstallerResultResponse) GetSuccess() bool { @@ -5685,7 +5747,7 @@ type ExposeServiceRequest struct { func (x *ExposeServiceRequest) Reset() { *x = ExposeServiceRequest{} - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[86] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5697,7 +5759,7 @@ func (x *ExposeServiceRequest) String() string { func (*ExposeServiceRequest) ProtoMessage() {} func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[85] + mi := &file_daemon_proto_msgTypes[86] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5710,7 +5772,7 @@ func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead. func (*ExposeServiceRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{85} + return file_daemon_proto_rawDescGZIP(), []int{86} } func (x *ExposeServiceRequest) GetPort() uint32 { @@ -5781,7 +5843,7 @@ type ExposeServiceEvent struct { func (x *ExposeServiceEvent) Reset() { *x = ExposeServiceEvent{} - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[87] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5793,7 +5855,7 @@ func (x *ExposeServiceEvent) String() string { func (*ExposeServiceEvent) ProtoMessage() {} func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[86] + mi := &file_daemon_proto_msgTypes[87] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5806,7 +5868,7 @@ func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead. func (*ExposeServiceEvent) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{86} + return file_daemon_proto_rawDescGZIP(), []int{87} } func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event { @@ -5847,7 +5909,7 @@ type ExposeServiceReady struct { func (x *ExposeServiceReady) Reset() { *x = ExposeServiceReady{} - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[88] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5859,7 +5921,7 @@ func (x *ExposeServiceReady) String() string { func (*ExposeServiceReady) ProtoMessage() {} func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[87] + mi := &file_daemon_proto_msgTypes[88] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5872,7 +5934,7 @@ func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message { // Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead. func (*ExposeServiceReady) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{87} + return file_daemon_proto_rawDescGZIP(), []int{88} } func (x *ExposeServiceReady) GetServiceName() string { @@ -5917,7 +5979,7 @@ type StartCaptureRequest struct { func (x *StartCaptureRequest) Reset() { *x = StartCaptureRequest{} - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[89] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -5929,7 +5991,7 @@ func (x *StartCaptureRequest) String() string { func (*StartCaptureRequest) ProtoMessage() {} func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[88] + mi := &file_daemon_proto_msgTypes[89] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -5942,7 +6004,7 @@ func (x *StartCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartCaptureRequest.ProtoReflect.Descriptor instead. func (*StartCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{88} + return file_daemon_proto_rawDescGZIP(), []int{89} } func (x *StartCaptureRequest) GetTextOutput() bool { @@ -5996,7 +6058,7 @@ type CapturePacket struct { func (x *CapturePacket) Reset() { *x = CapturePacket{} - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[90] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6008,7 +6070,7 @@ func (x *CapturePacket) String() string { func (*CapturePacket) ProtoMessage() {} func (x *CapturePacket) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[89] + mi := &file_daemon_proto_msgTypes[90] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6021,7 +6083,7 @@ func (x *CapturePacket) ProtoReflect() protoreflect.Message { // Deprecated: Use CapturePacket.ProtoReflect.Descriptor instead. func (*CapturePacket) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{89} + return file_daemon_proto_rawDescGZIP(), []int{90} } func (x *CapturePacket) GetData() []byte { @@ -6042,7 +6104,7 @@ type StartBundleCaptureRequest struct { func (x *StartBundleCaptureRequest) Reset() { *x = StartBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[91] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6054,7 +6116,7 @@ func (x *StartBundleCaptureRequest) String() string { func (*StartBundleCaptureRequest) ProtoMessage() {} func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[90] + mi := &file_daemon_proto_msgTypes[91] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6067,7 +6129,7 @@ func (x *StartBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StartBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{90} + return file_daemon_proto_rawDescGZIP(), []int{91} } func (x *StartBundleCaptureRequest) GetTimeout() *durationpb.Duration { @@ -6085,7 +6147,7 @@ type StartBundleCaptureResponse struct { func (x *StartBundleCaptureResponse) Reset() { *x = StartBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[92] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6097,7 +6159,7 @@ func (x *StartBundleCaptureResponse) String() string { func (*StartBundleCaptureResponse) ProtoMessage() {} func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[91] + mi := &file_daemon_proto_msgTypes[92] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6110,7 +6172,7 @@ func (x *StartBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StartBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StartBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{91} + return file_daemon_proto_rawDescGZIP(), []int{92} } type StopBundleCaptureRequest struct { @@ -6121,7 +6183,7 @@ type StopBundleCaptureRequest struct { func (x *StopBundleCaptureRequest) Reset() { *x = StopBundleCaptureRequest{} - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[93] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6133,7 +6195,7 @@ func (x *StopBundleCaptureRequest) String() string { func (*StopBundleCaptureRequest) ProtoMessage() {} func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[92] + mi := &file_daemon_proto_msgTypes[93] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6146,7 +6208,7 @@ func (x *StopBundleCaptureRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureRequest.ProtoReflect.Descriptor instead. func (*StopBundleCaptureRequest) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{92} + return file_daemon_proto_rawDescGZIP(), []int{93} } type StopBundleCaptureResponse struct { @@ -6157,7 +6219,7 @@ type StopBundleCaptureResponse struct { func (x *StopBundleCaptureResponse) Reset() { *x = StopBundleCaptureResponse{} - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[94] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6169,7 +6231,7 @@ func (x *StopBundleCaptureResponse) String() string { func (*StopBundleCaptureResponse) ProtoMessage() {} func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[93] + mi := &file_daemon_proto_msgTypes[94] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6182,7 +6244,7 @@ func (x *StopBundleCaptureResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use StopBundleCaptureResponse.ProtoReflect.Descriptor instead. func (*StopBundleCaptureResponse) Descriptor() ([]byte, []int) { - return file_daemon_proto_rawDescGZIP(), []int{93} + return file_daemon_proto_rawDescGZIP(), []int{94} } type PortInfo_Range struct { @@ -6195,7 +6257,7 @@ type PortInfo_Range struct { func (x *PortInfo_Range) Reset() { *x = PortInfo_Range{} - mi := &file_daemon_proto_msgTypes[95] + mi := &file_daemon_proto_msgTypes[96] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6207,7 +6269,7 @@ func (x *PortInfo_Range) String() string { func (*PortInfo_Range) ProtoMessage() {} func (x *PortInfo_Range) ProtoReflect() protoreflect.Message { - mi := &file_daemon_proto_msgTypes[95] + mi := &file_daemon_proto_msgTypes[96] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6348,7 +6410,7 @@ const file_daemon_proto_rawDesc = "" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"\xfe\b\n" + + "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" + "\x11GetConfigResponse\x12$\n" + "\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" + "\n" + @@ -6380,7 +6442,8 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" + "\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" + "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" + - "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\"\x92\x06\n" + + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" + + "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" + "\tPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + "\x06pubKey\x18\x02 \x01(\tR\x06pubKey\x12\x1e\n" + @@ -6695,7 +6758,9 @@ const file_daemon_proto_rawDesc = "" + "\x13GetFeaturesResponse\x12)\n" + "\x10disable_profiles\x18\x01 \x01(\bR\x0fdisableProfiles\x126\n" + "\x17disable_update_settings\x18\x02 \x01(\bR\x15disableUpdateSettings\x12)\n" + - "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"\x16\n" + + "\x10disable_networks\x18\x03 \x01(\bR\x0fdisableNetworks\"3\n" + + "\x19MDMManagedFieldsViolation\x12\x16\n" + + "\x06fields\x18\x01 \x03(\tR\x06fields\"\x16\n" + "\x14TriggerUpdateRequest\"M\n" + "\x15TriggerUpdateResponse\x12\x18\n" + "\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" + @@ -6851,7 +6916,7 @@ func file_daemon_proto_rawDescGZIP() []byte { } var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4) -var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 97) +var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 98) var file_daemon_proto_goTypes = []any{ (LogLevel)(0), // 0: daemon.LogLevel (ExposeProtocol)(0), // 1: daemon.ExposeProtocol @@ -6928,41 +6993,42 @@ var file_daemon_proto_goTypes = []any{ (*LogoutResponse)(nil), // 72: daemon.LogoutResponse (*GetFeaturesRequest)(nil), // 73: daemon.GetFeaturesRequest (*GetFeaturesResponse)(nil), // 74: daemon.GetFeaturesResponse - (*TriggerUpdateRequest)(nil), // 75: daemon.TriggerUpdateRequest - (*TriggerUpdateResponse)(nil), // 76: daemon.TriggerUpdateResponse - (*GetPeerSSHHostKeyRequest)(nil), // 77: daemon.GetPeerSSHHostKeyRequest - (*GetPeerSSHHostKeyResponse)(nil), // 78: daemon.GetPeerSSHHostKeyResponse - (*RequestJWTAuthRequest)(nil), // 79: daemon.RequestJWTAuthRequest - (*RequestJWTAuthResponse)(nil), // 80: daemon.RequestJWTAuthResponse - (*WaitJWTTokenRequest)(nil), // 81: daemon.WaitJWTTokenRequest - (*WaitJWTTokenResponse)(nil), // 82: daemon.WaitJWTTokenResponse - (*StartCPUProfileRequest)(nil), // 83: daemon.StartCPUProfileRequest - (*StartCPUProfileResponse)(nil), // 84: daemon.StartCPUProfileResponse - (*StopCPUProfileRequest)(nil), // 85: daemon.StopCPUProfileRequest - (*StopCPUProfileResponse)(nil), // 86: daemon.StopCPUProfileResponse - (*InstallerResultRequest)(nil), // 87: daemon.InstallerResultRequest - (*InstallerResultResponse)(nil), // 88: daemon.InstallerResultResponse - (*ExposeServiceRequest)(nil), // 89: daemon.ExposeServiceRequest - (*ExposeServiceEvent)(nil), // 90: daemon.ExposeServiceEvent - (*ExposeServiceReady)(nil), // 91: daemon.ExposeServiceReady - (*StartCaptureRequest)(nil), // 92: daemon.StartCaptureRequest - (*CapturePacket)(nil), // 93: daemon.CapturePacket - (*StartBundleCaptureRequest)(nil), // 94: daemon.StartBundleCaptureRequest - (*StartBundleCaptureResponse)(nil), // 95: daemon.StartBundleCaptureResponse - (*StopBundleCaptureRequest)(nil), // 96: daemon.StopBundleCaptureRequest - (*StopBundleCaptureResponse)(nil), // 97: daemon.StopBundleCaptureResponse - nil, // 98: daemon.Network.ResolvedIPsEntry - (*PortInfo_Range)(nil), // 99: daemon.PortInfo.Range - nil, // 100: daemon.SystemEvent.MetadataEntry - (*durationpb.Duration)(nil), // 101: google.protobuf.Duration - (*timestamppb.Timestamp)(nil), // 102: google.protobuf.Timestamp + (*MDMManagedFieldsViolation)(nil), // 75: daemon.MDMManagedFieldsViolation + (*TriggerUpdateRequest)(nil), // 76: daemon.TriggerUpdateRequest + (*TriggerUpdateResponse)(nil), // 77: daemon.TriggerUpdateResponse + (*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest + (*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse + (*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest + (*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse + (*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest + (*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse + (*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest + (*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse + (*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest + (*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse + (*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest + (*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse + (*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest + (*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent + (*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady + (*StartCaptureRequest)(nil), // 93: daemon.StartCaptureRequest + (*CapturePacket)(nil), // 94: daemon.CapturePacket + (*StartBundleCaptureRequest)(nil), // 95: daemon.StartBundleCaptureRequest + (*StartBundleCaptureResponse)(nil), // 96: daemon.StartBundleCaptureResponse + (*StopBundleCaptureRequest)(nil), // 97: daemon.StopBundleCaptureRequest + (*StopBundleCaptureResponse)(nil), // 98: daemon.StopBundleCaptureResponse + nil, // 99: daemon.Network.ResolvedIPsEntry + (*PortInfo_Range)(nil), // 100: daemon.PortInfo.Range + nil, // 101: daemon.SystemEvent.MetadataEntry + (*durationpb.Duration)(nil), // 102: google.protobuf.Duration + (*timestamppb.Timestamp)(nil), // 103: google.protobuf.Timestamp } var file_daemon_proto_depIdxs = []int32{ - 101, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 102, // 0: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 25, // 1: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus - 102, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp - 102, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp - 101, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration + 103, // 2: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp + 103, // 3: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp + 102, // 4: daemon.PeerState.latency:type_name -> google.protobuf.Duration 23, // 5: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo 20, // 6: daemon.FullStatus.managementState:type_name -> daemon.ManagementState 19, // 7: daemon.FullStatus.signalState:type_name -> daemon.SignalState @@ -6973,8 +7039,8 @@ var file_daemon_proto_depIdxs = []int32{ 55, // 12: daemon.FullStatus.events:type_name -> daemon.SystemEvent 24, // 13: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState 31, // 14: daemon.ListNetworksResponse.routes:type_name -> daemon.Network - 98, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry - 99, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range + 99, // 15: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry + 100, // 16: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range 32, // 17: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo 32, // 18: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo 33, // 19: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule @@ -6985,15 +7051,15 @@ var file_daemon_proto_depIdxs = []int32{ 52, // 24: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage 2, // 25: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity 3, // 26: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category - 102, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp - 100, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry + 103, // 27: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp + 101, // 28: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry 55, // 29: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent - 101, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration + 102, // 30: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration 68, // 31: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile 1, // 32: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol - 91, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady - 101, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration - 101, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration + 92, // 33: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady + 102, // 34: daemon.StartCaptureRequest.duration:type_name -> google.protobuf.Duration + 102, // 35: daemon.StartBundleCaptureRequest.timeout:type_name -> google.protobuf.Duration 30, // 36: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList 5, // 37: daemon.DaemonService.Login:input_type -> daemon.LoginRequest 7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest @@ -7013,9 +7079,9 @@ var file_daemon_proto_depIdxs = []int32{ 46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest 48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest 51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest - 92, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest - 94, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest - 96, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest + 93, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest + 95, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest + 97, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest 54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest 56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest 58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest @@ -7026,14 +7092,14 @@ var file_daemon_proto_depIdxs = []int32{ 69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest 71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest 73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest - 75, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest - 77, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest - 79, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest - 81, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest - 83, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest - 85, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest - 87, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest - 89, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest + 76, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest + 78, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest + 80, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest + 82, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest + 84, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest + 86, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest + 88, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest + 90, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest 6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse 8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse 10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse @@ -7052,9 +7118,9 @@ var file_daemon_proto_depIdxs = []int32{ 47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse 49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse 53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse - 93, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket - 95, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse - 97, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse + 94, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket + 96, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse + 98, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse 55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent 57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse 59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse @@ -7065,14 +7131,14 @@ var file_daemon_proto_depIdxs = []int32{ 70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse 72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse 74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse - 76, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse - 78, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse - 80, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse - 82, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse - 84, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse - 86, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse - 88, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse - 90, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent + 77, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse + 79, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse + 81, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse + 83, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse + 85, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse + 87, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse + 89, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse + 91, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent 76, // [76:115] is the sub-list for method output_type 37, // [37:76] is the sub-list for method input_type 37, // [37:37] is the sub-list for extension type_name @@ -7097,8 +7163,8 @@ func file_daemon_proto_init() { file_daemon_proto_msgTypes[54].OneofWrappers = []any{} file_daemon_proto_msgTypes[56].OneofWrappers = []any{} file_daemon_proto_msgTypes[67].OneofWrappers = []any{} - file_daemon_proto_msgTypes[75].OneofWrappers = []any{} - file_daemon_proto_msgTypes[86].OneofWrappers = []any{ + file_daemon_proto_msgTypes[76].OneofWrappers = []any{} + file_daemon_proto_msgTypes[87].OneofWrappers = []any{ (*ExposeServiceEvent_Ready)(nil), } type x struct{} @@ -7107,7 +7173,7 @@ func file_daemon_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)), NumEnums: 4, - NumMessages: 97, + NumMessages: 98, NumExtensions: 0, NumServices: 1, }, diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 6982e4a1c..265ab40bb 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -314,6 +314,13 @@ message GetConfigResponse { int32 sshJWTCacheTTL = 26; bool disable_ipv6 = 27; + + // mDMManagedFields lists the names of configuration keys whose value is + // currently enforced by an MDM policy. Names match mdm.Key* constants + // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should + // render the corresponding inputs as read-only and display a "managed + // by MDM" indicator. + repeated string mDMManagedFields = 28; } // PeerState contains the latest state of a peer @@ -733,6 +740,15 @@ message GetFeaturesResponse{ bool disable_networks = 3; } +// MDMManagedFieldsViolation is attached as a gRPC error detail on a +// FailedPrecondition status returned from SetConfig (and similar mutating +// RPCs) when the caller tries to modify one or more MDM-enforced fields. +// The fields list contains the offending key names; the entire request is +// rejected (no partial apply). +message MDMManagedFieldsViolation { + repeated string fields = 1; +} + message TriggerUpdateRequest {} message TriggerUpdateResponse { diff --git a/client/server/mdm.go b/client/server/mdm.go new file mode 100644 index 000000000..0da0ec5d1 --- /dev/null +++ b/client/server/mdm.go @@ -0,0 +1,419 @@ +package server + +import ( + "context" + "fmt" + "time" + + log "github.com/sirupsen/logrus" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/proto" +) + +// preSharedKeyRedactedSentinel is the value GetConfig returns in place +// of an actual PSK, so a UI that round-trips the field back to the +// daemon (via SetConfig / Login) can be distinguished from a deliberate +// override. Any incoming PSK that equals this sentinel is treated as +// a no-op echo, never as a conflict with the policy. +const preSharedKeyRedactedSentinel = "**********" + +// loadMDMPolicy is the indirection used by server handlers to read the +// active MDM policy. Tests override this to inject a fake policy. +var loadMDMPolicy = mdm.LoadPolicy + +// conflictCheck is a value-aware comparison between a single field in +// the incoming request and the corresponding MDM-enforced value. It +// runs only when the field was actually set in the request (presence +// already filtered upstream); ok=true reports the policy value, ok=false +// means the policy is silent on the key — both are treated as conflicts +// to be safe (an MDM key declared as managed must hold a value). +type conflictCheck struct { + key string + check func(*mdm.Policy) (match bool) +} + +// onMDMPolicyChange is invoked by the MDM reload ticker every time the +// OS-native managed-config store reports a diff vs the last observation. +// +// Restart sequence: +// 1. Cancel the active engine context (terminates connectWithRetryRuns). +// 2. Wait briefly for that goroutine to exit (giveUpChan is closed on exit). +// 3. Re-resolve Config from disk + MDM policy (Config.apply re-runs +// applyMDMPolicy with the freshly loaded Policy). +// 4. Spawn a fresh connectWithRetryRuns with the new context and config. +// 5. Broadcast a SystemEvent so any GUI / CLI subscriber (SubscribeEvents +// RPC) can refresh its cached config view without polling. +// +// The callback runs in the ticker's own goroutine. Ticker has already +// logged the per-key diff before invoking this hook. +func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error { + log.Warn("MDM policy changed; restarting engine to apply new configuration") + + // Hold s.mutex for the entire restart sequence (cancel + quiescence + // wait + re-spawn). Any concurrent Up/Down/Status arriving while + // MDM is restarting blocks on the Lock until we are done — they + // then observe the post-restart state coherently. This is safe + // because the connectWithRetryRuns goroutine no longer acquires + // s.mutex in its defer (intent vs. goroutine-alive concerns are + // fully separated; see the connectionGoroutineRunning helper). + s.mutex.Lock() + defer s.mutex.Unlock() + + if !s.clientRunning { + // The client is not running, so there's no engine to restart. + return nil + } + if s.actCancel != nil { + s.actCancel() + } + + // Wait for previous connectWithRetryRuns to exit so we don't end up + // with two goroutines fighting over the same status recorder + engine. + // The teardown engages a fan-out of engine goroutines (peer workers, + // signal handler, route manager, ...). close(clientGiveUpChan) + // happens in the function-scope defer of connectWithRetryRuns, on + // every exit path (ctx cancel, backoff exhausted, panic) — see the + // defer in server.go. + if s.clientGiveUpChan != nil { + select { + case <-s.clientGiveUpChan: + case <-time.After(10 * time.Second): + return fmt.Errorf("failed to restart the engine due to timeout") + } + } + + if err := s.restartEngineForMDMLocked(); err != nil { + log.Errorf("MDM restart failed: %v", err) + return err + } + + // publishConfigChangedEvent has already fired inside + // restartEngineForMDMLocked with source="mdm". Emit an MDM-specific + // user-visible toast so the operator knows their IT policy was + // applied (UserMessage != "" triggers the GUI notifier). + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + "MDM policy applied", + "NetBird configuration was updated by your IT policy.", + map[string]string{"source": "mdm", "type": "policy_applied"}, + ) + return nil +} + +// publishConfigChangedEvent broadcasts a SystemEvent informing any active +// SubscribeEvents subscriber (typically the GUI tray) that the daemon's +// effective Config has been replaced and any cached client-side view +// should be refreshed. Callers pass a stable `source` label so the GUI +// can distinguish a startup spawn from a user-triggered Up or an +// MDM-driven restart. Reusing the SYSTEM category keeps the proto enum +// stable; metadata.type="config_changed" routes to the GUI's refresh +// handler. UserMessage is left empty so the system tray does not toast +// for every internal restart; the MDM path emits a separate +// "policy_applied" event (with UserMessage) for that purpose. +func (s *Server) publishConfigChangedEvent(source string) { + if s.statusRecorder == nil { + return + } + s.statusRecorder.PublishEvent( + proto.SystemEvent_INFO, + proto.SystemEvent_SYSTEM, + fmt.Sprintf("daemon config changed (source=%s)", source), + "", + map[string]string{ + "source": source, + "type": "config_changed", + }, + ) +} + +// restartEngineForMDMLocked re-resolves the active profile config +// (re-running applyMDMPolicy via Config.apply) and re-spawns +// connectWithRetryRuns. Mirrors the tail of Server.Start so a runtime +// MDM change behaves identically to a fresh boot under the new policy. +// +// MUST be called with s.mutex held — onMDMPolicyChange holds the lock +// for the entire restart sequence (cancel + quiescence wait + re-spawn) +// so concurrent Up/Down/Status RPCs observe a coherent post-restart +// state. +func (s *Server) restartEngineForMDMLocked() error { + activeProf, err := s.profileManager.GetActiveProfileState() + if err != nil { + return fmt.Errorf("get active profile state: %w", err) + } + config, _, err := s.getConfig(activeProf) + if err != nil { + return fmt.Errorf("get active profile config: %w", err) + } + + s.config = config + s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String()) + s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive) + s.statusRecorder.UpdateLazyConnection(config.LazyConnectionEnabled) + + ctx, cancel := context.WithCancel(s.rootCtx) + s.actCancel = cancel + s.clientRunning = true + s.clientRunningChan = make(chan struct{}) + s.clientGiveUpChan = make(chan struct{}) + log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config") + go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) + s.publishConfigChangedEvent("mdm") + return nil +} + +// conflictBool builds a conflictCheck for a boolean MDM key. If p is nil +// the field is treated as matching (no override requested); otherwise the +// check returns true only when the policy contains the key and its +// boolean value equals *p. +func conflictBool(key string, p *bool) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if p == nil { + return true // absent → match by definition + } + want, ok := pol.GetBool(key) + return ok && want == *p + }, + } +} + +// conflictString builds a conflictCheck for a string MDM key. An empty +// `got` is treated as "field not set" (no override requested); otherwise +// the check returns true only when the policy contains the key and its +// value equals got. +func conflictString(key, got string) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if got == "" { + return true + } + want, ok := pol.GetString(key) + return ok && want == got + }, + } +} + +// conflictInt64 builds a conflictCheck for an integer MDM key. If p is +// nil the field is treated as matching; otherwise the check returns +// true only when the policy contains the key and its int value equals *p. +func conflictInt64(key string, p *int64) conflictCheck { + return conflictCheck{ + key: key, + check: func(pol *mdm.Policy) bool { + if p == nil { + return true + } + want, ok := pol.GetInt(key) + return ok && want == *p + }, + } +} + +// resolveConflicts walks the per-field checks against the active MDM +// policy and returns the names of keys whose requested value diverges +// from the policy-enforced value. Keys not present in the policy are +// skipped silently (the gate fires only for keys the admin has +// actually pushed). Returns nil for an empty policy. +func resolveConflicts(policy *mdm.Policy, checks []conflictCheck) []string { + if policy.IsEmpty() { + return nil + } + var conflicts []string + for _, c := range checks { + if !policy.HasKey(c.key) { + continue + } + if !c.check(policy) { + conflicts = append(conflicts, c.key) + } + } + return conflicts +} + +// mdmManagedFieldConflicts returns the names of MDM-managed keys whose +// requested value in the SetConfigRequest differs from the MDM-enforced +// value. A field set to the same value the policy already enforces is +// treated as a no-op echo (the GUI tray sends a full Config snapshot on +// every toggle, so most fields in a typical request match the policy +// exactly and must NOT be flagged as conflicts). The redacted PSK +// sentinel ("**********") returned by GetConfig is recognised and +// treated as no-op so the UI can safely round-trip it. +func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) []string { + if msg == nil { + return nil + } + + // PSK round-trip echo: collapse the sentinel to empty so the + // shared check treats it as "field not set". + pskGot := "" + if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != preSharedKeyRedactedSentinel { + pskGot = *msg.OptionalPreSharedKey + } + + return resolveConflicts(policy, []conflictCheck{ + conflictString(mdm.KeyManagementURL, msg.ManagementUrl), + conflictString(mdm.KeyPreSharedKey, pskGot), + conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + }) +} + +// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest +// carries ANY field that would actually mutate the persisted config. +// The CLI builds a SetConfigRequest unconditionally on every +// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain +// `netbird up` produces a request with every field at its zero value; +// the gate must skip such no-op invocations or it would always fire +// even when the user did not pass any --flag. Returns false on a nil +// msg; true when any management/admin URL, PSK, DNS/NAT list+clean +// flag, interface/port/MTU, or any optional bool/duration field is set. +func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { + if msg == nil { + return false + } + return msg.ManagementUrl != "" || + msg.AdminURL != "" || + msg.OptionalPreSharedKey != nil || + len(msg.CustomDNSAddress) > 0 || + len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs || + len(msg.ExtraIFaceBlacklist) > 0 || + len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || + msg.DnsRouteInterval != nil || + msg.RosenpassEnabled != nil || + msg.RosenpassPermissive != nil || + msg.InterfaceName != nil || + msg.WireguardPort != nil || + msg.Mtu != nil || + msg.DisableAutoConnect != nil || + msg.ServerSSHAllowed != nil || + msg.NetworkMonitor != nil || + msg.DisableClientRoutes != nil || + msg.DisableServerRoutes != nil || + msg.DisableDns != nil || + msg.DisableFirewall != nil || + msg.BlockLanAccess != nil || + msg.DisableNotifications != nil || + msg.LazyConnectionEnabled != nil || + msg.BlockInbound != nil || + msg.DisableIpv6 != nil || + msg.EnableSSHRoot != nil || + msg.EnableSSHSFTP != nil || + msg.EnableSSHLocalPortForwarding != nil || + msg.EnableSSHRemotePortForwarding != nil || + msg.DisableSSHAuth != nil || + msg.SshJWTCacheTTL != nil +} + +// loginRequestHasConfigOverrides reports whether the LoginRequest +// carries ANY field that would mutate persisted daemon configuration +// (as opposed to pure-auth fields like setupKey, hostname, hint, +// profileName, username). Used by the Login handler to decide whether +// the `--disable-update-settings` / MDM gates must run: a re-auth that +// changes nothing about the configuration is always allowed. +func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { + if msg == nil { + return false + } + return msg.ManagementUrl != "" || + msg.AdminURL != "" || + msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + msg.OptionalPreSharedKey != nil || + len(msg.CustomDNSAddress) > 0 || + len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs || + msg.RosenpassEnabled != nil || + msg.InterfaceName != nil || + msg.WireguardPort != nil || + msg.DisableAutoConnect != nil || + msg.ServerSSHAllowed != nil || + msg.RosenpassPermissive != nil || + len(msg.ExtraIFaceBlacklist) > 0 || + msg.NetworkMonitor != nil || + msg.DnsRouteInterval != nil || + msg.DisableClientRoutes != nil || + msg.DisableServerRoutes != nil || + msg.DisableDns != nil || + msg.DisableFirewall != nil || + msg.BlockLanAccess != nil || + msg.DisableNotifications != nil || + len(msg.DnsLabels) > 0 || msg.CleanDNSLabels || + msg.LazyConnectionEnabled != nil || + msg.BlockInbound != nil +} + +// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the +// LoginRequest surface. Same value-aware semantics: a field set to the +// MDM-enforced value is a no-op echo, not a conflict; only a divergent +// value is flagged. PSK has two proto fields — PreSharedKey (deprecated) +// and OptionalPreSharedKey (current); either route trips the gate if it +// diverges from the MDM-enforced PSK. OptionalPreSharedKey wins when +// both are set; the redaction sentinel ("**********") is accepted as +// a no-op echo. +func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []string { + if msg == nil { + return nil + } + + // Collapse the two PSK fields + the redaction sentinel down to a + // single "got" string the shared check can compare against the + // policy: OptionalPreSharedKey wins if set; PreSharedKey (deprecated) + // is the fallback; sentinel echo is treated as "field not set". + pskGot := "" + if msg.OptionalPreSharedKey != nil { + pskGot = *msg.OptionalPreSharedKey + } else if msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login + pskGot = msg.PreSharedKey //nolint:staticcheck // SA1019 + } + if pskGot == preSharedKeyRedactedSentinel { + pskGot = "" + } + + return resolveConflicts(policy, []conflictCheck{ + conflictString(mdm.KeyManagementURL, msg.ManagementUrl), + conflictString(mdm.KeyPreSharedKey, pskGot), + conflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled), + conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), + conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), + conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), + conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), + conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), + conflictInt64(mdm.KeyWireguardPort, msg.WireguardPort), + }) +} + +// rejectMDMManagedFieldConflicts returns a FailedPrecondition gRPC error +// with an MDMManagedFieldsViolation detail when any of the requested +// fields tries to change an MDM-enforced value to something else, and +// nil otherwise. The whole request is rejected on any conflict; non- +// conflicting fields in the same request are not applied either (no +// partial apply). +func rejectMDMManagedFieldConflicts(conflicts []string) error { + if len(conflicts) == 0 { + return nil + } + log.Warnf("MDM rejected request: tried to modify %d managed key(s): %v", + len(conflicts), conflicts) + st := gstatus.New( + codes.FailedPrecondition, + fmt.Sprintf("fields managed by MDM cannot be modified: %v", conflicts), + ) + detailed, err := st.WithDetails(&proto.MDMManagedFieldsViolation{Fields: conflicts}) + if err != nil { + // Detail attachment is best-effort; fall back to the plain status + // so the caller still gets a usable FailedPrecondition. + return st.Err() + } + return detailed.Err() +} diff --git a/client/server/network.go b/client/server/network.go index 12cefbd9c..7a3c08f2e 100644 --- a/client/server/network.go +++ b/client/server/network.go @@ -30,7 +30,7 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro s.mutex.Lock() defer s.mutex.Unlock() - if s.networksDisabled { + if s.checkNetworksDisabled() { return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled) } @@ -143,7 +143,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ s.mutex.Lock() defer s.mutex.Unlock() - if s.networksDisabled { + if s.checkNetworksDisabled() { return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled) } @@ -195,7 +195,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe s.mutex.Lock() defer s.mutex.Unlock() - if s.networksDisabled { + if s.checkNetworksDisabled() { return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled) } diff --git a/client/server/server.go b/client/server/server.go index 397fb37e4..32daf7718 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -24,6 +24,7 @@ import ( "github.com/netbirdio/netbird/client/internal/expose" "github.com/netbirdio/netbird/client/internal/profilemanager" sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/system" mgm "github.com/netbirdio/netbird/shared/management/client" "github.com/netbirdio/netbird/shared/management/domain" @@ -71,7 +72,13 @@ type Server struct { mutex sync.Mutex config *profilemanager.Config proto.UnimplementedDaemonServiceServer - clientRunning bool // protected by mutex + // clientRunning tracks "the daemon wants to be connected" — set true by + // Start / Up, cleared by Down / Logout. Persists across retry + // loops, signal disconnects, and ErrResetConnection cycles. NOT + // changed by connectWithRetryRuns goroutine exit — for that + // (goroutine-still-alive) check, see connectionGoroutineRunning() which + // derives from clientGiveUpChan close state. Protected by s.mutex. + clientRunning bool clientRunningChan chan struct{} clientGiveUpChan chan struct{} // closed when connectWithRetryRuns goroutine exits @@ -98,6 +105,11 @@ type Server struct { sleepHandler *sleephandler.SleepHandler + // mdmTicker periodically re-reads the OS-native MDM policy and triggers + // an engine restart when the policy changes. Launched once by Start; + // stopped by the rootCtx cancellation. + mdmTicker *mdm.Ticker + updateManager *updater.Manager jwtCache *jwtCache @@ -155,6 +167,17 @@ func (s *Server) Start() error { s.updateManager.CheckUpdateSuccess(s.rootCtx) } + // MDM policy reload ticker: every minute the desktop daemon re-reads + // the OS-native managed-config store and, on diff vs the previous + // observation, cancels the active engine context so connectWithRetry- + // Runs re-resolves Config (re-running profilemanager.Config.apply which + // applies the freshly-read MDM policy as the last layer) and brings + // the engine back with the new values. + if s.mdmTicker == nil { + s.mdmTicker = mdm.NewTicker(mdm.DefaultReloadInterval) + go s.mdmTicker.Run(s.rootCtx, s.onMDMPolicyChange) + } + // if current state contains any error, return it // in all other cases we can continue execution only if status is idle and up command was // not in the progress or already successfully established connection. @@ -213,17 +236,27 @@ func (s *Server) Start() error { s.clientRunningChan = make(chan struct{}) s.clientGiveUpChan = make(chan struct{}) go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) + s.publishConfigChangedEvent("startup") return nil } // connectWithRetryRuns runs the client connection with a backoff strategy where we retry the operation as additional // mechanism to keep the client connected even when the connection is lost. // we cancel retry if the client receive a stop or down command, or if disable auto connect is configured. +// +// The goroutine's exit is signalled to the daemon via close(giveUpChan) +// — placed in the function-scope defer so every return path (panic, +// DisableAutoConnect early-exit, backoff exhausted, ctx cancel) closes +// it. Callers that need to observe "is the goroutine still alive?" use +// Server.connectionGoroutineRunning() which non-blockingly checks the close state +// of clientGiveUpChan. The defer does NOT touch s.mutex; the daemon's +// "intent" (clientRunning) is maintained by the RPC handlers, not by this +// goroutine. func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profilemanager.Config, statusRecorder *peer.Status, runningChan chan struct{}, giveUpChan chan struct{}) { defer func() { - s.mutex.Lock() - s.clientRunning = false - s.mutex.Unlock() + if giveUpChan != nil { + close(giveUpChan) + } }() if s.config.DisableAutoConnect { @@ -269,9 +302,26 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil if err := backoff.Retry(runOperation, backOff); err != nil { log.Errorf("operation failed: %v", err) } + // giveUpChan is closed by the function-scope defer. +} - if giveUpChan != nil { - close(giveUpChan) +// connectionGoroutineRunning reports whether the connectWithRetryRuns goroutine is +// still running. Returns false when no goroutine has ever been started +// AND when the most recent one has already closed clientGiveUpChan on +// exit (whether due to ctx cancel, DisableAutoConnect single-shot +// completion, or backoff retry exhaustion). +// +// MUST be called with s.mutex held — accesses s.clientGiveUpChan which +// is written by Start/Up under the same lock. +func (s *Server) connectionGoroutineRunning() bool { + if s.clientGiveUpChan == nil { + return false + } + select { + case <-s.clientGiveUpChan: + return false + default: + return true } } @@ -304,54 +354,85 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques s.mutex.Lock() defer s.mutex.Unlock() - if s.checkUpdateSettingsDisabled() { - return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) + // Skip the update-settings gate when the request carries no actual + // overrides: the CLI builds a SetConfigRequest unconditionally on + // every `netbird up` (setupSetConfigReq in cmd/up.go), so a plain + // `netbird up` would otherwise always trip the gate and surface a + // misleading "setConfig method is not available" warning, even when + // the user did not pass any config flag. + if setConfigRequestHasConfigOverrides(msg) { + if s.checkUpdateSettingsDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) + } } + // MDM gate: refuse the whole request if any of its fields is enforced + // by the active MDM policy. The error carries an MDMManagedFields- + // Violation detail listing the offending key names. Non-conflicting + // fields in the same request are not applied either. + policy := loadMDMPolicy() + if err := rejectMDMManagedFieldConflicts(mdmManagedFieldConflicts(msg, policy)); err != nil { + return nil, err + } + + config, err := setConfigInputFromRequest(msg) + if err != nil { + return nil, err + } + + if _, err := profilemanager.UpdateConfig(config); err != nil { + log.Errorf("failed to update profile config: %v", err) + return nil, fmt.Errorf("failed to update profile config: %w", err) + } + + return &proto.SetConfigResponse{}, nil +} + +// setConfigInputFromRequest translates a SetConfigRequest into the +// profilemanager.ConfigInput that profilemanager.UpdateConfig consumes. +// Pure mapping with no business logic beyond presence-aware copying of +// optional fields and the "empty / clean" semantics for the two slice +// fields (DNS labels, NAT external IPs). Extracted from SetConfig to +// keep the handler's cognitive complexity below the SonarCube +// threshold; the body is intentionally linear because each proto +// field is its own optional case. Returns the resolved ConfigInput +// and a non-nil error only when the active profile file path cannot +// be determined. +func setConfigInputFromRequest(msg *proto.SetConfigRequest) (profilemanager.ConfigInput, error) { + var config profilemanager.ConfigInput + profState := profilemanager.ActiveProfileState{ Name: msg.ProfileName, Username: msg.Username, } - profPath, err := profState.FilePath() if err != nil { log.Errorf("failed to get active profile file path: %v", err) - return nil, fmt.Errorf("failed to get active profile file path: %w", err) + return config, fmt.Errorf("failed to get active profile file path: %w", err) } - - var config profilemanager.ConfigInput - config.ConfigPath = profPath if msg.ManagementUrl != "" { config.ManagementURL = msg.ManagementUrl } - if msg.AdminURL != "" { config.AdminURL = msg.AdminURL } - if msg.InterfaceName != nil { config.InterfaceName = msg.InterfaceName } - if msg.WireguardPort != nil { wgPort := int(*msg.WireguardPort) config.WireguardPort = &wgPort } - - if msg.OptionalPreSharedKey != nil { - if *msg.OptionalPreSharedKey != "" { - config.PreSharedKey = msg.OptionalPreSharedKey - } + if msg.OptionalPreSharedKey != nil && *msg.OptionalPreSharedKey != "" { + config.PreSharedKey = msg.OptionalPreSharedKey } if msg.CleanDNSLabels { config.DNSLabels = domain.List{} - } else if msg.DnsLabels != nil { - dnsLabels := domain.FromPunycodeList(msg.DnsLabels) - config.DNSLabels = dnsLabels + config.DNSLabels = domain.FromPunycodeList(msg.DnsLabels) } if msg.CleanNATExternalIPs { @@ -364,7 +445,6 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques if string(msg.CustomDNSAddress) == "empty" { config.CustomDNSAddress = []byte{} } - config.ExtraIFaceBlackList = msg.ExtraIFaceBlacklist if msg.DnsRouteInterval != nil { @@ -397,22 +477,31 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques ttl := int(*msg.SshJWTCacheTTL) config.SSHJWTCacheTTL = &ttl } - if msg.Mtu != nil { mtu := uint16(*msg.Mtu) config.MTU = &mtu } - - if _, err := profilemanager.UpdateConfig(config); err != nil { - log.Errorf("failed to update profile config: %v", err) - return nil, fmt.Errorf("failed to update profile config: %w", err) - } - - return &proto.SetConfigResponse{}, nil + return config, nil } // Login uses setup key to prepare configuration for the daemon. func (s *Server) Login(callerCtx context.Context, msg *proto.LoginRequest) (*proto.LoginResponse, error) { + // Config-override gates. LoginRequest carries the same surface as + // SetConfigRequest (managementUrl, PSK, ssh/rosenpass/port toggles, + // ...), so the same protections must apply. Without these the CLI + // command `netbird up --management-url=X` (which falls through to + // Login when SetConfig is rejected — see cmd/up.go) would silently + // bypass `--disable-update-settings` and any MDM policy. + if loginRequestHasConfigOverrides(msg) { + if s.checkUpdateSettingsDisabled() { + return nil, gstatus.Errorf(codes.Unavailable, errUpdateSettingsDisabled) + } + policy := loadMDMPolicy() + if err := rejectMDMManagedFieldConflicts(loginRequestMDMConflicts(msg, policy)); err != nil { + return nil, err + } + } + s.mutex.Lock() if s.actCancel != nil { s.actCancel() @@ -652,7 +741,13 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin // Up starts engine work in the daemon. func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpResponse, error) { s.mutex.Lock() - if s.clientRunning { + // clientRunning is the daemon-intent flag (set by previous Up/Start, cleared + // by Down). connectionGoroutineRunning() reports whether the previous retry-loop + // goroutine is still trying. When intent is up AND goroutine is alive, + // the existing engine is on the job — just wait for it. When intent + // is up but the goroutine has given up (backoff exhausted) OR when + // intent is down, fall through to spawn a fresh retry loop. + if s.clientRunning && s.connectionGoroutineRunning() { state := internal.CtxGetState(s.rootCtx) status, err := state.Status() if err != nil { @@ -743,6 +838,7 @@ func (s *Server) Up(callerCtx context.Context, msg *proto.UpRequest) (*proto.UpR s.clientGiveUpChan = make(chan struct{}) go s.connectWithRetryRuns(ctx, s.config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan) + s.publishConfigChangedEvent("up_rpc") s.mutex.Unlock() return s.waitForUp(callerCtx) @@ -871,6 +967,12 @@ func (s *Server) cleanupConnection() error { return ErrServiceNotUp } + // Daemon intent flips to "down" — all callers (Down RPC, + // Logout RPC handlers) tear down the connection because the user + // explicitly asked for it. MDM restart does NOT go through this + // path, so its clientRunning stays true. + s.clientRunning = false + // Capture the engine reference before cancelling the context. // After actCancel(), the connectWithRetryRuns goroutine wakes up // and sets connectClient.engine = nil, causing connectClient.Stop() @@ -1074,10 +1176,14 @@ func (s *Server) Status( msg *proto.StatusRequest, ) (*proto.StatusResponse, error) { s.mutex.Lock() - clientRunning := s.clientRunning + // Only wait if the retry-loop goroutine is alive and making + // progress. clientRunning=true with connectionGoroutineRunning=false means the + // backoff has given up — there is nothing to wait for; let the + // caller observe the failed status directly. + alive := s.connectionGoroutineRunning() s.mutex.Unlock() - if msg.WaitForReady != nil && *msg.WaitForReady && clientRunning { + if msg.WaitForReady != nil && *msg.WaitForReady && alive { state := internal.CtxGetState(s.rootCtx) status, err := state.Status() if err != nil { @@ -1548,6 +1654,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p EnableSSHRemotePortForwarding: enableSSHRemotePortForwarding, DisableSSHAuth: disableSSHAuth, SshJWTCacheTTL: sshJWTCacheTTL, + MDMManagedFields: cfg.Policy().ManagedKeys(), }, nil } @@ -1646,7 +1753,7 @@ func (s *Server) GetFeatures(ctx context.Context, msg *proto.GetFeaturesRequest) features := &proto.GetFeaturesResponse{ DisableProfiles: s.checkProfilesDisabled(), DisableUpdateSettings: s.checkUpdateSettingsDisabled(), - DisableNetworks: s.networksDisabled, + DisableNetworks: s.checkNetworksDisabled(), } return features, nil @@ -1668,22 +1775,46 @@ func (s *Server) connect(ctx context.Context, config *profilemanager.Config, sta return nil } +// MDM authority: when the platform-native MDM source sets a kill switch +// key (regardless of true/false value), that value wins. The CLI flag +// supplied at service install time is the fallback used only when the +// MDM source is silent on the key. This honors the "MDM decides +// everything" semantic agreed for NET-1214 — an admin pushing +// disableX=false via MDM explicitly re-enables the feature even on a +// box installed with --disable-X. func (s *Server) checkProfilesDisabled() bool { - // Check if the environment variable is set to disable profiles - if s.profilesDisabled { - return true + if s.config != nil { + if v, ok := s.config.Policy().GetBool(mdm.KeyDisableProfiles); ok { + return v + } } + return s.profilesDisabled +} - return false +// checkNetworksDisabled reports whether the networks/exit-node feature +// is disabled on this daemon instance. Resolved MDM-first: when the +// active policy declares mdm.KeyDisableNetworks the policy value wins +// (regardless of true/false), so an admin can re-enable the feature +// via MDM even on a host that was installed with --disable-networks. +// Falls back to the s.networksDisabled CLI flag when the policy is +// silent on the key. Mirrors checkProfilesDisabled and +// checkUpdateSettingsDisabled. +func (s *Server) checkNetworksDisabled() bool { + if s.config != nil { + if v, ok := s.config.Policy().GetBool(mdm.KeyDisableNetworks); ok { + return v + } + } + return s.networksDisabled } func (s *Server) checkUpdateSettingsDisabled() bool { - // Check if the environment variable is set to disable profiles - if s.updateSettingsDisabled { - return true + if s.config != nil { + if v, ok := s.config.Policy().GetBool(mdm.KeyDisableUpdateSettings); ok { + return v + } } - - return false + return s.updateSettingsDisabled } func (s *Server) startUpdateManagerForGUI() { diff --git a/client/server/server_connect_test.go b/client/server/server_connect_test.go index faea7da39..0c6e03a4a 100644 --- a/client/server/server_connect_test.go +++ b/client/server/server_connect_test.go @@ -101,6 +101,7 @@ func TestCleanupConnection_ClearsConnectClient(t *testing.T) { require.NoError(t, err) assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup") + assert.False(t, s.clientRunning, "clientRunning should be cleared after cleanup (intent = down)") } // TestCleanState_NilConnectClient validates that CleanState doesn't panic @@ -144,17 +145,20 @@ func TestDownThenUp_StaleRunningChan(t *testing.T) { _, cancel := context.WithCancel(context.Background()) s.actCancel = cancel - // Simulate Down(): cleanupConnection sets connectClient = nil + // Simulate Down(): cleanupConnection sets connectClient = nil and + // flips clientRunning to false (intent = down). The connectionGoroutineRunning state + // remains independent of intent — derived from clientGiveUpChan. s.mutex.Lock() err := s.cleanupConnection() s.mutex.Unlock() require.NoError(t, err) - // After cleanup: connectClient is nil, clientRunning still true - // (goroutine hasn't exited yet) + // After cleanup: connectClient is nil, clientRunning is false (intent + // cleared by cleanupConnection), connectionGoroutineRunning may still be true + // (goroutine teardown is independent of the intent flag). s.mutex.Lock() assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup") - assert.True(t, s.clientRunning, "clientRunning still true until goroutine exits") + assert.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)") s.mutex.Unlock() // waitForUp() returns immediately due to stale closed clientRunningChan diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go new file mode 100644 index 000000000..53232c70d --- /dev/null +++ b/client/server/setconfig_mdm_test.go @@ -0,0 +1,198 @@ +package server + +import ( + "context" + "os/user" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + gstatus "google.golang.org/grpc/status" + + "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" + "github.com/netbirdio/netbird/client/proto" +) + +// withMDMPolicy temporarily overrides the server-package loadMDMPolicy hook +// so SetConfig observes the supplied Policy. Restores the original loader +// at test cleanup. +func withMDMPolicy(t *testing.T, policy *mdm.Policy) { + t.Helper() + prev := loadMDMPolicy + loadMDMPolicy = func() *mdm.Policy { return policy } + t.Cleanup(func() { loadMDMPolicy = prev }) +} + +// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved: +// overrides profilemanager paths to a temp dir, seeds a profile, sets it +// active, and constructs a Server instance. Returns the constructed server +// plus context + profile name + username + cfgPath for the seeded profile. +func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profName, username, cfgPath string) { + t.Helper() + tempDir := t.TempDir() + + origDefaultProfileDir := profilemanager.DefaultConfigPathDir + origDefaultConfigPath := profilemanager.DefaultConfigPath + origActiveProfileStatePath := profilemanager.ActiveProfileStatePath + profilemanager.ConfigDirOverride = tempDir + profilemanager.DefaultConfigPathDir = tempDir + profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json" + profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json") + t.Cleanup(func() { + profilemanager.DefaultConfigPathDir = origDefaultProfileDir + profilemanager.ActiveProfileStatePath = origActiveProfileStatePath + profilemanager.DefaultConfigPath = origDefaultConfigPath + profilemanager.ConfigDirOverride = "" + }) + + currUser, err := user.Current() + require.NoError(t, err) + + profName = "test-profile-mdm" + cfgPath = filepath.Join(tempDir, profName+".json") + + _, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{ + ConfigPath: cfgPath, + ManagementURL: "https://api.netbird.io:443", + }) + require.NoError(t, err) + + pm := profilemanager.ServiceManager{} + require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{ + Name: profName, + Username: currUser.Username, + })) + + ctx = context.Background() + s = New(ctx, "console", "", false, false, false, false) + return s, ctx, profName, currUser.Username, cfgPath +} + +// extractViolation pulls the MDMManagedFieldsViolation detail from a +// FailedPrecondition error. Fails the test if absent or malformed. +func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation { + t.Helper() + require.Error(t, err) + st, ok := gstatus.FromError(err) + require.True(t, ok, "error must be a gRPC status: %v", err) + require.Equal(t, codes.FailedPrecondition, st.Code(), "expected FailedPrecondition, got %s", st.Code()) + for _, d := range st.Details() { + if v, ok := d.(*proto.MDMManagedFieldsViolation); ok { + return v + } + } + t.Fatalf("MDMManagedFieldsViolation detail not found on status; details: %v", st.Details()) + return nil +} + +func TestSetConfig_MDMReject_SingleField(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://user.tried.this.com:443", + }) + + v := extractViolation(t, err) + assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields()) +} + +func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + mdm.KeyBlockInbound: true, + mdm.KeyRosenpassEnabled: true, + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + blockInbound := false + rosenpassEnabled := false + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://user.tried.this.com:443", + BlockInbound: &blockInbound, + RosenpassEnabled: &rosenpassEnabled, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{ + mdm.KeyManagementURL, + mdm.KeyBlockInbound, + mdm.KeyRosenpassEnabled, + }, v.GetFields()) +} + +func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) { + // MDM enforces ManagementURL only; user request touches both the + // enforced field AND a non-enforced field (RosenpassEnabled). + // The whole request must be rejected — non-conflicting fields are not + // applied either. + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + })) + + s, ctx, profName, username, cfgPath := setupServerWithProfile(t) + + rosenpassEnabled := true + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://user.tried.this.com:443", + RosenpassEnabled: &rosenpassEnabled, + }) + + v := extractViolation(t, err) + assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields()) + + // Confirm RosenpassEnabled was NOT applied even though it was not + // in the conflict list: the request was rejected as a whole. + reloaded, err := profilemanager.GetConfig(cfgPath) + require.NoError(t, err) + assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected") +} + +func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) { + // MDM enforces ManagementURL but the user only writes RosenpassEnabled. + // Request must succeed. + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyManagementURL: "https://mdm.example.com:443", + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + rosenpassEnabled := true + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + RosenpassEnabled: &rosenpassEnabled, + }) + + require.NoError(t, err) + require.NotNil(t, resp) +} + +func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) { + // No MDM policy active: any field can be written. + withMDMPolicy(t, mdm.NewPolicy(nil)) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ManagementUrl: "https://user.changed.url.com:443", + }) + + require.NoError(t, err) + require.NotNil(t, resp) +} diff --git a/client/ui/client_ui.go b/client/ui/client_ui.go index c4b644354..5814ad9b4 100644 --- a/client/ui/client_ui.go +++ b/client/ui/client_ui.go @@ -38,6 +38,7 @@ import ( "github.com/netbirdio/netbird/client/iface" "github.com/netbirdio/netbird/client/internal" "github.com/netbirdio/netbird/client/internal/profilemanager" + "github.com/netbirdio/netbird/client/mdm" "github.com/netbirdio/netbird/client/proto" "github.com/netbirdio/netbird/client/ui/desktop" "github.com/netbirdio/netbird/client/ui/event" @@ -56,8 +57,22 @@ const ( const ( censoredPreSharedKey = "**********" maxSSHJWTCacheTTL = 86_400 // 24 hours in seconds + // mdmFieldSuffix is appended to plain-text Entry widgets in the + // advanced Settings window when the underlying field is enforced + // by MDM, so the user sees the lock indicator inline next to the + // value. Stripped before any read site that feeds the value back + // into a SetConfig request (saveSettings / parseNumericSettings). + mdmFieldSuffix = " (MDM)" ) +// main is the entry point for the UI tray/client binary. Parses CLI +// flags, initialises logging, builds the Fyne application and tray +// icons, and constructs the service client (which may open a +// requested UI window). When a window-mode flag is set the Fyne event +// loop runs and main returns; otherwise main enforces single-instance +// behaviour (signalling an existing instance to show its window when +// present), sets up signal handling + default fonts, and runs the +// system tray loop. func main() { flags := parseFlags() @@ -315,9 +330,13 @@ type serviceClient struct { isUpdateIconActive bool isEnforcedUpdate bool lastNotifiedVersion string - settingsEnabled bool profilesEnabled bool networksEnabled bool + // networksMenuEnabled caches the last applied enabled-state of the + // mNetworks + mExitNode submenu items. Combines features.DisableNetworks + // AND s.connected — both must be true for the menus to be active. + // Zero value (false) matches the Disable() call at AddMenuItem time. + networksMenuEnabled bool showNetworks bool wNetworks fyne.Window wProfiles fyne.Window @@ -336,6 +355,13 @@ type serviceClient struct { updateContextCancel context.CancelFunc connectCancel context.CancelFunc + + // mdmManagedFields caches the names of MDM-enforced policy keys + // surfaced by the daemon in GetConfigResponse. Each refresh of + // daemon config (loadSettings, getSrvConfig, config_changed event) + // updates this set and re-applies the lock/badge to the affected + // menu items and settings-form widgets. + mdmManagedFields map[string]bool } type menuHandler struct { @@ -441,15 +467,12 @@ func (s *serviceClient) updateIcon() { } func (s *serviceClient) showSettingsUI() { - // Check if update settings are disabled by daemon - features, err := s.getFeatures() - if err != nil { - log.Errorf("failed to get features from daemon: %v", err) - // Continue with default behavior if features can't be retrieved - } else if features != nil && features.DisableUpdateSettings { - log.Warn("Update settings are disabled by daemon") - return - } + // DisableUpdateSettings no longer gates the window from opening: + // the daemon blocks every actual mutation at SetConfig / Login, + // so the window is safe to show as a read-only view. The previous + // early-return also blocked Advanced Settings whenever update + // editing was off, which conflated two distinct kill switches + // (see comment in checkAndUpdateFeatures). // add settings window UI elements. s.wSettings = s.app.NewWindow("NetBird Settings") @@ -532,7 +555,7 @@ func (s *serviceClient) saveSettings() { return } - iMngURL := strings.TrimSpace(s.iMngURL.Text) + iMngURL := strings.TrimSpace(strings.TrimSuffix(s.iMngURL.Text, mdmFieldSuffix)) if s.hasSettingsChanged(iMngURL, port, mtu) { if err := s.applySettingsChanges(iMngURL, port, mtu); err != nil { @@ -554,7 +577,7 @@ func (s *serviceClient) validateSettings() error { } func (s *serviceClient) parseNumericSettings() (int64, int64, error) { - port, err := strconv.ParseInt(s.iInterfacePort.Text, 10, 64) + port, err := strconv.ParseInt(strings.TrimSpace(strings.TrimSuffix(s.iInterfacePort.Text, mdmFieldSuffix)), 10, 64) if err != nil { return 0, 0, errors.New("invalid interface port") } @@ -663,7 +686,15 @@ func (s *serviceClient) buildSetConfigRequest(iMngURL string, port, mtu int64) ( req.SshJWTCacheTTL = &sshJWTCacheTTL32 } - if s.iPreSharedKey.Text != censoredPreSharedKey { + // Only attach the PSK when the user actually typed something: + // - "" means the field was left untouched (we deliberately render + // an empty Text + placeholder hint to avoid leaking the daemon's + // "**********" redaction through the password reveal toggle); + // sending an empty pointer would tell the daemon to clear / overwrite + // the on-disk or MDM-enforced PSK, which then trips the MDM + // conflict gate when PSK is policy-managed. + // - "**********" is the redacted echo (legacy non-MDM path); also a no-op. + if s.iPreSharedKey.Text != "" && s.iPreSharedKey.Text != censoredPreSharedKey { req.OptionalPreSharedKey = &s.iPreSharedKey.Text } @@ -1036,6 +1067,13 @@ func (s *serviceClient) onTrayReady() { } s.mProfile = newProfileMenu(*newProfileMenuArgs) + // Seed the transition cache to match the actual default menu + // state (visible / enabled). Without this, the first + // checkAndUpdateFeatures tick that observes DisableProfiles=true + // is a no-op (cache zero-value == desired-false) and the menu + // never gets hidden — symptom: MDM enforces the kill switch but + // the profile menu stays clickable. + s.profilesEnabled = true systray.AddSeparator() s.mUp = systray.AddMenuItem("Connect", "Connect") @@ -1055,18 +1093,18 @@ func (s *serviceClient) onTrayReady() { s.mCreateDebugBundle = s.mSettings.AddSubMenuItem("Create Debug Bundle", debugBundleMenuDescr) s.loadSettings() - // Disable settings menu if update settings are disabled by daemon + // Disable profile menu if profiles are disabled by daemon. + // DisableUpdateSettings is enforced at the daemon's SetConfig / + // Login gates, not by hiding the UI — so the Settings menu (and + // its Advanced Settings submenu, which has its own kill switch) + // stays visible and the user can still inspect current values. features, err := s.getFeatures() if err != nil { log.Errorf("failed to get features from daemon: %v", err) // Continue with default behavior if features can't be retrieved - } else { - if features != nil && features.DisableUpdateSettings { - s.setSettingsEnabled(false) - } - if features != nil && features.DisableProfiles { - s.mProfile.setEnabled(false) - } + } else if features != nil && features.DisableProfiles { + s.mProfile.setEnabled(false) + s.profilesEnabled = false } s.exitNodeMu.Lock() @@ -1100,13 +1138,20 @@ func (s *serviceClient) onTrayReady() { // update exit node menu in case service is already connected go s.updateExitNodes() + // Features (DisableProfiles, DisableUpdateSettings, DisableNetworks, + // ...) only change in two ways: at service install time (CLI flag, + // static) and at MDM ticker diff time. The daemon already publishes + // a SystemEvent{type=config_changed} on every MDM-driven engine + // restart, so the UI no longer needs to poll GetFeatures every 2 s. + // A single fetch at startup covers the static CLI-flag case; the + // event handler below covers MDM transitions. updateStatus stays in + // the 2 s loop because connection / peer state genuinely change + // continuously and have no event yet. + s.checkAndUpdateFeatures() go func() { s.getSrvConfig() time.Sleep(100 * time.Millisecond) // To prevent race condition caused by systray not being fully initialized and ignoring setIcon for { - // Check features before status so menus respect disable flags before being enabled - s.checkAndUpdateFeatures() - err := s.updateStatus() if err != nil { log.Errorf("error while updating status: %v", err) @@ -1150,6 +1195,23 @@ func (s *serviceClient) onTrayReady() { s.onUpdateAvailable(newVersion, enforced) } }) + s.eventManager.AddHandler(func(event *proto.SystemEvent) { + // Daemon emits a config_changed event after every engine spawn + // (Server.Start, Server.Up, MDM ticker restart). Re-sync the + // tray submenu checkboxes from the fresh daemon-side config so + // the user does not have to restart the tray to see CLI- or + // MDM-driven changes. + if event.Category == proto.SystemEvent_SYSTEM && event.Metadata["type"] == "config_changed" { + log.Infof("config_changed event received (source=%s); refreshing settings + features", event.Metadata["source"]) + s.loadSettings() + // MDM-driven feature kill switches (DisableProfiles / + // DisableUpdateSettings / DisableNetworks) ride the same + // config_changed signal because the daemon re-applies its + // MDM policy on every engine spawn. Pull them in here so + // the UI is up to date without a periodic GetFeatures poll. + s.checkAndUpdateFeatures() + } + }) go s.eventManager.Start(s.ctx) go s.eventHandler.listen(s.ctx) @@ -1213,18 +1275,6 @@ func (s *serviceClient) getSrvClient(timeout time.Duration) (proto.DaemonService return s.conn, nil } -// setSettingsEnabled enables or disables the settings menu based on the provided state -func (s *serviceClient) setSettingsEnabled(enabled bool) { - if s.mSettings != nil { - if enabled { - s.mSettings.Enable() - } else { - s.mSettings.Hide() - s.mSettings.SetTooltip("Settings are disabled by daemon") - } - } -} - // checkAndUpdateFeatures checks the current features and updates the UI accordingly func (s *serviceClient) checkAndUpdateFeatures() { features, err := s.getFeatures() @@ -1236,12 +1286,11 @@ func (s *serviceClient) checkAndUpdateFeatures() { s.updateIndicationLock.Lock() defer s.updateIndicationLock.Unlock() - // Update settings menu based on current features - settingsEnabled := features == nil || !features.DisableUpdateSettings - if s.settingsEnabled != settingsEnabled { - s.settingsEnabled = settingsEnabled - s.setSettingsEnabled(settingsEnabled) - } + // DisableUpdateSettings is enforced server-side by the daemon gates + // on SetConfig + Login: any attempt to mutate config from UI or + // CLI is rejected at that layer. The UI deliberately keeps the + // Settings menu visible so the user can still inspect current + // values — read-only by virtue of the daemon refusing edits. // Update profile menu based on current features if s.mProfile != nil { @@ -1252,14 +1301,23 @@ func (s *serviceClient) checkAndUpdateFeatures() { } } - // Update networks and exit node menus based on current features + // Update networks and exit node menus based on current features. + // `networksEnabled` is the bare feature flag (read elsewhere, e.g. at + // connection-status transitions). `networksMenuEnabled` is the + // transition-cached state actually applied to the menu items — + // it folds in the connection state so a Connected client with the + // kill switch off shows the menus active, and only flips on diff. s.networksEnabled = features == nil || !features.DisableNetworks - if s.networksEnabled && s.connected { - s.mNetworks.Enable() - s.mExitNode.Enable() - } else { - s.mNetworks.Disable() - s.mExitNode.Disable() + desiredNetworksMenu := s.networksEnabled && s.connected + if desiredNetworksMenu != s.networksMenuEnabled { + s.networksMenuEnabled = desiredNetworksMenu + if desiredNetworksMenu { + s.mNetworks.Enable() + s.mExitNode.Enable() + } else { + s.mNetworks.Disable() + s.mExitNode.Disable() + } } } @@ -1356,7 +1414,14 @@ func (s *serviceClient) getSrvConfig() { if s.showAdvancedSettings { s.iMngURL.SetText(s.managementURL) - s.iPreSharedKey.SetText(cfg.PreSharedKey) + // PSK is rendered with an empty Text and a hint via the + // placeholder so the eye toggle never reveals literal asterisks + // (the daemon returns the "**********" sentinel — writing that + // into a PasswordEntry would surface the literal sentinel when + // the user unmasks the field). The placeholder communicates the + // configured / MDM-managed state without exposing any value. + s.iPreSharedKey.SetText("") + s.iPreSharedKey.SetPlaceHolder(preSharedKeyPlaceholder(srvCfg)) s.iInterfaceName.SetText(cfg.WgIface) s.iInterfacePort.SetText(strconv.Itoa(cfg.WgPort)) if cfg.MTU != 0 { @@ -1366,7 +1431,15 @@ func (s *serviceClient) getSrvConfig() { s.iMTU.SetPlaceHolder(strconv.Itoa(int(iface.DefaultMTU))) } s.sRosenpassPermissive.SetChecked(cfg.RosenpassPermissive) - if !cfg.RosenpassEnabled { + // Re-baseline the enabled state on every refresh: when Rosenpass + // is on the checkbox is editable, when it's off the field is + // inert. Without an explicit Enable() here the control stays + // stuck disabled after a previous refresh (or an MDM unlock) had + // turned it off — applyMDMLocksToSettingsForm below adds the + // MDM lock on top of this baseline. + if cfg.RosenpassEnabled { + s.sRosenpassPermissive.Enable() + } else { s.sRosenpassPermissive.Disable() } s.sNetworkMonitor.SetChecked(*cfg.NetworkMonitor) @@ -1395,6 +1468,13 @@ func (s *serviceClient) getSrvConfig() { } } + // MDM locks must run before the mNotifications-nil early return: + // the Settings window is rendered by a separate UI process launched + // with --settings (see handleAdvancedSettingsClick), and that child + // process does NOT run onReady — so its mNotifications is nil and + // the early return below skipped the lock pass entirely. + s.applyMDMLocks(srvCfg.MDMManagedFields) + if s.mNotifications == nil { return } @@ -1579,6 +1659,129 @@ func (s *serviceClient) loadSettings() { if s.eventManager != nil { s.eventManager.SetNotificationsEnabled(s.mNotifications.Checked()) } + s.applyMDMLocks(cfg.MDMManagedFields) +} + +// applyMDMLocks disables and badges any tray submenu item or settings- +// form widget whose underlying field is enforced by the active MDM +// policy. Called from loadSettings (submenu refresh) and from +// getSrvConfig (settings-window refresh). Locked items keep their value +// already set by the surrounding refresh code — this routine only +// flips the enabled state and the title suffix, never the value. +func (s *serviceClient) applyMDMLocks(managed []string) { + set := make(map[string]bool, len(managed)) + for _, k := range managed { + set[k] = true + } + s.mdmManagedFields = set + if len(managed) > 0 { + log.Infof("MDM-managed UI fields: %v", managed) + } + + type submenuTarget struct { + item *systray.MenuItem + title string + key string + } + for _, t := range []submenuTarget{ + {s.mAllowSSH, "Allow SSH", mdm.KeyAllowServerSSH}, + {s.mAutoConnect, "Connect on Startup", mdm.KeyDisableAutoConnect}, + {s.mEnableRosenpass, "Enable Quantum-Resistance", mdm.KeyRosenpassEnabled}, + {s.mBlockInbound, "Block Inbound Connections", mdm.KeyBlockInbound}, + } { + if t.item == nil { + continue + } + if set[t.key] { + t.item.SetTitle(t.title + " (MDM)") + t.item.Disable() + } else { + t.item.SetTitle(t.title) + t.item.Enable() + } + } + + s.applyMDMLocksToSettingsForm(set) +} + +// preSharedKeyPlaceholder returns the hint string shown in the PSK +// Entry's placeholder slot. The placeholder is the only signal the +// user gets that a PSK is configured, because the entry's Text is +// forced to empty to keep the password reveal toggle from leaking +// the daemon-returned "**********" redaction sentinel. Returns "" if +// no PSK is present, "MDM-managed" if the key is enforced by MDM, +// and "configured" otherwise. +func preSharedKeyPlaceholder(cfg *proto.GetConfigResponse) string { + if cfg == nil || cfg.PreSharedKey == "" { + return "" + } + for _, k := range cfg.MDMManagedFields { + if k == mdm.KeyPreSharedKey { + return "MDM-managed" + } + } + return "configured" +} + +// applyMDMLocksToSettingsForm disables the per-field input widgets in +// the advanced Settings window when the corresponding MDM key is set. +// For plain-text entries (Management URL, Interface Port) the visible +// value is suffixed with " (MDM)" so the user sees the lock indicator +// inline; for the password entry the suffix is skipped (a password +// widget renders every char as a dot and the indicator would not be +// readable). The widgets are created lazily by showSettingsUI, so +// guard each ref against nil. +func (s *serviceClient) applyMDMLocksToSettingsForm(set map[string]bool) { + type entryTarget struct { + entry *widget.Entry + key string + inlineTag bool + } + for _, t := range []entryTarget{ + {s.iMngURL, mdm.KeyManagementURL, true}, + {s.iPreSharedKey, mdm.KeyPreSharedKey, false}, + {s.iInterfacePort, mdm.KeyWireguardPort, true}, + } { + if t.entry == nil { + continue + } + if set[t.key] { + if t.inlineTag && t.entry.Text != "" && !strings.HasSuffix(t.entry.Text, mdmFieldSuffix) { + t.entry.SetText(t.entry.Text + mdmFieldSuffix) + } + t.entry.Disable() + } else { + if t.inlineTag { + t.entry.SetText(strings.TrimSuffix(t.entry.Text, mdmFieldSuffix)) + } + t.entry.Enable() + } + } + type checkTarget struct { + check *widget.Check + key string + } + for _, t := range []checkTarget{ + {s.sDisableClientRoutes, mdm.KeyDisableClientRoutes}, + {s.sDisableServerRoutes, mdm.KeyDisableServerRoutes}, + } { + if t.check == nil { + continue + } + if set[t.key] { + t.check.Disable() + } else { + t.check.Enable() + } + } + if s.sRosenpassPermissive != nil && set[mdm.KeyRosenpassPermissive] { + // MDM lock layered on top of the Rosenpass-on/off baseline + // applied by getSrvConfig. No Enable() branch here: when the + // MDM key is removed, the next getSrvConfig refresh re-baselines + // the control on cfg.RosenpassEnabled and brings it back if + // Rosenpass is on. + s.sRosenpassPermissive.Disable() + } } // updateConfig updates the configuration parameters diff --git a/client/ui/profile.go b/client/ui/profile.go index 7ee89e631..d3db17855 100644 --- a/client/ui/profile.go +++ b/client/ui/profile.go @@ -666,16 +666,48 @@ func (p *profileMenu) clear(profiles []Profile) { } } -// setEnabled enables or disables the profile menu based on the provided state +// setEnabled greys out (Disable) the profile menu and every existing +// sub-item when the daemon reports the kill switch active, so the user +// sees the menu but cannot enter "Manage Profiles" or switch profile. +// Previously this used Hide() on the parent, but Fyne's systray on +// Windows does not propagate Hide() to a parent that already has +// children — the submenu kept popping up and accepting clicks. Disable +// is the reliable visual lock. func (p *profileMenu) setEnabled(enabled bool) { - if p.profileMenuItem != nil { - if enabled { - p.profileMenuItem.Enable() - p.profileMenuItem.SetTooltip("") - } else { - p.profileMenuItem.Hide() - p.profileMenuItem.SetTooltip("Profiles are disabled by daemon") + if p.profileMenuItem == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + + if enabled { + p.profileMenuItem.Enable() + p.profileMenuItem.SetTooltip("") + } else { + p.profileMenuItem.Disable() + p.profileMenuItem.SetTooltip("Profiles are disabled by daemon") + } + + apply := func(item *systray.MenuItem) { + if item == nil { + return } + if enabled { + item.Enable() + } else { + item.Disable() + } + } + for _, sub := range p.profileSubItems { + if sub != nil { + apply(sub.MenuItem) + } + } + if p.manageProfilesSubItem != nil { + apply(p.manageProfilesSubItem.MenuItem) + } + if p.logoutSubItem != nil { + apply(p.logoutSubItem.MenuItem) } } diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist new file mode 100644 index 000000000..f42b6b3d2 --- /dev/null +++ b/docs/io.netbird.client.plist @@ -0,0 +1,126 @@ + + + + + + + + managementURL + https://api.netbird.io:443 + + + + + + + allowServerSSH + + + + + + + + + + + + + + + diff --git a/docs/netbird-macos.mobileconfig b/docs/netbird-macos.mobileconfig new file mode 100644 index 000000000..53453db5c --- /dev/null +++ b/docs/netbird-macos.mobileconfig @@ -0,0 +1,159 @@ + + + + + + + PayloadType + Configuration + PayloadVersion + 1 + PayloadIdentifier + io.netbird.client.mdm + PayloadUUID + 11111111-1111-1111-1111-111111111111 + PayloadDisplayName + NetBird MDM Policy + PayloadDescription + Enforces NetBird client configuration. Values written here override any local user / CLI / on-disk setting and are re-applied at every daemon boot and on every 1-minute MDM reload tick. + PayloadOrganization + NetBird + PayloadScope + System + PayloadRemovalDisallowed + + + PayloadContent + + + + PayloadType + com.apple.ManagedClient.preferences + PayloadVersion + 1 + PayloadIdentifier + io.netbird.client.mdm.preferences + PayloadUUID + 22222222-2222-2222-2222-222222222222 + PayloadDisplayName + NetBird Managed Preferences + PayloadEnabled + + + PayloadContent + + io.netbird.client + + Forced + + + mcx_preference_settings + + + + managementURL + https://api.netbird.io:443 + + + + + + + allowServerSSH + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/netbird-macos.sh b/docs/netbird-macos.sh new file mode 100644 index 000000000..a2f5ff5e8 --- /dev/null +++ b/docs/netbird-macos.sh @@ -0,0 +1,189 @@ +#!/bin/bash +# +# SYNOPSIS +# Push the NetBird MDM policy to a macOS device via JumpCloud Commands. +# +# DESCRIPTION +# This is the macOS counterpart of docs/netbird-policy.reg.ps1. +# It writes the values declared in the "POLICY VALUES" block below to +# the managed-preferences plist that the NetBird daemon's +# client/mdm/policy_darwin.go loader reads on every 1-minute MDM +# reload tick: +# +# /Library/Managed Preferences/io.netbird.client.plist +# +# Once the plist lands, the daemon picks up the new values without +# restart (the ticker calls Config.apply() → applyMDMPolicy() and +# restarts the engine on diff). +# +# DEPLOYMENT (JumpCloud) +# 1. Admin Console -> Device Management -> Commands -> +. +# 2. Type: Mac, Shell, Run as: root. +# 3. Paste this file verbatim into the command body. +# 4. Bind to the target system group, save, run. +# +# IMPORTANT: PERSISTENCE +# macOS wipes /Library/Managed Preferences/ at every boot on devices +# that are NOT MDM-enrolled. For a persistent fleet rollout, push the +# companion docs/netbird-macos.mobileconfig as a Custom Configuration +# Profile (Admin Console -> MDM -> Mac Custom Configuration Profiles) +# instead of this script. Use this script when: +# - the device is MDM-enrolled (file survives reboots), or +# - you need a one-shot test push before reboot, or +# - you orchestrate via JumpCloud Commands and want the same +# variable-driven workflow as the Windows .ps1 sibling. +# +# IDEMPOTENCY: re-running with the same values is a no-op from the +# daemon's point of view (the 1-minute reload ticker diff returns empty). +# +# SECURITY: PreSharedKey is redacted in this script's log output. + +set -euo pipefail + +### POLICY VALUES — EDIT THIS BLOCK ########################################### +# +# Set each variable below to the desired value. Set to empty string "" +# or to NULL to omit a key entirely (the daemon treats an absent key +# as "no enforcement" for that field). Booleans use "true"/"false" +# (lowercase). Integers as decimal. +# +# Reference for key names + accepted values: +# client/mdm/policy.go (Key* constants) +# docs/netbird-macos.mobileconfig (sample profile) +# docs/netbird.admx + .adml (Windows ADMX schema) +# +NULL='__UNSET__' +managementURL='https://api.netbird.io:443' +preSharedKey="$NULL" # secret; redacted in log +allowServerSSH='true' +blockInbound="$NULL" +disableAutoConnect="$NULL" +disableClientRoutes="$NULL" +disableServerRoutes="$NULL" +disableMetricsCollection="$NULL" +disableUpdateSettings="$NULL" +disableProfiles="$NULL" +disableNetworks="$NULL" +rosenpassEnabled="$NULL" +rosenpassPermissive="$NULL" +wireguardPort='51820' +splitTunnelMode="$NULL" # "allow" or "disallow", Android-only at the daemon level +splitTunnelApps="$NULL" # comma-separated app IDs, Android-only +############################################################################## + +readonly PLIST_DIR='/Library/Managed Preferences' +readonly PLIST_PATH="$PLIST_DIR/io.netbird.client.plist" +readonly LOG_TAG='netbird-mdm' + +# log sends a message to the system logger using the configured tag and echoes the message to stdout prefixed by an ISO 8601 UTC timestamp and the tag. +log() { + /usr/bin/logger -t "$LOG_TAG" "$*" + printf '%s [%s] %s\n' "$(date -u '+%Y-%m-%dT%H:%M:%SZ')" "$LOG_TAG" "$*" +} + +# is_set returns success if the provided value is non-empty and is not equal to the special NULL marker. +is_set() { + local value="$1" + [[ -n "$value" && "$value" != "$NULL" ]] +} + +# start_plist creates the temporary plist file at "$PLIST_PATH.tmp" containing the XML plist header and opening `` for the policy plist. +start_plist() { + cat > "$PLIST_PATH.tmp" <<'EOF' + + + + +EOF +} + +# end_plist appends the closing `` and `` tags to the temporary plist file. +end_plist() { + cat >> "$PLIST_PATH.tmp" <<'EOF' + + +EOF +} + +# emit_string appends a plist ``/`` entry for the given key and value to "$PLIST_PATH.tmp", XML-escaping `&`, `<`, and `>`, and logs the assignment (masking the logged value as `********** (secret)` when the key is `preSharedKey`). +emit_string() { + local key="$1" value="$2" log_value="$2" + # Escape XML entities in the value + local escaped + escaped="$(printf '%s' "$value" | sed -e 's/&/\&/g' -e 's//\>/g')" + printf ' %s\n %s\n' "$key" "$escaped" >> "$PLIST_PATH.tmp" + if [[ "$key" == "preSharedKey" ]]; then + log_value='********** (secret)' + fi + log "set $key = $log_value" +} + +# emit_bool writes a boolean plist entry for a given key into the temporary plist file. +# emit_bool writes a boolean plist entry for a key when the provided value matches an accepted boolean token; logs an error and skips the key on invalid input. +emit_bool() { + local key="$1" value="$2" + local xml_bool + case "$value" in + true|True|TRUE|1|yes) xml_bool='' ; value='true' ;; + false|False|FALSE|0|no) xml_bool='' ; value='false' ;; + *) log "invalid boolean for $key: $value (must be true/false); skipping"; return ;; + esac + printf ' %s\n %s\n' "$key" "$xml_bool" >> "$PLIST_PATH.tmp" + log "set $key = $value" +} + +# emit_int validates that VALUE contains only decimal digits and, if valid, appends an `` plist entry for KEY to the temporary plist (`$PLIST_PATH.tmp`) and logs the assignment; on invalid input it logs a skip and does not emit the key. +emit_int() { + local key="$1" value="$2" + if ! [[ "$value" =~ ^[0-9]+$ ]]; then + log "invalid integer for $key: $value (must be decimal); skipping" + return + fi + printf ' %s\n %s\n' "$key" "$value" >> "$PLIST_PATH.tmp" + log "set $key = $value" +} + +# main builds the NetBird MDM plist from configured policy variables, validates and installs it to /Library/Managed Preferences/io.netbird.client.plist (root:wheel, 644) and optionally triggers the NetBird daemon to reload. +main() { + log "applying NetBird MDM policy to $PLIST_PATH" + /bin/mkdir -p "$PLIST_DIR" + start_plist + + is_set "$managementURL" && emit_string managementURL "$managementURL" + is_set "$preSharedKey" && emit_string preSharedKey "$preSharedKey" + is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH" + is_set "$blockInbound" && emit_bool blockInbound "$blockInbound" + is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect" + is_set "$disableClientRoutes" && emit_bool disableClientRoutes "$disableClientRoutes" + is_set "$disableServerRoutes" && emit_bool disableServerRoutes "$disableServerRoutes" + is_set "$disableMetricsCollection" && emit_bool disableMetricsCollection "$disableMetricsCollection" + is_set "$disableUpdateSettings" && emit_bool disableUpdateSettings "$disableUpdateSettings" + is_set "$disableProfiles" && emit_bool disableProfiles "$disableProfiles" + is_set "$disableNetworks" && emit_bool disableNetworks "$disableNetworks" + is_set "$rosenpassEnabled" && emit_bool rosenpassEnabled "$rosenpassEnabled" + is_set "$rosenpassPermissive" && emit_bool rosenpassPermissive "$rosenpassPermissive" + is_set "$wireguardPort" && emit_int wireguardPort "$wireguardPort" + is_set "$splitTunnelMode" && emit_string splitTunnelMode "$splitTunnelMode" + is_set "$splitTunnelApps" && emit_string splitTunnelApps "$splitTunnelApps" + + end_plist + + if ! /usr/bin/plutil -lint "$PLIST_PATH.tmp" >/dev/null 2>&1; then + log "ERROR: generated plist failed plutil lint; not installing" + /usr/bin/plutil -lint "$PLIST_PATH.tmp" >&2 || true + /bin/rm -f "$PLIST_PATH.tmp" + exit 1 + fi + + /bin/mv -f "$PLIST_PATH.tmp" "$PLIST_PATH" + /usr/sbin/chown root:wheel "$PLIST_PATH" + /bin/chmod 644 "$PLIST_PATH" + + log "policy installed; NetBird daemon will pick it up within the next 1-minute reload tick" + + # Optional: kick the daemon for an immediate apply. Safe — does + # nothing on a host where NetBird is not yet installed. + /bin/launchctl kickstart -k system/io.netbird.client 2>/dev/null || true +} + +main "$@" diff --git a/docs/netbird-policy.reg b/docs/netbird-policy.reg new file mode 100644 index 0000000000000000000000000000000000000000..ba4402e50f956facd45f368b214ee6fbfc6dcf39 GIT binary patch literal 1418 zcmbu9VNV)C5Qg8+P5K`UpBvQFHYtf8fHtvKDuRbG+K}V!6e1qx4kY^H)n{hW)I`+M zZ1!$TzChI%1!*|Dvik z7$5b)=ZSXo3!7v0wWobGRp;MVi+_`|pZd+|sk+#ofjU+eT4V4h=wzyBn;9b+Bbl={X3MPVU|r!WSX~lt6;?5riLYZn z{{&LONy-1xISt{Ilc%ctF5PeNQ-AW@MOx_Ezg<}GxR(@8hL)=4d9&O-j)2f`=7}!I zRjvWr7G?T|_2weZ?=UNgU;s)+&S!peO;AFn680ADavm=uHNmrc$+7P^rzyLTxqroK zVQJN6cU@8ycJId4xP`h}g<+_ckll+FnYCeMbvbfAt3~(sz5j2fBI+;YHZ9kNJM|1% z<-M1iMP#-^P;8abof)+qD!xN6explwwaKma6j6&2=sn~^q!F489k{%tn%+0814Avh zyq97SGw&R$o(NBS3;#op$U3m5b;cRlpU_nUXIs^+?`zs(e11Z;tXj|IWG7}-!Zv3W zYW}TG32#-!tN1xe8_WWp)aSTrbj$1$^uD5Jd9wKc D>FeT* literal 0 HcmV?d00001 diff --git a/docs/netbird-policy.reg.ps1 b/docs/netbird-policy.reg.ps1 new file mode 100644 index 000000000..011d706dc --- /dev/null +++ b/docs/netbird-policy.reg.ps1 @@ -0,0 +1,94 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Push the NetBird MDM policy to a Windows device via JumpCloud Commands + by importing a sidecar netbird-policy.reg file. + +.DESCRIPTION + Windows counterpart of docs/netbird-macos.sh. Outcome: + HKLM\Software\Policies\NetBird populated from the attached + netbird-policy.reg file, daemon picks up the change via the + 1-minute MDM reload ticker. + + Deployment: + 1. Admin Console -> Device Management -> Commands -> +. + 2. Type: Windows PowerShell. Run as: SYSTEM. + 3. Paste this file verbatim into the command body. + 4. In the same command, attach `netbird-policy.reg` as a file. + JumpCloud copies attached files into the command's working + directory before invoking the script, so `$PSScriptRoot` or + Get-Location resolves to where the .reg lives. + 5. Bind to the target system group, save, run. + + Producing the .reg file: + On a reference machine, after configuring the policy values either + via gpedit (GPO) or manual `reg add`, export with: + + reg export "HKLM\Software\Policies\NetBird" netbird-policy.reg /y + + Then attach the resulting file to the JumpCloud command. + + Semantics: + - The script nukes the existing HKLM\Software\Policies\NetBird key + before importing the .reg, so the .reg is the SINGLE SOURCE OF + TRUTH. Any value present in the registry but absent from the .reg + is removed. This is what an MDM admin almost always wants. + - Setting the .reg to an empty (header-only) file effectively unsets + the policy. + + Idempotency: re-running the script with the same .reg is a no-op from + the daemon's perspective (values identical → 1-min ticker sees no + diff → engine not restarted). + + Exit codes: 0 = success; 1 = .reg missing or reg.exe error. +#> + +$ErrorActionPreference = "Stop" + +$RegFileName = "netbird-policy.reg" +$RegKey = "HKLM\Software\Policies\NetBird" + +# Resolve the attached .reg file: JumpCloud copies command attachments +# into C:\Windows\Temp\ before invoking the script. Cwd / $PSScriptRoot +# fallbacks cover the local-dev case where you might dot-source this +# from elsewhere. +$candidates = @( + (Join-Path "$env:WINDIR\Temp" $RegFileName) + (Join-Path (Get-Location) $RegFileName) + (Join-Path $PSScriptRoot $RegFileName) +) | Where-Object { Test-Path $_ } + +if ($candidates.Count -eq 0) { + Write-Error "[netbird-mdm] $RegFileName not found in working directory or `$PSScriptRoot. Attach the file to the JumpCloud command." + exit 1 +} +$regFile = $candidates[0] +Write-Host "[netbird-mdm] using $regFile" + +# Wipe the existing policy key so the .reg is authoritative. +$existed = Test-Path "Registry::HKEY_LOCAL_MACHINE\Software\Policies\NetBird" +if ($existed) { + & reg.exe delete $RegKey /f | Out-Null + if ($LASTEXITCODE -ne 0) { + Write-Error "[netbird-mdm] failed to clear $RegKey before import (exit $LASTEXITCODE)" + exit 1 + } + Write-Host "[netbird-mdm] cleared previous values under $RegKey" +} + +# Import. reg.exe writes both data and (re-)creates the key if needed. +& reg.exe import $regFile +if ($LASTEXITCODE -ne 0) { + Write-Error "[netbird-mdm] reg import failed (exit $LASTEXITCODE)" + exit 1 +} + +# Audit dump so the JumpCloud per-execution log captures the applied state. +Write-Host "[netbird-mdm] final policy state under $RegKey :" +& reg.exe query $RegKey /s + +# Daemon's 1-min reload ticker picks up the change automatically. +# Uncomment to force immediate convergence (skips the ticker wait): +# Restart-Service netbird -Force -ErrorAction SilentlyContinue + +exit 0 diff --git a/docs/netbird.adml b/docs/netbird.adml new file mode 100644 index 000000000..d49b05022 --- /dev/null +++ b/docs/netbird.adml @@ -0,0 +1,95 @@ + + + NetBird Client Policies + Group Policy template for NetBird client MDM-managed settings. Values are written under HKLM\Software\Policies\NetBird and consumed by the netbird daemon at startup and every 1-minute reload tick. + + + + + NetBird + NetBird Client 0.40+ + + + Management URL + URL of the NetBird management server. Format: https://host[:port]. When set, users cannot override this value via UI or CLI. + + Pre-shared key + WireGuard pre-shared key used as an additional symmetric secret on every peer-to-peer tunnel. Secret value. + + + Disable auto-connect + When enabled, the NetBird tunnel does not auto-connect at daemon startup. Equivalent to --disable-auto-connect. + + Disable client routes + When enabled, this client will not consume routes advertised by routing peers. Equivalent to --disable-client-routes. + + Disable server routes + When enabled, this client will not act as a routing peer for other clients. Equivalent to --disable-server-routes. + + Block inbound + When enabled, the client firewall blocks all inbound peer traffic on the WireGuard interface. Equivalent to --block-inbound. + + Allow server SSH + When enabled, this client accepts incoming SSH sessions via NetBird SSH. Equivalent to --allow-server-ssh. + + Enable Rosenpass + Enables Rosenpass post-quantum key exchange on WireGuard tunnels. Both peers must support it. + + Rosenpass permissive + When enabled, the client falls back to plain WireGuard if a peer does not support Rosenpass; otherwise it refuses the connection. + + WireGuard port + UDP port used by the local WireGuard interface. Allowed range: 1-65535. + + Split tunnel + Restrict the NetBird tunnel to or from a chosen list of application package names. Choose either the allow mode (only the listed apps route through NetBird) or the disallow mode (the listed apps bypass NetBird; everything else routes through). The mode is mutually exclusive — only one can be active at a time. Android-only at the daemon level; Windows/macOS/iOS clients ignore this policy. + Allow only listed apps (everything else bypasses) + Disallow listed apps (everything else routes) + + + Disable update settings + When enabled, blocks every configuration change from the client UI and from the CLI (netbird up / login / setconfig). The Settings view stays viewable but read-only. Equivalent to --disable-update-settings. + + Disable profiles + When enabled, the client UI/CLI cannot list, create, switch or remove NetBird connection profiles. Equivalent to --disable-profiles. + + Disable networks + When enabled, the client UI/CLI cannot list, select or deselect NetBird networks (the corresponding daemon RPCs return Unavailable). Equivalent to --disable-networks. + + Disable metrics collection + When enabled, the client does not collect or report local usage metrics. + + + + + + + + https://api.netbird.io:443 + + + + + + + + + + + WireGuard UDP port: + + + + Mode: + + + + + + + + diff --git a/docs/netbird.admx b/docs/netbird.admx new file mode 100644 index 000000000..2f7645d63 --- /dev/null +++ b/docs/netbird.admx @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + allow + disallow + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/go.mod b/go.mod index f42a3abe2..0b9cc9f29 100644 --- a/go.mod +++ b/go.mod @@ -134,6 +134,7 @@ require ( gorm.io/driver/sqlite v1.5.7 gorm.io/gorm v1.25.12 gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 + howett.net/plist v1.0.1 ) require ( diff --git a/go.sum b/go.sum index e8ff034d8..bc78d17e5 100644 --- a/go.sum +++ b/go.sum @@ -380,6 +380,7 @@ github.com/jcmturner/rpc/v2 v2.0.3 h1:7FXXj8Ti1IaVFpSAziCZWNzbNuZmnvw/i6CqLNdWfZ github.com/jcmturner/rpc/v2 v2.0.3/go.mod h1:VUJYCIDm3PVOEHw8sgt091/20OJjskO/YJki3ELg/Hc= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE= github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= @@ -946,6 +947,7 @@ gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= @@ -968,5 +970,7 @@ gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU= gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89 h1:mGJaeA61P8dEHTqdvAgc70ZIV3QoUoJcXCRyyjO26OA= gvisor.dev/gvisor v0.0.0-20260219192049-0f2374377e89/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY= rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs= From b19467e3afd100cdbaa49b12794318472753dbed Mon Sep 17 00:00:00 2001 From: Viktor Liu <17948409+lixmal@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:50:46 +0900 Subject: [PATCH 19/19] [client] Answer NODATA when a host resolves without addresses of the requested family (#6418) --- client/internal/dns/resutil/resolve.go | 12 ++ client/internal/dns/resutil/resolve_test.go | 122 ++++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 client/internal/dns/resutil/resolve_test.go diff --git a/client/internal/dns/resutil/resolve.go b/client/internal/dns/resutil/resolve.go index 5a3744719..07a70d6d1 100644 --- a/client/internal/dns/resutil/resolve.go +++ b/client/internal/dns/resutil/resolve.go @@ -14,6 +14,10 @@ import ( log "github.com/sirupsen/logrus" ) +// errNoSuitableAddress mirrors the unexported error string the net package +// uses when a resolved host has no addresses of the requested family. +const errNoSuitableAddress = "no suitable address found" + // GenerateRequestID creates a random 8-character hex string for request tracing. func GenerateRequestID() string { bytes := make([]byte, 4) @@ -126,6 +130,14 @@ func LookupIP(ctx context.Context, r resolver, network, host string, qtype uint1 } func getRcodeForError(ctx context.Context, r resolver, host string, qtype uint16, err error) int { + // The net package returns this AddrError when the host resolves but has + // no addresses of the requested family. The domain exists, so answer + // NODATA instead of SERVFAIL. + var addrErr *net.AddrError + if errors.As(err, &addrErr) && addrErr.Err == errNoSuitableAddress { + return dns.RcodeSuccess + } + var dnsErr *net.DNSError if !errors.As(err, &dnsErr) { return dns.RcodeServerFailure diff --git a/client/internal/dns/resutil/resolve_test.go b/client/internal/dns/resutil/resolve_test.go new file mode 100644 index 000000000..432367c22 --- /dev/null +++ b/client/internal/dns/resutil/resolve_test.go @@ -0,0 +1,122 @@ +package resutil + +import ( + "context" + "errors" + "net" + "net/netip" + "testing" + + "github.com/miekg/dns" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type mockResolver struct { + // results maps network ("ip4"/"ip6") to the lookup outcome. + results map[string]mockLookup +} + +type mockLookup struct { + ips []netip.Addr + err error +} + +func (m *mockResolver) LookupNetIP(_ context.Context, network, _ string) ([]netip.Addr, error) { + res, ok := m.results[network] + if !ok { + return nil, errors.New("unexpected network: " + network) + } + return res.ips, res.err +} + +func TestLookupIP_Success(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip4": {ips: []netip.Addr{netip.MustParseAddr("::ffff:192.0.2.1")}}, + }} + + result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA) + + assert.Equal(t, dns.RcodeSuccess, result.Rcode, "successful lookup should return NOERROR") + require.Len(t, result.IPs, 1, "should return the resolved address") + assert.Equal(t, netip.MustParseAddr("192.0.2.1"), result.IPs[0], "v4-mapped address should be unmapped") +} + +func TestLookupIP_NoSuitableAddress(t *testing.T) { + // The net package returns this AddrError when the host resolves but has + // no addresses of the requested family (e.g. AAAA query for a v4-only + // hosts file entry). The domain exists, so this is NODATA, not SERVFAIL. + r := &mockResolver{results: map[string]mockLookup{ + "ip6": {err: &net.AddrError{Err: "no suitable address found", Addr: "example.com."}}, + }} + + result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA) + + assert.Equal(t, dns.RcodeSuccess, result.Rcode, "no suitable address should map to NODATA") + assert.Empty(t, result.IPs, "NODATA response should carry no addresses") +} + +// TestErrNoSuitableAddressMatchesNetPackage pins our copy of the error string +// to what the net package actually emits. A literal IP of the wrong family +// takes the same filterAddrList path as a resolved hostname, without network +// access. +func TestErrNoSuitableAddressMatchesNetPackage(t *testing.T) { + _, err := (&net.Resolver{}).LookupNetIP(context.Background(), "ip6", "192.0.2.1") + require.Error(t, err) + + var addrErr *net.AddrError + require.ErrorAs(t, err, &addrErr, "wrong-family lookup should return AddrError") + assert.Equal(t, errNoSuitableAddress, addrErr.Err, "net package error string should match our constant") +} + +func TestLookupIP_OtherAddrError(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip4": {err: &net.AddrError{Err: "some other address problem", Addr: "example.com."}}, + }} + + result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA) + + assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "unrecognized AddrError should map to SERVFAIL") +} + +func TestLookupIP_NotFoundNXDomain(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip4": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}}, + "ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}}, + }} + + result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA) + + assert.Equal(t, dns.RcodeNameError, result.Rcode, "not found for both families should map to NXDOMAIN") +} + +func TestLookupIP_NotFoundNoData(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip6": {err: &net.DNSError{Err: "no such host", Name: "example.com.", IsNotFound: true}}, + "ip4": {ips: []netip.Addr{netip.MustParseAddr("192.0.2.1")}}, + }} + + result := LookupIP(context.Background(), r, "ip6", "example.com.", dns.TypeAAAA) + + assert.Equal(t, dns.RcodeSuccess, result.Rcode, "not found with the other family present should map to NODATA") +} + +func TestLookupIP_GenericError(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip4": {err: errors.New("connection refused")}, + }} + + result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA) + + assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "generic error should map to SERVFAIL") +} + +func TestLookupIP_DNSErrorNotIsNotFound(t *testing.T) { + r := &mockResolver{results: map[string]mockLookup{ + "ip4": {err: &net.DNSError{Err: "server misbehaving", Name: "example.com.", IsTemporary: true}}, + }} + + result := LookupIP(context.Background(), r, "ip4", "example.com.", dns.TypeA) + + assert.Equal(t, dns.RcodeServerFailure, result.Rcode, "upstream failure should map to SERVFAIL") +}