Compare commits

..

10 Commits

Author SHA1 Message Date
Zoltán Papp
85f61d0c57 [relay] Unit test ForeignRelaysStore and FallbackOpener 2026-07-22 11:46:07 +02:00
Zoltán Papp
d663da8f82 Merge branch 'main' into refactor/relay-foreign-cache
# Conflicts:
#	shared/relay/client/manager.go
2026-07-21 11:27:09 +02:00
Zoltan Papp
cf101c44b4 [relay] Count winning attempt before draining losers
The success path in handleResult left settled at 0, so drainLoser
waited on a result that was already consumed and blocked forever,
leaking a goroutine and the results channel on every successful race.
Increment settled before stop() so drainLoser only waits for actual
started losers.
2026-07-02 12:22:01 +02:00
Zoltan Papp
79b51a79e4 [relay] Release home relay lock before the connection race
OpenConn held relayClientMu for the whole call, including the network-bound
FallbackOpener.Run, which blocked onServerDisconnected/storeClient from taking
the write lock and delayed reconnect by up to the race timeout. Snapshot the
home relay client under the read lock, release it, and run the foreign check
and the race on the snapshot. isForeignServer now takes the client so it uses
the snapshot instead of re-reading the field after the lock is dropped.
2026-07-01 19:22:41 +02:00
Zoltan Papp
a737504ec9 Fix timeout 2026-07-01 17:11:23 +02:00
Zoltan Papp
3b1beb3497 [relay] Return raceOutcome struct from handleResult to avoid nilnil
handleResult returned (net.Conn, error, bool) and produced (nil, nil, false)
on the non-terminal path, which the nilnil linter rejects. Return a
raceOutcome{conn, err, done} struct instead so the not-done case carries no
ambiguous nil value / nil error pair.
2026-07-01 17:05:46 +02:00
Zoltan Papp
49c0aeb6ce [relay] Update relay client tests for the new OpenConn signature
Manager.OpenConn now takes a RelayServer struct and a preferForeign
flag instead of a server address string and a serverIP. Adjust the
manager tests accordingly: bundle addr/IP into RelayServer and set
preferForeign=true for foreign-relay dials, false for home-relay dials.
2026-07-01 17:00:20 +02:00
Zoltan Papp
6774a43eae [relay] Rename ConnRacer to FallbackOpener and align file names with types
Rename the ConnRacer type to FallbackOpener and its constructor to
NewFallbackOpener. Rename fallback.go to fallback_opener.go and
foreign_relays.go to foreign_relays_store.go so file names mirror their
primary struct.
2026-07-01 16:53:42 +02:00
Zoltan Papp
50a29c07ce [relay] Race home and foreign relay for peer connection setup
When a peer advertises a relay different from the home relay, open the
peer connection by racing the home relay and the remote peer's relay in
parallel: start the preferred one (controller prefers home, otherwise the
remote relay), fall back to the other after a delay, use whichever connects
first and drain the loser.

Expose the foreign relay cache as ForeignRelaysStore and the race as
ConnRacer. The manager passes the remote relay as a RelayServer{Addr, IP}.
OpenConn no longer returns the winning server address; the worker derives it
from the returned conn's RemoteAddr to register the close listener, matching
the previous main-branch behavior.
2026-06-30 18:26:13 +02:00
Zoltan Papp
7d8e20030b [relay] Extract foreign relay client cache into a dedicated type
Move the foreign-relay client cache out of Manager into a foreignRelays
type. Concurrent first-time connects to the same server are deduplicated
with singleflight, so the cache mutex is never held during a network
connect (removing the previous stall where a slow connect blocked all map
operations). A per-entry in-use refcount prevents the cleanup loop from
closing a client while a connection is being opened on it.

This drops RelayTrack, its per-track lock and the hand-over-hand locking
between the map lock and the track lock. The exported API is unchanged.
2026-06-29 00:41:47 +02:00
293 changed files with 6238 additions and 20723 deletions

View File

@@ -5,13 +5,6 @@ on:
schedule:
- cron: "0 3 * * *"
workflow_dispatch:
inputs:
bedrock_model:
description: >-
Bedrock inference-profile id to drive the matrix with, exactly as
AWS issues it. Leave empty for the Sonnet 4.6 default.
required: false
default: ""
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
@@ -58,9 +51,6 @@ jobs:
# token (and URL, for gateways) is unset, so partial coverage is fine.
OPENAI_TOKEN: ${{ secrets.E2E_OPENAI_TOKEN }}
ANTHROPIC_TOKEN: ${{ secrets.E2E_ANTHROPIC_TOKEN }}
# Moonshot AI platform key (platform.kimi.ai); drives both Kimi wire
# shapes (OpenAI /v1 and Anthropic /anthropic) through kimi_api.
KIMI_TOKEN: ${{ secrets.E2E_KIMI_TOKEN }}
VERCEL_URL: ${{ secrets.E2E_VERCEL_URL }}
VERCEL_TOKEN: ${{ secrets.E2E_VERCEL_TOKEN }}
OPENROUTER_URL: ${{ secrets.E2E_OPENROUTER_URL }}
@@ -69,8 +59,6 @@ jobs:
CLOUDFLARE_TOKEN: ${{ secrets.E2E_CLOUDFLARE_TOKEN }}
AWS_BEARER_TOKEN_BEDROCK: ${{ secrets.E2E_AWS_BEARER_TOKEN_BEDROCK }}
AWS_REGION: ${{ secrets.E2E_AWS_REGION }}
# Bedrock model override: dispatch input wins, then the repo variable, else the test default.
AWS_BEDROCK_MODEL: ${{ inputs.bedrock_model || vars.E2E_AWS_BEDROCK_MODEL }}
# Vertex (Anthropic-on-Vertex): SA + project required; region defaults
# to "global", model to a pinned claude snapshot.
GOOGLE_VERTEX_SA_BASE64: ${{ secrets.E2E_GOOGLE_VERTEX_SA_BASE64 }}

View File

@@ -86,7 +86,7 @@ jobs:
${{ runner.os }}-pnpm-
- name: Install dependencies
run: pnpm install --frozen-lockfile --ignore-scripts
run: pnpm install --frozen-lockfile
- name: Generate Wails bindings
run: pnpm run bindings

View File

@@ -45,7 +45,7 @@ jobs:
display_name: Linux
name: ${{ matrix.display_name }}
runs-on: ${{ matrix.os }}
timeout-minutes: 25
timeout-minutes: 15
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
@@ -79,4 +79,4 @@ jobs:
skip-cache: true
skip-save-cache: true
cache-invalidation-interval: 0
args: --timeout=20m
args: --timeout=12m

View File

@@ -249,35 +249,78 @@ jobs:
docker compose exec management ls -l /var/lib/netbird/ | grep -i GeoLite2-City_[0-9]*.mmdb
docker compose exec management ls -l /var/lib/netbird/ | grep -i geonames_[0-9]*.db
test-legacy-getting-started-scripts:
test-getting-started-script:
runs-on: ubuntu-latest
steps:
- name: Install jq
run: sudo apt-get install -y jq
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Verify Dex retirement notice
run: |
if infrastructure_files/getting-started-with-dex.sh >stdout.txt 2>stderr.txt; then
echo "Expected the retired Dex installer to fail"
exit 1
fi
test ! -s stdout.txt
grep -Fq "Dex support is not deprecated." stderr.txt
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt
grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/local" stderr.txt
grep -Fq "removed in NetBird v0.80" stderr.txt
- name: run script with Zitadel PostgreSQL
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
- name: Verify Zitadel retirement notice
- name: test Caddy file gen postgres
run: test -f Caddyfile
- name: test docker-compose file gen postgres
run: test -f docker-compose.yml
- name: test management.json file gen postgres
run: test -f management.json
- name: test turnserver.conf file gen postgres
run: |
if bash infrastructure_files/getting-started-with-zitadel.sh >stdout.txt 2>stderr.txt; then
echo "Expected the retired Zitadel installer to fail"
exit 1
fi
test ! -s stdout.txt
grep -Fq "Zitadel support and existing Zitadel deployments are not deprecated." stderr.txt
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-quickstart" stderr.txt
grep -Fq "https://docs.netbird.io/selfhosted/identity-providers/zitadel" stderr.txt
grep -Fq "https://docs.netbird.io/selfhosted/selfhosted-guide" stderr.txt
grep -Fq "removed in NetBird v0.80" stderr.txt
set -x
test -f turnserver.conf
grep external-ip turnserver.conf
- name: test zitadel.env file gen postgres
run: test -f zitadel.env
- name: test dashboard.env file gen postgres
run: test -f dashboard.env
- name: test relay.env file gen postgres
run: test -f relay.env
- name: test zdb.env file gen postgres
run: test -f zdb.env
- name: Postgres run cleanup
run: |
docker compose down --volumes --rmi all
rm -rf docker-compose.yml Caddyfile zitadel.env dashboard.env machinekey/zitadel-admin-sa.token turnserver.conf management.json zdb.env
- name: run script with Zitadel CockroachDB
run: bash -x infrastructure_files/getting-started-with-zitadel.sh
env:
NETBIRD_DOMAIN: use-ip
ZITADEL_DATABASE: cockroach
- name: test Caddy file gen CockroachDB
run: test -f Caddyfile
- name: test docker-compose file gen CockroachDB
run: test -f docker-compose.yml
- name: test management.json file gen CockroachDB
run: test -f management.json
- name: test turnserver.conf file gen CockroachDB
run: |
set -x
test -f turnserver.conf
grep external-ip turnserver.conf
- name: test zitadel.env file gen CockroachDB
run: test -f zitadel.env
- name: test dashboard.env file gen CockroachDB
run: test -f dashboard.env
- name: test relay.env file gen CockroachDB
run: test -f relay.env

View File

@@ -273,8 +273,8 @@ dockers_v2:
- netbirdio/netbird
- ghcr.io/netbirdio/netbird
tags:
- "{{ .Version }}-rootless"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-latest{{ end }}"
- "v{{ .Version }}-rootless"
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}latest{{ end }}"
dockerfile: client/Dockerfile-rootless
extra_files:
- client/netbird-entrypoint.sh

View File

@@ -24,8 +24,6 @@ builds:
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- production
- id: netbird-ui-windows-amd64
dir: client/ui
@@ -41,8 +39,6 @@ builds:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
- -H windowsgui
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- production
- id: netbird-ui-windows-arm64
dir: client/ui
@@ -59,8 +55,6 @@ builds:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
- -H windowsgui
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- production
archives:
- id: linux-arch

View File

@@ -29,8 +29,6 @@ builds:
ldflags:
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
mod_timestamp: "{{ .CommitTimestamp }}"
tags:
- production
universal_binaries:
- id: netbird-ui-darwin

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"os"
"slices"
"strings"
"sync"
"time"
@@ -300,13 +299,6 @@ func (c *Client) SetInfoLogLevel() {
// PeersList return with the list of the PeerInfos
func (c *Client) PeersList() *PeerInfoArray {
// The recorder only caches transfer counters and handshake times; nothing
// refreshes them on its own, so without this they read as zero. The desktop
// daemon does the same before serving a full peer status.
if err := c.recorder.RefreshWireGuardStats(); err != nil {
log.Debugf("failed to refresh WireGuard stats: %v", err)
}
fullStatus := c.recorder.GetFullStatus()
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
@@ -317,20 +309,6 @@ func (c *Client) PeersList() *PeerInfoArray {
FQDN: p.FQDN,
ConnStatus: int(p.ConnStatus),
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
PubKey: p.PubKey,
Latency: formatDuration(p.Latency),
LatencyMs: p.Latency.Milliseconds(),
BytesRx: p.BytesRx,
BytesTx: p.BytesTx,
ConnStatusUpdate: formatTime(p.ConnStatusUpdate),
Relayed: p.Relayed,
RosenpassEnabled: p.RosenpassEnabled,
LastWireguardHandshake: formatTime(p.LastWireguardHandshake),
LocalIceCandidateType: p.LocalIceCandidateType,
RemoteIceCandidateType: p.RemoteIceCandidateType,
LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint,
RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint,
}
peerInfos[n] = pi
}
@@ -461,6 +439,10 @@ func (c *Client) RemoveConnectionListener() {
c.recorder.RemoveConnectionListener()
}
func (c *Client) toggleRoute(command routeCommand) error {
return command.toggleRoute()
}
func (c *Client) getRouteManager() (routemanager.Manager, error) {
client := c.getConnectClient()
if client == nil {
@@ -480,22 +462,22 @@ func (c *Client) getRouteManager() (routemanager.Manager, error) {
return manager, nil
}
func (c *Client) SelectRoute(id string) error {
func (c *Client) SelectRoute(route string) error {
manager, err := c.getRouteManager()
if err != nil {
return err
}
return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true)
return c.toggleRoute(selectRouteCommand{route: route, manager: manager})
}
func (c *Client) DeselectRoute(id string) error {
func (c *Client) DeselectRoute(route string) error {
manager, err := c.getRouteManager()
if err != nil {
return err
}
return manager.DeselectRoutes([]route.NetID{route.NetID(id)})
return c.toggleRoute(deselectRouteCommand{route: route, manager: manager})
}
// getNetworkDomainsFromRoute extracts domains from a route and enriches each domain
@@ -530,28 +512,3 @@ func exportEnvList(list *EnvList) {
}
}
}
// formatDuration renders a duration for display, trimming the fractional part
// to two digits so latencies read as "12.34ms" rather than "12.345678ms".
func formatDuration(d time.Duration) string {
ds := d.String()
dotIndex := strings.Index(ds, ".")
if dotIndex == -1 {
return ds
}
endIndex := min(dotIndex+3, len(ds))
// Skip the remaining digits so only the unit suffix is appended back.
unitStart := endIndex
for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' {
unitStart++
}
return ds[:endIndex] + ds[unitStart:]
}
// formatTime renders a timestamp in UTC using a fixed layout. The zero time is
// passed through as-is so the UI can recognise it and show "never" instead.
func formatTime(t time.Time) string {
return t.UTC().Format("2006-01-02 15:04:05")
}

View File

@@ -12,30 +12,12 @@ const (
)
// PeerInfo describe information about the peers. It designed for the UI usage
//
// The fields below ConnStatus back the peer detail screen. Durations and times
// are pre-formatted into strings so the UI does not have to know Go's layouts;
// Latency is additionally exposed as LatencyMs for colour coding.
type PeerInfo struct {
IP string
IPv6 string
FQDN string
ConnStatus int
Routes PeerRoutes
PubKey string
Latency string
LatencyMs int64
BytesRx int64
BytesTx int64
ConnStatusUpdate string
Relayed bool
RosenpassEnabled bool
LastWireguardHandshake string
LocalIceCandidateType string
RemoteIceCandidateType string
LocalIceCandidateEndpoint string
RemoteIceCandidateEndpoint string
}
func (p *PeerInfo) GetPeerRoutes() *PeerRoutes {

View File

@@ -189,19 +189,6 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
return nil
}
// RenameProfile changes a profile's display name. The profile ID, and therefore
// its on-disk filename, is left untouched: only the "name" field of the config
// is rewritten. This works for the default profile too, whose config lives in
// netbird.cfg rather than under profiles/.
func (pm *ProfileManager) RenameProfile(id string, newName string) error {
if err := pm.serviceMgr.RenameProfile(profilemanager.ID(id), androidUsername, newName); err != nil {
return fmt.Errorf("failed to rename profile: %w", err)
}
log.Infof("renamed profile %s to: %s", id, newName)
return nil
}
// RemoveProfile deletes a profile
func (pm *ProfileManager) RemoveProfile(id string) error {
// Use ServiceManager (removes profile from profiles/ directory)

View File

@@ -0,0 +1,70 @@
//go:build android
package android
import (
"fmt"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"github.com/netbirdio/netbird/client/internal/routemanager"
"github.com/netbirdio/netbird/route"
)
func executeRouteToggle(id string, manager routemanager.Manager,
operationName string,
routeOperation func(routes []route.NetID, allRoutes []route.NetID) error) error {
netID := route.NetID(id)
routes := []route.NetID{netID}
routesMap := manager.GetClientRoutesWithNetID()
routes = route.ExpandV6ExitPairs(routes, routesMap)
log.Debugf("%s with ids: %v", operationName, routes)
if err := routeOperation(routes, maps.Keys(routesMap)); err != nil {
log.Debugf("error when %s: %s", operationName, err)
return fmt.Errorf("error %s: %w", operationName, err)
}
manager.TriggerSelection(manager.GetClientRoutes())
return nil
}
type routeCommand interface {
toggleRoute() error
}
type selectRouteCommand struct {
route string
manager routemanager.Manager
}
func (s selectRouteCommand) toggleRoute() error {
routeSelector := s.manager.GetRouteSelector()
if routeSelector == nil {
return fmt.Errorf("no route selector available")
}
routeOperation := func(routes []route.NetID, allRoutes []route.NetID) error {
return routeSelector.SelectRoutes(routes, true, allRoutes)
}
return executeRouteToggle(s.route, s.manager, "selecting route", routeOperation)
}
type deselectRouteCommand struct {
route string
manager routemanager.Manager
}
func (d deselectRouteCommand) toggleRoute() error {
routeSelector := d.manager.GetRouteSelector()
if routeSelector == nil {
return fmt.Errorf("no route selector available")
}
return executeRouteToggle(d.route, d.manager, "deselecting route", routeSelector.DeselectRoutes)
}

View File

@@ -464,8 +464,6 @@ func Test_RemovePeer(t *testing.T) {
}
func Test_ConnectPeers(t *testing.T) {
t.Setenv("NB_DISABLE_EBPF_WG_PROXY", "true")
peer1ifaceName := fmt.Sprintf("utun%d", WgIntNumber+400)
peer1wgIP := netip.MustParsePrefix("10.99.99.17/30")
peer1Key, _ := wgtypes.GeneratePrivateKey()
@@ -507,8 +505,12 @@ func Test_ConnectPeers(t *testing.T) {
t.Fatal(err)
}
localIP1 := "127.0.0.1"
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP1, peer1wgPort))
localIP, err := getLocalIP()
if err != nil {
t.Fatal(err)
}
peer1endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer1wgPort))
if err != nil {
t.Fatal(err)
}
@@ -544,8 +546,7 @@ func Test_ConnectPeers(t *testing.T) {
t.Fatal(err)
}
localIP2 := "127.0.0.1"
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP2, peer2wgPort))
peer2endpoint, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", localIP, peer2wgPort))
if err != nil {
t.Fatal(err)
}
@@ -568,17 +569,17 @@ func Test_ConnectPeers(t *testing.T) {
if err != nil {
t.Fatal(err)
}
// The peers use userspace WireGuard (stdnet transport). A tight busy-loop
// here starves the wireguard-go goroutines that process the handshake, so
// poll on a ticker instead and yield the CPU between checks. WireGuard also
// only retries a lost handshake initiation every REKEY_TIMEOUT (5s), which
// is why the overall wait can occasionally stretch to tens of seconds.
// todo: investigate why in some tests execution we need 30s
timeout := 30 * time.Second
timeoutChannel := time.After(timeout)
ticker := time.NewTicker(500 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-timeoutChannel:
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
default:
}
peer, gpErr := getPeer(peer1ifaceName, peer2Key.PublicKey().String())
if gpErr != nil {
t.Fatal(gpErr)
@@ -587,12 +588,6 @@ func Test_ConnectPeers(t *testing.T) {
t.Log("peers successfully handshake")
break
}
select {
case <-timeoutChannel:
t.Fatalf("waiting for peer handshake timeout after %s", timeout.String())
case <-ticker.C:
}
}
}
@@ -620,3 +615,28 @@ func getPeer(ifaceName, peerPubKey string) (wgtypes.Peer, error) {
}
return wgtypes.Peer{}, fmt.Errorf("peer not found")
}
func getLocalIP() (string, error) {
// Get all interfaces
addrs, err := net.InterfaceAddrs()
if err != nil {
return "", err
}
for _, addr := range addrs {
ipNet, ok := addr.(*net.IPNet)
if !ok {
continue
}
if ipNet.IP.IsLoopback() {
continue
}
if ipNet.IP.To4() == nil {
continue
}
return ipNet.IP.String(), nil
}
return "", fmt.Errorf("no local IP found")
}

View File

@@ -351,7 +351,6 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
a.config.BlockLANAccess,
a.config.BlockInbound,
a.config.DisableIPv6,
a.config.SyncMessageVersion,
a.config.EnableSSHRoot,
a.config.EnableSSHSFTP,
a.config.EnableSSHLocalPortForwarding,

View File

@@ -24,7 +24,11 @@ import (
)
const (
maxPastHorizon = 30 * 24 * time.Hour
// Skew tolerates a small clock difference between the management
// server and this peer before treating a deadline as "in the past".
// Slightly above typical NTP drift; tight enough that the UI doesn't
// paint a stale expiry as if it were valid.
Skew = 30 * time.Second
// maxDeadlineHorizon caps how far in the future an accepted deadline
// can sit. A timestamp beyond this is almost certainly a protocol
@@ -53,7 +57,7 @@ var (
ErrDeadlineTooFarFuture = errors.New("session deadline too far in the future")
// ErrDeadlineInPast is returned by Update when the supplied deadline
// is more than maxPastHorizon in the past.
// is more than Skew in the past.
ErrDeadlineInPast = errors.New("session deadline in the past")
)
@@ -62,14 +66,15 @@ var (
// for deadline change/clear, PublishEvent for the two warnings); tests pass
// a fake recorder so the same surface is observable without an engine.
//
// While the watcher runs, it owns the deadline propagated to the recorder:
// every set, clear and sanity-check rejection routes the value through
// SetSessionExpiresAt, so the SubscribeStatus snapshot the UI reads can
// never drift from the watcher's timer state. (SetSessionExpiresAt fans
// out its own state-change notification, so no separate notify is needed.)
// The recorder is server-scoped and outlives this engine-scoped watcher;
// Close deliberately leaves the recorder value in place so transient engine
// restarts don't blank it — the client run loop clears it on real teardown.
// The watcher is the single owner of the deadline propagated to the
// recorder: every set, clear, sanity-check rejection and Close routes the
// value through SetSessionExpiresAt, so the SubscribeStatus snapshot the UI
// reads can never drift from the watcher's timer state. (SetSessionExpiresAt
// fans out its own state-change notification, so no separate notify is
// needed.) The recorder is server-scoped and outlives this engine-scoped
// watcher — without the Close-time clear a teardown (Down, or the Down+Up of
// a profile switch) would leave the next session showing the previous one's
// stale "expires in" value.
//
// PublishEvent's signature mirrors peer.Status.PublishEvent: the watcher
// composes the metadata internally so the wire format (MetaSession*) is
@@ -130,13 +135,10 @@ func NewWithLeads(lead, final time.Duration, recorder StatusRecorder) *Watcher {
// was disabled).
//
// Same-value updates are no-ops. A different non-zero value cancels any
// pending timer, resets the "already fired" guards, and — when the
// deadline lies in the future — arms fresh warning timers. A deadline
// already in the past (within maxPastHorizon) is recorded as-is with no
// timers: the session has expired and consumers render it that way.
// pending timer, resets the "already fired" guard, and arms a new one.
//
// Returns one of the sentinel Err* values when the deadline fails the
// sanity checks (pre-epoch, far future, or past beyond maxPastHorizon).
// sanity checks (pre-epoch, far future, or in the past beyond Skew).
// In every error case the watcher first clears its state so it stays
// consistent with what the caller will push into its other sinks (e.g.
// applySessionDeadline forces a zero deadline into the status recorder
@@ -161,7 +163,7 @@ func (w *Watcher) Update(deadline time.Time) error {
case deadline.After(now.Add(maxDeadlineHorizon)):
w.clearLocked()
return fmt.Errorf("%w: %v", ErrDeadlineTooFarFuture, deadline)
case deadline.Before(now.Add(-maxPastHorizon)):
case deadline.Before(now.Add(-Skew)):
w.clearLocked()
return fmt.Errorf("%w: %v (now=%v)", ErrDeadlineInPast, deadline, now)
}
@@ -181,9 +183,7 @@ func (w *Watcher) Update(deadline time.Time) error {
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
if deadline.After(now) {
w.armTimerLocked(deadline)
}
w.armTimerLocked(deadline)
recorder := w.recorder
w.mu.Unlock()
if recorder != nil {
@@ -227,25 +227,30 @@ func (w *Watcher) Dismiss() {
log.Infof("auth session final-warning dismissed for deadline %s", w.current.Format(time.RFC3339))
}
// Close stops any pending timer. Update calls after Close are ignored.
// The recorder keeps its deadline: the watcher is engine-scoped and closes
// on every engine restart (network change, sleep/wake, stream errors)
// while the SSO deadline stays valid across those, so clearing here would
// blank the UI's "expires in" row on every transient reconnect. The
// client run loop clears the server-scoped recorder when it exits for
// real (Down, profile switch, permanent login failure).
// Close stops any pending timer and drops the deadline on the status
// recorder. Update calls after Close are ignored. Clearing the recorder
// here is what keeps a teardown (Down, or the Down+Up of a profile switch)
// from leaving the next session showing this one's stale "expires in"
// value — the recorder is server-scoped and outlives this engine-scoped
// watcher, so nothing else drops the anchor on teardown.
func (w *Watcher) Close() {
w.mu.Lock()
defer w.mu.Unlock()
if w.closed {
w.mu.Unlock()
return
}
w.closed = true
w.stopTimerLocked()
hadDeadline := !w.current.IsZero()
w.current = time.Time{}
w.firedAt = time.Time{}
w.finalFiredAt = time.Time{}
w.dismissedAt = time.Time{}
recorder := w.recorder
w.mu.Unlock()
if recorder != nil && hadDeadline {
recorder.SetSessionExpiresAt(time.Time{})
}
}
// clearLocked drops the tracked deadline and notifies the recorder so

View File

@@ -224,13 +224,11 @@ func TestNewDeadlineCancelsPriorTimer(t *testing.T) {
func TestRefreshAfterFireArmsNewWarning(t *testing.T) {
r := &fakeRecorder{}
lead := 150 * time.Millisecond
lead := 30 * time.Millisecond
w := newWatcher(lead, r)
defer w.Close()
// Warning fires ~20ms in; the deadline itself stays 150ms away so the
// replacement below lands well before it.
first := time.Now().Add(170 * time.Millisecond)
first := time.Now().Add(50 * time.Millisecond)
_ = w.Update(first)
// Wait for stateChange + warning of the first cycle.
@@ -308,29 +306,7 @@ func TestUpdateRejectsTooFarFuture(t *testing.T) {
}
}
func TestUpdateRecentPastRecordedAsExpired(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
d := time.Now().Add(-1 * time.Hour)
if err := w.Update(d); err != nil {
t.Fatalf("recent-past Update should succeed, got %v", err)
}
if !w.Deadline().Equal(d) {
t.Fatalf("expected deadline to be recorded, got %v want %v", w.Deadline(), d)
}
if got := r.deadline(); !got.Equal(d) {
t.Fatalf("recorder deadline = %v, want %v", got, d)
}
time.Sleep(80 * time.Millisecond)
if n := countWhere(r.snapshot(), func(e event) bool { return e.kind == publish }); n != 0 {
t.Fatalf("no warning events may fire for an already-past deadline, got %+v", r.snapshot())
}
}
func TestUpdateAncientPastRejected(t *testing.T) {
func TestUpdateInPastClearsDeadline(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
@@ -342,12 +318,12 @@ func TestUpdateAncientPastRejected(t *testing.T) {
// Drain the stateChange from the seed.
waitForEvents(t, r, 1)
err := w.Update(time.Now().Add(-31 * 24 * time.Hour))
err := w.Update(time.Now().Add(-1 * time.Hour))
if !errors.Is(err, ErrDeadlineInPast) {
t.Fatalf("want ErrDeadlineInPast, got %v", err)
}
if !w.Deadline().IsZero() {
t.Fatalf("rejected ancient-past update must clear the deadline, got %v", w.Deadline())
t.Fatalf("in-past update must clear the deadline, got %v", w.Deadline())
}
events := waitForEvents(t, r, 2)
if events[1].kind != stateChange {
@@ -355,25 +331,39 @@ func TestUpdateAncientPastRejected(t *testing.T) {
}
}
func TestUpdateWithinSkewAccepted(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
defer w.Close()
// 5 seconds in the past is within the 30s Skew tolerance — accept it.
d := time.Now().Add(-5 * time.Second)
if err := w.Update(d); err != nil {
t.Fatalf("within-skew Update should succeed, got %v", err)
}
if !w.Deadline().Equal(d) {
t.Fatalf("expected deadline to be applied, got %v want %v", w.Deadline(), d)
}
}
func TestCloseSilencesUpdates(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(50*time.Millisecond, r)
w.Close()
if err := w.Update(time.Now().Add(time.Hour)); err != nil {
t.Fatalf("Update after Close: want nil, got %v", err)
}
_ = w.Update(time.Now().Add(time.Hour))
time.Sleep(20 * time.Millisecond)
if got := r.snapshot(); len(got) != 0 {
t.Fatalf("expected no events after Close, got %+v", got)
}
}
// TestCloseKeepsRecorderDeadline pins the reconnect-flap fix: the watcher
// closes on every engine restart (network change, sleep/wake) while the
// SSO deadline stays valid across those, so Close must leave the
// server-scoped recorder's value in place. The client run loop clears the
// recorder when it exits for real.
func TestCloseKeepsRecorderDeadline(t *testing.T) {
// TestCloseClearsRecorderDeadline pins the profile-switch fix: a watcher
// holding a live deadline must zero the recorder on Close so the next
// engine's watcher (and the UI reading the shared server-scoped recorder)
// doesn't start out showing the previous session's stale "expires in".
func TestCloseClearsRecorderDeadline(t *testing.T) {
r := &fakeRecorder{}
w := newWatcher(time.Hour, r)
@@ -387,8 +377,8 @@ func TestCloseKeepsRecorderDeadline(t *testing.T) {
w.Close()
if got := r.deadline(); !got.Equal(d) {
t.Fatalf("recorder deadline after Close = %v, want %v", got, d)
if got := r.deadline(); !got.IsZero() {
t.Fatalf("recorder deadline after Close = %v, want zero", got)
}
}

View File

@@ -34,8 +34,6 @@ const (
// - Handling connection establishment based on peer signaling
//
// The implementation is not thread-safe; it is protected by engine.syncMsgMux.
// The only exception is ActivatePeer, which is safe for concurrent use so the
// DNS warm-up path can call it without contending on the engine mutex.
type ConnMgr struct {
peerStore *peerstore.Store
statusRecorder *peer.Status
@@ -44,26 +42,12 @@ type ConnMgr struct {
rosenpassEnabled bool
lazyConnMgr *manager.Manager
// lazyConnMgrMu guards the lazyConnMgr pointer for readers outside the
// engine loop (ActivatePeer). Writers hold it in addition to
// engine.syncMsgMux; all other reads stay under engine.syncMsgMux only.
lazyConnMgrMu sync.RWMutex
// reconcileRoutedIPs re-applies a peer's routed allowed IPs after its lazy wake endpoint is
// (re)armed (Mode A at arm time). Injected by the engine; nil disables the reconcile.
reconcileRoutedIPs func(peerKey string) error
wg sync.WaitGroup
lazyCtx context.Context
lazyCtxCancel context.CancelFunc
}
// SetRoutedIPsReconciler injects the callback used to re-apply a peer's routed allowed IPs when
// its lazy wake endpoint is (re)armed. Must be called before the lazy manager starts.
func (e *ConnMgr) SetRoutedIPsReconciler(fn func(peerKey string) error) {
e.reconcileRoutedIPs = fn
}
func NewConnMgr(engineConfig *EngineConfig, statusRecorder *peer.Status, peerStore *peerstore.Store, iface lazyconn.WGIface) *ConnMgr {
e := &ConnMgr{
peerStore: peerStore,
@@ -254,20 +238,12 @@ func (e *ConnMgr) RemovePeerConn(peerKey string) {
conn.Log.Infof("removed peer from lazy conn manager")
}
// ActivatePeer wakes an idle lazy connection. Unlike the rest of ConnMgr it is
// safe for concurrent use: the lazy manager pointer is read under lazyConnMgrMu
// and the manager itself is internally synchronized, so callers outside the
// engine loop (DNS warm-up) do not need engine.syncMsgMux.
func (e *ConnMgr) ActivatePeer(ctx context.Context, conn *peer.Conn) {
e.lazyConnMgrMu.RLock()
lazyConnMgr := e.lazyConnMgr
started := lazyConnMgr != nil && e.lazyCtxCancel != nil
e.lazyConnMgrMu.RUnlock()
if !started {
if !e.isStartedWithLazyMgr() {
return
}
if found := lazyConnMgr.ActivatePeer(conn.GetKey()); found {
if found := e.lazyConnMgr.ActivatePeer(conn.GetKey()); found {
if err := conn.Open(ctx); err != nil {
conn.Log.Errorf("failed to open connection: %v", err)
}
@@ -292,22 +268,16 @@ func (e *ConnMgr) Close() {
e.lazyCtxCancel()
e.wg.Wait()
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil
e.lazyConnMgrMu.Unlock()
}
func (e *ConnMgr) initLazyManager(engineCtx context.Context) {
cfg := manager.Config{
InactivityThreshold: inactivityThresholdEnv(),
ReconcileAllowedIPs: e.reconcileRoutedIPs,
}
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = manager.NewManager(cfg, engineCtx, e.peerStore, e.iface)
e.lazyCtx, e.lazyCtxCancel = context.WithCancel(engineCtx)
e.lazyConnMgrMu.Unlock()
e.wg.Add(1)
go func() {
@@ -346,10 +316,7 @@ func (e *ConnMgr) closeManager(ctx context.Context) {
e.lazyCtxCancel()
e.wg.Wait()
e.lazyConnMgrMu.Lock()
e.lazyConnMgr = nil
e.lazyConnMgrMu.Unlock()
for _, peerID := range e.peerStore.PeersPubKey() {
e.peerStore.PeerConnOpen(ctx, peerID)
@@ -385,20 +352,11 @@ func inactivityThresholdEnv() *time.Duration {
return nil
}
// Documented format: a Go duration such as "30m" or "1h".
if d, err := time.ParseDuration(envValue); err == nil {
if d <= 0 {
return nil
}
return &d
parsedMinutes, err := strconv.Atoi(envValue)
if err != nil || parsedMinutes <= 0 {
return nil
}
// Backwards compatibility: a bare integer used to be interpreted as minutes.
if parsedMinutes, err := strconv.Atoi(envValue); err == nil && parsedMinutes > 0 {
d := time.Duration(parsedMinutes) * time.Minute
return &d
}
log.Warnf("invalid %s value %q: expected a Go duration such as 30m or 1h", lazyconn.EnvInactivityThreshold, envValue)
return nil
d := time.Duration(parsedMinutes) * time.Minute
return &d
}

View File

@@ -1,21 +1,10 @@
package internal
import (
"context"
"net"
"net/netip"
"os"
"sync"
"testing"
"time"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/lazyconn"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore"
"github.com/netbirdio/netbird/monotime"
)
func TestResolveLazyForce(t *testing.T) {
@@ -49,93 +38,3 @@ func TestResolveLazyForce(t *testing.T) {
})
}
}
type mockLazyWGIface struct{}
func (mockLazyWGIface) RemovePeer(string) error { return nil }
func (mockLazyWGIface) UpdatePeer(string, []netip.Prefix, time.Duration, *net.UDPAddr, *wgtypes.Key) error {
return nil
}
func (mockLazyWGIface) IsUserspaceBind() bool { return false }
func (mockLazyWGIface) Address() wgaddr.Address { return wgaddr.Address{} }
func (mockLazyWGIface) LastActivities() map[string]monotime.Time { return nil }
func (mockLazyWGIface) MTU() uint16 { return 1280 }
// TestConnMgr_ActivatePeerConcurrentWithLifecycle exercises ActivatePeer from
// non-engine goroutines (the DNS warm-up path) racing the manager lifecycle,
// which stays on the engine loop. Run with -race: it fails if ActivatePeer
// still requires engine.syncMsgMux for safety.
func TestConnMgr_ActivatePeerConcurrentWithLifecycle(t *testing.T) {
t.Setenv(lazyconn.EnvLazyConn, "on")
status := peer.NewRecorder("https://mgm")
store := peerstore.NewConnStore()
connMgr := NewConnMgr(&EngineConfig{}, status, store, mockLazyWGIface{})
conn := newTestPeerConn(t, "peerA")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
connMgr.Start(ctx)
done := make(chan struct{})
var wg sync.WaitGroup
for range 4 {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-done:
return
default:
connMgr.ActivatePeer(ctx, conn)
}
}
}()
}
// Let the activators spin against the started manager, then tear it down
// underneath them and let them spin against the stopped manager.
time.Sleep(100 * time.Millisecond)
connMgr.Close()
time.Sleep(50 * time.Millisecond)
close(done)
wg.Wait()
}
func TestInactivityThresholdEnv(t *testing.T) {
tests := []struct {
name string
val string
want *time.Duration
}{
{name: "unset", val: "", want: nil},
{name: "go duration minutes", val: "30m", want: durPtr(30 * time.Minute)},
{name: "go duration hours", val: "1h", want: durPtr(time.Hour)},
{name: "go duration seconds", val: "90s", want: durPtr(90 * time.Second)},
{name: "bare integer is minutes (backwards compat)", val: "5", want: durPtr(5 * time.Minute)},
{name: "zero duration", val: "0s", want: nil},
{name: "zero integer", val: "0", want: nil},
{name: "negative duration", val: "-5m", want: nil},
{name: "garbage", val: "abc", want: nil},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(lazyconn.EnvInactivityThreshold, tc.val)
got := inactivityThresholdEnv()
switch {
case tc.want == nil && got != nil:
t.Fatalf("want nil, got %v", *got)
case tc.want != nil && got == nil:
t.Fatalf("want %v, got nil", *tc.want)
case tc.want != nil && *got != *tc.want:
t.Fatalf("want %v, got %v", *tc.want, *got)
}
})
}
}
func durPtr(d time.Duration) *time.Duration { return &d }

View File

@@ -34,7 +34,6 @@ import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/client/internal/tunnelnotifier"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/internal/updater/installer"
nbnet "github.com/netbirdio/netbird/client/net"
@@ -137,13 +136,10 @@ func (c *ConnectClient) RunOniOS(
// Set GC percent to 5% to reduce memory usage as iOS only allows 50MB of memory for the extension.
debug.SetGCPercent(5)
notifier := tunnelnotifier.New(networkChangeListener, dnsManager)
defer notifier.Close()
mobileDependency := MobileDependency{
FileDescriptor: fileDescriptor,
NetworkChangeListener: notifier,
DnsManager: notifier,
NetworkChangeListener: networkChangeListener,
DnsManager: dnsManager,
StateFilePath: stateFilePath,
TempDir: cacheDir,
}
@@ -261,10 +257,7 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
log.Errorf("failed to clean up temporary installer file: %v", err)
}
defer func() {
c.statusRecorder.SetSessionExpiresAt(time.Time{})
c.statusRecorder.ClientStop()
}()
defer c.statusRecorder.ClientStop()
operation := func() error {
// if context cancelled we not start new backoff cycle
if c.ctx.Err() != nil {
@@ -625,7 +618,6 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
BlockLANAccess: config.BlockLANAccess,
BlockInbound: config.BlockInbound,
DisableIPv6: config.DisableIPv6,
SyncMessageVersion: config.SyncMessageVersion,
LazyConnection: lazyconn.ParseState(config.LazyConnection),
@@ -701,7 +693,6 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
config.BlockLANAccess,
config.BlockInbound,
config.DisableIPv6,
config.SyncMessageVersion,
config.EnableSSHRoot,
config.EnableSSHSFTP,
config.EnableSSHLocalPortForwarding,

View File

@@ -676,7 +676,6 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
configContent.WriteString(fmt.Sprintf("BlockLANAccess: %v\n", g.internalConfig.BlockLANAccess))
configContent.WriteString(fmt.Sprintf("BlockInbound: %v\n", g.internalConfig.BlockInbound))
configContent.WriteString(fmt.Sprintf("DisableIPv6: %v\n", g.internalConfig.DisableIPv6))
configContent.WriteString(fmt.Sprintf("SyncMessageVersion: %v\n", g.internalConfig.SyncMessageVersion))
if g.internalConfig.DisableNotifications != nil {
configContent.WriteString(fmt.Sprintf("DisableNotifications: %v\n", *g.internalConfig.DisableNotifications))

View File

@@ -887,8 +887,6 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
ClientCertKeyPath: "/tmp/key",
LazyConnection: "on",
MTU: 1280,
DisableIPv6: true,
SyncMessageVersion: func(v int) *int { return &v }(1),
}
for _, anonymize := range []bool{false, true} {

View File

@@ -6,7 +6,6 @@ import (
"fmt"
"net"
"net/netip"
"os"
"slices"
"strings"
"sync"
@@ -37,43 +36,7 @@ type resolver interface {
// record is left alone (it points at something outside our mesh, e.g.
// a non-peer upstream).
type PeerConnectivity interface {
IsConnectedByIP(ip netip.Addr) (known, connected bool)
}
// PeerActivator wakes lazy-connection peers on demand. The local resolver calls
// it with the tunnel IPs an answer points at, so a peer that is idle (lazily
// disconnected) starts connecting at DNS-resolution time rather than racing the
// client's first request packet. nil disables warm-up.
type PeerActivator interface {
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and blocks
// until one is connected or ctx (a short per-query budget) expires. It is a
// fast no-op for unknown or already-connected addresses.
ActivatePeersByIP(ctx context.Context, addrs []netip.Addr)
}
const (
defaultLazyWarmupTimeout = 2 * time.Second
envLazyWarmupTimeout = "NB_DNS_LAZY_WARMUP_TIMEOUT"
)
// lazyWarmupTimeoutFromEnv returns the per-query budget for waking a
// lazy-connection peer a DNS answer points at. Tunable via
// NB_DNS_LAZY_WARMUP_TIMEOUT (a Go duration). Parsed once at construction time.
func lazyWarmupTimeoutFromEnv() time.Duration {
v := os.Getenv(envLazyWarmupTimeout)
if v == "" {
return defaultLazyWarmupTimeout
}
d, err := time.ParseDuration(v)
if err != nil {
log.Warnf("invalid %s value %q, using default %s: %v", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout, err)
return defaultLazyWarmupTimeout
}
if d <= 0 {
log.Warnf("non-positive %s value %q, using default %s", envLazyWarmupTimeout, v, defaultLazyWarmupTimeout)
return defaultLazyWarmupTimeout
}
return d
IsConnectedByIP(ip string) (known, connected bool)
}
type Resolver struct {
@@ -88,12 +51,6 @@ type Resolver struct {
// filter and preserves the legacy "return whatever is registered"
// behaviour for callers that never wire a status source.
peerConn PeerConnectivity
// peerActivator, when non-nil, is called at resolution time to warm the
// lazy connection to the peer(s) an answer points at. nil disables warm-up.
peerActivator PeerActivator
// warmupTimeout is the per-query budget for the lazy-connection warm-up
// wait, resolved from the environment once at construction time.
warmupTimeout time.Duration
ctx context.Context
cancel context.CancelFunc
@@ -102,12 +59,11 @@ type Resolver struct {
func NewResolver() *Resolver {
ctx, cancel := context.WithCancel(context.Background())
return &Resolver{
records: make(map[dns.Question][]dns.RR),
domains: make(map[domain.Domain]struct{}),
zones: make(map[domain.Domain]bool),
warmupTimeout: lazyWarmupTimeoutFromEnv(),
ctx: ctx,
cancel: cancel,
records: make(map[dns.Question][]dns.RR),
domains: make(map[domain.Domain]struct{}),
zones: make(map[domain.Domain]bool),
ctx: ctx,
cancel: cancel,
}
}
@@ -120,14 +76,6 @@ func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
d.peerConn = p
}
// SetPeerActivator wires the DNS-time lazy-connection warm-up. Pass nil to
// disable. Safe to call multiple times; the latest value wins.
func (d *Resolver) SetPeerActivator(a PeerActivator) {
d.mu.Lock()
defer d.mu.Unlock()
d.peerActivator = a
}
func (d *Resolver) MatchSubdomains() bool {
return true
}
@@ -174,9 +122,6 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
replyMessage.RecursionAvailable = true
result := d.lookupRecords(logger, question)
// Warm before filtering: activation flips a lazily-idle target to connected,
// which then lets it survive the disconnected-peer filter below.
d.warmLazyPeers(question, result.records)
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
replyMessage.Authoritative = !result.hasExternalData
replyMessage.Answer = result.records
@@ -550,8 +495,8 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
kept := make([]dns.RR, 0, len(records))
var dropped int
for _, rr := range records {
ip, ok := extractRecordAddr(rr)
if !ok {
ip := extractRecordIP(rr)
if ip == "" {
kept = append(kept, rr)
continue
}
@@ -573,57 +518,22 @@ func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns
return kept
}
// warmLazyPeers triggers lazy-connection wake-up for the peers a resolved
// answer points at and waits briefly for one to connect, so the caller's first
// request doesn't race the connection establishment. Warm-up is scoped to
// match-only (non-authoritative) zones — the synthesized private-service zones
// and user-created zones whose records point at specific peers. The account's
// peer zone is authoritative, so plain peer-name lookups never trigger warm-up;
// otherwise resolving any peer's name would wake its idle connection, defeating
// laziness mesh-wide. No-op when no activator is wired (lazy connections
// disabled) or the answer carries no peer IPs.
func (d *Resolver) warmLazyPeers(question dns.Question, records []dns.RR) {
if len(records) < 2 {
return
}
d.mu.RLock()
activator := d.peerActivator
var nonAuth, found bool
if activator != nil {
nonAuth, found = d.findZone(question.Name)
}
d.mu.RUnlock()
if activator == nil || !found || !nonAuth {
return
}
var addrs []netip.Addr
for _, rr := range records {
if addr, ok := extractRecordAddr(rr); ok {
addrs = append(addrs, addr)
}
}
if len(addrs) == 0 {
return
}
ctx, cancel := context.WithTimeout(d.ctx, d.warmupTimeout)
defer cancel()
activator.ActivatePeersByIP(ctx, addrs)
}
// extractRecordAddr returns the IP address carried by an A or AAAA record.
// ok is false for any other record type or a record with no address.
func extractRecordAddr(rr dns.RR) (netip.Addr, bool) {
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
// an A or AAAA record, or "" for any other record type.
func extractRecordIP(rr dns.RR) string {
switch r := rr.(type) {
case *dns.A:
addr, ok := netip.AddrFromSlice(r.A)
return addr.Unmap(), ok
if r.A == nil {
return ""
}
return r.A.String()
case *dns.AAAA:
addr, ok := netip.AddrFromSlice(r.AAAA)
return addr.Unmap(), ok
if r.AAAA == nil {
return ""
}
return r.AAAA.String()
}
return netip.Addr{}, false
return ""
}
// Update replaces all zones and their records

View File

@@ -37,8 +37,8 @@ type mockPeerConnectivity struct {
byIP map[string]struct{ known, connected bool }
}
func (m mockPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
v, ok := m.byIP[ip.String()]
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
v, ok := m.byIP[ip]
if !ok {
return false, false
}

View File

@@ -1,204 +0,0 @@
package local
import (
"context"
"net"
"net/netip"
"sync"
"testing"
"time"
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/dns/test"
nbdns "github.com/netbirdio/netbird/dns"
)
// recordingActivator records the addresses it was asked to warm and returns
// immediately, so ServeDNS is not blocked by the test.
type recordingActivator struct {
mu sync.Mutex
called bool
addrs []netip.Addr
}
func (r *recordingActivator) ActivatePeersByIP(_ context.Context, addrs []netip.Addr) {
r.mu.Lock()
defer r.mu.Unlock()
r.called = true
r.addrs = append(r.addrs, addrs...)
}
func serveA(t *testing.T, resolver *Resolver, name string) *dns.Msg {
t.Helper()
var resp *dns.Msg
w := &test.MockResponseWriter{WriteMsgFunc: func(m *dns.Msg) error { resp = m; return nil }}
resolver.ServeDNS(w, new(dns.Msg).SetQuestion(name, dns.TypeA))
return resp
}
// serviceZone registers rec in a match-only (non-authoritative) zone, the shape
// the synthesized private-service zones arrive in.
func serviceZone(t *testing.T, resolver *Resolver, zone string, records ...nbdns.SimpleRecord) {
t.Helper()
resolver.Update([]nbdns.CustomZone{{
Domain: zone,
Records: records,
NonAuthoritative: true,
}})
}
func TestLocalResolver_WarmsLazyPeerOnResolve(t *testing.T) {
// Warm-up fires only for multi-record answers (the HA / round-robin shape of
// the synthesized private-service zones), so register two peer targets.
const name = "svc.proxy.netbird.cloud."
recs := []nbdns.SimpleRecord{
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"},
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.8"},
}
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud", recs...)
act := &recordingActivator{}
resolver.SetPeerActivator(act)
resp := serveA(t, resolver, name)
require.NotNil(t, resp, "resolver must answer")
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
act.mu.Lock()
defer act.mu.Unlock()
assert.True(t, act.called, "activator must be invoked for a multi-record service-zone answer")
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.7"), "activator must receive the first peer IP")
assert.Contains(t, act.addrs, netip.MustParseAddr("100.64.0.8"), "activator must receive the second peer IP")
}
func TestLocalResolver_NoWarmupForSingleRecord(t *testing.T) {
// A single-record answer does not trigger warm-up; the resolver only warms
// multi-record answers.
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
act := &recordingActivator{}
resolver.SetPeerActivator(act)
resp := serveA(t, resolver, rec.Name)
require.NotNil(t, resp, "resolver must answer")
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
act.mu.Lock()
defer act.mu.Unlock()
assert.False(t, act.called, "activator must not be invoked for a single-record answer")
}
func TestLocalResolver_NoActivatorNoWarmup(t *testing.T) {
// With no activator wired the resolver behaves exactly as before.
rec := nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"}
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud", rec)
resp := serveA(t, resolver, rec.Name)
require.NotNil(t, resp, "resolver must still answer without an activator")
require.NotEmpty(t, resp.Answer, "answer must carry the A record")
}
func TestLocalResolver_NoWarmupForMissingRecord(t *testing.T) {
// A query that resolves to nothing must not invoke the activator (no IPs).
resolver := NewResolver()
serviceZone(t, resolver, "proxy.netbird.cloud",
nbdns.SimpleRecord{Name: "svc.proxy.netbird.cloud.", Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.7"})
act := &recordingActivator{}
resolver.SetPeerActivator(act)
serveA(t, resolver, "absent.proxy.netbird.cloud.")
act.mu.Lock()
defer act.mu.Unlock()
assert.False(t, act.called, "activator must not be invoked when there is no answer")
}
func TestLocalResolver_NoWarmupInAuthoritativeZone(t *testing.T) {
// The account's peer zone is authoritative; resolving a peer's name there
// must not wake its lazy connection — warm-up is scoped to match-only
// (non-authoritative) zones such as the synthesized private-service zones.
// Use a multi-record answer so the authoritative-zone scoping is the only
// reason warm-up is skipped, not the single-record guard.
const name = "peer.netbird.cloud."
recs := []nbdns.SimpleRecord{
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.9"},
{Name: name, Type: 1, Class: nbdns.DefaultClass, TTL: 300, RData: "100.64.0.10"},
}
resolver := NewResolver()
resolver.Update([]nbdns.CustomZone{{
Domain: "netbird.cloud",
Records: recs,
}})
act := &recordingActivator{}
resolver.SetPeerActivator(act)
resp := serveA(t, resolver, name)
require.NotNil(t, resp, "resolver must answer")
require.NotEmpty(t, resp.Answer, "answer must carry the A records")
act.mu.Lock()
defer act.mu.Unlock()
assert.False(t, act.called, "activator must not be invoked for authoritative-zone answers")
}
func TestLazyWarmupTimeoutFromEnv(t *testing.T) {
tests := []struct {
name string
value string
envSet bool
want time.Duration
}{
{name: "unset uses default", want: defaultLazyWarmupTimeout},
{name: "valid overrides", value: "5s", envSet: true, want: 5 * time.Second},
{name: "invalid falls back", value: "not-a-duration", envSet: true, want: defaultLazyWarmupTimeout},
{name: "negative falls back", value: "-1s", envSet: true, want: defaultLazyWarmupTimeout},
{name: "zero falls back", value: "0s", envSet: true, want: defaultLazyWarmupTimeout},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envSet {
t.Setenv(envLazyWarmupTimeout, tt.value)
}
assert.Equal(t, tt.want, lazyWarmupTimeoutFromEnv())
assert.Equal(t, tt.want, NewResolver().warmupTimeout, "constructor must resolve the timeout once")
})
}
}
func TestExtractRecordAddr(t *testing.T) {
t.Run("A record yields unmapped v4", func(t *testing.T) {
// net.ParseIP returns the 16-byte v4-in-v6 form, the same shape
// miekg/dns stores after parsing an A record; the extracted address
// must compare equal to a plain v4 netip.Addr.
addr, ok := extractRecordAddr(&dns.A{A: net.ParseIP("100.64.0.7")})
require.True(t, ok)
assert.True(t, addr.Is4())
assert.Equal(t, netip.MustParseAddr("100.64.0.7"), addr)
})
t.Run("AAAA record yields v6", func(t *testing.T) {
addr, ok := extractRecordAddr(&dns.AAAA{AAAA: net.ParseIP("fd00::1")})
require.True(t, ok)
assert.Equal(t, netip.MustParseAddr("fd00::1"), addr)
})
t.Run("A record without address", func(t *testing.T) {
_, ok := extractRecordAddr(&dns.A{})
assert.False(t, ok)
})
t.Run("non-address record", func(t *testing.T) {
_, ok := extractRecordAddr(&dns.CNAME{Target: "target.netbird.cloud."})
assert.False(t, ok)
})
}

View File

@@ -8,7 +8,6 @@ import (
"github.com/miekg/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
"github.com/netbirdio/netbird/client/internal/dns/local"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/route"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -93,11 +92,6 @@ func (m *MockServer) SetFirewall(Firewall) {
// Mock implementation - no-op
}
// SetPeerActivator mock implementation of SetPeerActivator from Server interface
func (m *MockServer) SetPeerActivator(local.PeerActivator) {
// Mock implementation - no-op
}
// BeginBatch mock implementation of BeginBatch from Server interface
func (m *MockServer) BeginBatch() {
// Mock implementation - no-op

View File

@@ -82,7 +82,6 @@ type Server interface {
PopulateManagementDomain(mgmtURL *url.URL) error
SetRouteSources(selected, active func() route.HAMap)
SetFirewall(Firewall)
SetPeerActivator(local.PeerActivator)
}
type nsGroupsByDomain struct {
@@ -492,13 +491,6 @@ func (s *DefaultServer) SetFirewall(fw Firewall) {
}
}
// SetPeerActivator wires the DNS-time lazy-connection warm-up on the local
// resolver. Injected after the connection manager exists (it does not at
// DNS-server construction time). Pass nil to disable.
func (s *DefaultServer) SetPeerActivator(a local.PeerActivator) {
s.localResolver.SetPeerActivator(a)
}
// Stop stops the server
func (s *DefaultServer) Stop() {
s.ctxCancel()
@@ -1443,11 +1435,11 @@ type localPeerConnectivity struct {
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
func (l localPeerConnectivity) IsConnectedByIP(ip netip.Addr) (known, connected bool) {
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
if l.status == nil {
return false, false
}
state, ok := l.status.PeerStateByIP(ip.String())
state, ok := l.status.PeerStateByIP(ip)
if !ok {
return false, false
}

View File

@@ -1,76 +0,0 @@
package internal
import (
"context"
"net/netip"
"time"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore"
)
const dnsActivationPollInterval = 50 * time.Millisecond
// dnsPeerActivator wakes lazy-connection peers from the DNS resolution path. It
// implements dns/local.PeerActivator. DNS queries run on their own goroutines,
// so it only touches state that is safe for concurrent use — ConnMgr.ActivatePeer,
// peerstore.Store and peer.Status — and never takes the engine's syncMsgMux,
// keeping DNS resolution from contending with network-map processing.
type dnsPeerActivator struct {
connMgr *ConnMgr
peerStore *peerstore.Store
status *peer.Status
// ctx is the engine's long-lived context. The connection dial is tied to it
// (not the per-query DNS wait budget) so a handshake that outlasts the wait
// still completes in the background rather than being cancelled at the deadline.
ctx context.Context
}
// ActivatePeersByIP triggers wake-up for the peer(s) owning addrs and waits
// until one is connected or ctx (the per-query DNS wait budget) expires.
// Activation itself is tied to the engine's long-lived context so the dial
// survives a wait that times out. Unknown or already-connected addresses are
// skipped, so the steady-state (warm) path adds no latency.
func (a *dnsPeerActivator) ActivatePeersByIP(ctx context.Context, addrs []netip.Addr) {
if a == nil || a.connMgr == nil {
return
}
var pending []string
for _, addr := range addrs {
ip := addr.String()
st, ok := a.status.PeerStateByIP(ip)
if !ok || st.ConnStatus == peer.StatusConnected {
continue
}
conn, ok := a.peerStore.PeerConn(st.PubKey)
if !ok {
continue
}
a.connMgr.ActivatePeer(a.ctx, conn)
pending = append(pending, ip)
}
if len(pending) == 0 {
return
}
a.waitConnected(ctx, pending)
}
// waitConnected blocks until any of ips reports a connected peer or ctx expires.
func (a *dnsPeerActivator) waitConnected(ctx context.Context, ips []string) {
ticker := time.NewTicker(dnsActivationPollInterval)
defer ticker.Stop()
for {
for _, ip := range ips {
if st, ok := a.status.PeerStateByIP(ip); ok && st.ConnStatus == peer.StatusConnected {
return
}
}
select {
case <-ctx.Done():
return
case <-ticker.C:
}
}
}

View File

@@ -1,129 +0,0 @@
package internal
import (
"context"
"net/netip"
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/peerstore"
)
func newTestPeerConn(t *testing.T, key string) *peer.Conn {
t.Helper()
conn, err := peer.NewConn(peer.ConnConfig{
Key: key,
LocalKey: "local",
WgConfig: peer.WgConfig{
AllowedIps: []netip.Prefix{netip.MustParsePrefix("100.64.0.1/32")},
},
}, peer.ServiceDependencies{})
require.NoError(t, err)
return conn
}
func newTestDNSPeerActivator(t *testing.T) (*dnsPeerActivator, *peer.Status, *peerstore.Store) {
t.Helper()
status := peer.NewRecorder("https://mgm")
store := peerstore.NewConnStore()
// ConnMgr without Start: the lazy manager is nil, so ActivatePeer is a
// no-op — these tests exercise the activator's skip/wait logic.
connMgr := NewConnMgr(&EngineConfig{}, status, store, nil)
return &dnsPeerActivator{
connMgr: connMgr,
peerStore: store,
status: status,
ctx: context.Background(),
}, status, store
}
func TestDNSPeerActivator_NilSafe(t *testing.T) {
var a *dnsPeerActivator
a.ActivatePeersByIP(context.Background(), []netip.Addr{netip.MustParseAddr("100.64.0.1")})
}
// TestDNSPeerActivator_SkipsUnknownAndConnectedPeers verifies the steady-state
// (warm) path adds no latency: already-connected and unknown addresses never
// enter the wait loop.
func TestDNSPeerActivator_SkipsUnknownAndConnectedPeers(t *testing.T) {
a, status, store := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", "fd00::1"))
require.NoError(t, status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected}))
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{
netip.MustParseAddr("100.64.0.1"), // known, connected -> skipped
netip.MustParseAddr("fd00::1"), // known via IPv6, connected -> skipped
netip.MustParseAddr("100.64.0.99"), // unknown -> skipped
})
require.Less(t, time.Since(start), time.Second, "no pending peer must mean no wait")
}
// TestDNSPeerActivator_WaitsForPendingPeerToConnect verifies the wait loop
// returns as soon as a pending peer reports connected, well before the
// per-query budget expires.
func TestDNSPeerActivator_WaitsForPendingPeerToConnect(t *testing.T) {
a, status, store := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
go func() {
time.Sleep(150 * time.Millisecond)
_ = status.UpdatePeerState(peer.State{PubKey: "peerA", ConnStatus: peer.StatusConnected})
}()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
elapsed := time.Since(start)
require.GreaterOrEqual(t, elapsed, 100*time.Millisecond, "must wait for the pending peer")
require.Less(t, elapsed, 5*time.Second, "must return on connect, not at the deadline")
}
// TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle verifies a peer that
// never connects releases the DNS response at the per-query budget instead of
// blocking it indefinitely.
func TestDNSPeerActivator_ReturnsAtBudgetWhenPeerStaysIdle(t *testing.T) {
a, status, store := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
store.AddPeerConn("peerA", newTestPeerConn(t, "peerA"))
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
elapsed := time.Since(start)
require.GreaterOrEqual(t, elapsed, 250*time.Millisecond, "must wait out the budget for a pending peer")
require.Less(t, elapsed, 5*time.Second, "must not block past the budget")
}
// TestDNSPeerActivator_NoWaitWithoutPeerConn verifies a known-but-idle peer
// with no connection object in the store is not waited on: there is nothing to
// activate, so waiting could only ever time out.
func TestDNSPeerActivator_NoWaitWithoutPeerConn(t *testing.T) {
a, status, _ := newTestDNSPeerActivator(t)
require.NoError(t, status.AddPeer("peerA", "a.netbird.cloud", "100.64.0.1", ""))
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
start := time.Now()
a.ActivatePeersByIP(ctx, []netip.Addr{netip.MustParseAddr("100.64.0.1")})
require.Less(t, time.Since(start), time.Second, "peer without a conn must not be waited on")
}

View File

@@ -52,14 +52,11 @@ int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
udp->dest = dns_port;
// Clear the now-stale checksum; zero means "not computed" for IPv4.
udp->check = 0;
return XDP_PASS;
}
if (udp->source == dns_port && ip->saddr == dns_ip) {
udp->source = GENERAL_DNS_PORT;
udp->check = 0;
return XDP_PASS;
}

View File

@@ -50,11 +50,5 @@ int xdp_wg_proxy(struct iphdr *ip, struct udphdr *udp) {
__be16 new_dst_port = htons(proxy_port);
udp->dest = new_dst_port;
udp->source = new_src_port;
// The ports are covered by the UDP checksum. This is an IPv4 loopback hop
// and the payload is already integrity-protected, so clear the checksum (a
// zero UDP checksum means "not computed" for IPv4) rather than leave a
// stale value the kernel would drop as UDP_CSUM.
udp->check = 0;
return XDP_PASS;
}

View File

@@ -64,10 +64,7 @@ import (
"github.com/netbirdio/netbird/route"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
sharedgrpc "github.com/netbirdio/netbird/shared/management/grpc"
nbnetworkmap "github.com/netbirdio/netbird/shared/management/networkmap"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
types "github.com/netbirdio/netbird/shared/management/types"
"github.com/netbirdio/netbird/shared/netiputil"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
@@ -150,7 +147,6 @@ type EngineConfig struct {
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
SyncMessageVersion *int
// LazyConnection is the MDM-sourced lazy-connection override; StateUnset defers to
// the env var and management feature flag.
@@ -224,13 +220,6 @@ type Engine struct {
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
networkSerial uint64
// latestComponents is the most-recent NetworkMapComponents decoded from
// a NetworkMapEnvelope (capability=3 peers only). Held alongside the
// NetworkMap that Calculate() produced from it so future incremental
// updates have a base to apply changes against. nil for legacy-format
// peers. Guarded by syncMsgMux.
latestComponents *types.NetworkMapComponents
networkMonitor *networkmonitor.NetworkMonitor
sshServer sshServer
@@ -663,24 +652,8 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
iceCfg := e.createICEConfig()
e.connMgr = NewConnMgr(e.config, e.statusRecorder, e.peerStore, wgIface)
e.connMgr.SetRoutedIPsReconciler(func(peerKey string) error {
if e.routeManager == nil {
return nil
}
return e.routeManager.ReconcilePeerAllowedIPs(peerKey)
})
e.connMgr.Start(e.ctx)
// Wire DNS-time lazy-connection warm-up now that the connection manager
// exists (it does not at DNS-server construction time). A DNS answer that
// points at an idle peer then wakes it before the client's first request.
e.dnsServer.SetPeerActivator(&dnsPeerActivator{
connMgr: e.connMgr,
peerStore: e.peerStore,
status: e.statusRecorder,
ctx: e.ctx,
})
e.srWatcher = guard.NewSRWatcher(e.signal, e.relayManager, e.mobileDep.IFaceDiscover, iceCfg)
e.srWatcher.Start(peer.IsForceRelayed())
@@ -990,12 +963,8 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
e.ApplySessionDeadline(update.GetSessionExpiresAt())
// Envelope sync responses carry PeerConfig at the top level; legacy
// NetworkMap syncs carry it under NetworkMap.PeerConfig.
if pc := update.GetPeerConfig(); pc != nil {
e.handleAutoUpdateVersion(pc.GetAutoUpdate())
} else if nm := update.GetNetworkMap(); nm != nil && nm.GetPeerConfig() != nil {
e.handleAutoUpdateVersion(nm.GetPeerConfig().GetAutoUpdate())
if update.NetworkMap != nil && update.NetworkMap.PeerConfig != nil {
e.handleAutoUpdateVersion(update.NetworkMap.PeerConfig.AutoUpdate)
}
done := e.phase("netbird_config")
@@ -1005,47 +974,12 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return err
}
// Decode the network map from either the components envelope or the
// legacy proto.NetworkMap before the posture-check gating below, so the
// "is there a network map" decision covers both wire shapes.
var (
nm *mgmProto.NetworkMap
components *types.NetworkMapComponents
)
if version := update.GetVersion(); version == int32(sharedgrpc.ComponentNetworkMap) {
// Components-format peer: decode the envelope back to typed
// components, run Calculate() locally, and convert to the wire
// NetworkMap shape the rest of the engine consumes. Components are
// retained so future incremental updates can apply deltas instead
// of doing a full reconstruction.
envelope := update.GetNetworkMapEnvelope()
if envelope == nil {
return fmt.Errorf("received a SyncReponse indicating use of components network map, but components are missing")
}
localKey := e.config.WgPrivateKey.PublicKey().String()
dnsName := ""
if pc := update.GetPeerConfig(); pc != nil {
// PeerConfig.Fqdn = "<dns_label>.<dns_domain>" — extract the
// shared domain by stripping the peer's own label prefix. Falls
// back to empty if the FQDN doesn't have the expected shape.
dnsName = extractDNSDomainFromFQDN(pc.GetFqdn())
}
result, err := nbnetworkmap.EnvelopeToNetworkMap(e.ctx, envelope, localKey, dnsName)
if err != nil {
return fmt.Errorf("decode network map envelope: %w", err)
}
nm = result.NetworkMap
components = result.Components
} else {
nm = update.GetNetworkMap()
}
// 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
}
@@ -1058,14 +992,6 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
}
done = e.phase("persist")
// Only retain the components view when the server sent the envelope
// path. A legacy proto.NetworkMap means components == nil; writing it
// here would clobber a previously-cached snapshot, breaking the
// incremental-delta base on a future envelope sync.
if components != nil {
e.latestComponents = components
}
e.persistSyncResponse(update)
done()
@@ -1079,19 +1005,6 @@ func (e *Engine) handleSync(update *mgmProto.SyncResponse) error {
return nil
}
// extractDNSDomainFromFQDN returns the trailing dotted domain part of the
// receiving peer's FQDN — the same value the management server fills as
// dnsName when it builds the legacy NetworkMap. "peer42.netbird.cloud" →
// "netbird.cloud". An empty string is returned for unrecognized formats.
func extractDNSDomainFromFQDN(fqdn string) string {
for i := 0; i < len(fqdn); i++ {
if fqdn[i] == '.' && i+1 < len(fqdn) {
return fqdn[i+1:]
}
}
return ""
}
// 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.
@@ -1251,7 +1164,6 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
e.config.SyncMessageVersion,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,
@@ -2120,7 +2032,6 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
e.config.BlockLANAccess,
e.config.BlockInbound,
e.config.DisableIPv6,
e.config.SyncMessageVersion,
e.config.EnableSSHRoot,
e.config.EnableSSHSFTP,
e.config.EnableSSHLocalPortForwarding,

View File

@@ -75,14 +75,4 @@ func TestApplySessionDeadline_ThreeState(t *testing.T) {
require.True(t, e.statusRecorder.GetSessionExpiresAt().IsZero(),
"invalid timestamp must clear the deadline")
})
t.Run("recently expired timestamp stays visible as expired", func(t *testing.T) {
e := newEngine()
expired := time.Now().Add(-5 * time.Minute).UTC().Truncate(time.Second)
e.ApplySessionDeadline(timestamppb.New(expired))
require.True(t, e.statusRecorder.GetSessionExpiresAt().Equal(expired),
"recently-expired deadline must stay on the recorder so consumers render it as expired")
})
}

View File

@@ -29,11 +29,6 @@ type managedPeer struct {
type Config struct {
InactivityThreshold *time.Duration
// ReconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is
// armed. The activity listener creates the wake peer with the overlay /32 only; without the
// routed prefixes WireGuard would not steer subnet-bound traffic to the wake endpoint, so an
// idle routing peer could never be woken by that traffic. Optional; nil disables the reconcile.
ReconcileAllowedIPs func(peerKey string) error
}
// Manager manages lazy connections
@@ -61,9 +56,6 @@ type Manager struct {
peerToHAGroups map[string][]route.HAUniqueID // peer ID -> HA groups they belong to
haGroupToPeers map[route.HAUniqueID][]string // HA group -> peer IDs in the group
routesMu sync.RWMutex
// reconcileAllowedIPs re-applies a peer's routed allowed IPs after its wake endpoint is armed.
reconcileAllowedIPs func(peerKey string) error
}
// NewManager creates a new lazy connection manager
@@ -81,7 +73,6 @@ func NewManager(config Config, engineCtx context.Context, peerStore *peerstore.S
activityManager: activity.NewManager(wgIface),
peerToHAGroups: make(map[string][]route.HAUniqueID),
haGroupToPeers: make(map[route.HAUniqueID][]string),
reconcileAllowedIPs: config.ReconcileAllowedIPs,
}
if wgIface.IsUserspaceBind() {
@@ -210,7 +201,7 @@ func (m *Manager) AddPeer(peerCfg lazyconn.PeerConfig) (bool, error) {
return false, nil
}
if err := m.armActivityListener(peerCfg); err != nil {
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
return false, err
}
@@ -297,7 +288,7 @@ func (m *Manager) DeactivatePeer(peerID peerid.ConnID) {
m.inactivityManager.RemovePeer(mp.peerCfg.PublicKey)
if err := m.armActivityListener(*mp.peerCfg); err != nil {
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
return
}
@@ -474,31 +465,6 @@ func (m *Manager) close() {
}
// shouldDeferIdleForHA checks if peer should stay connected due to HA group requirements
// armRoutedAllowedIPs re-applies the peer's routed allowed IPs onto its freshly armed wake
// endpoint. The activity listener creates the wake peer with the overlay /32 only, so without
// this the routed prefixes would be missing and traffic to a routed subnet could not wake the
// idle routing peer. It is a no-op when no reconciler is configured.
// armActivityListener (re)arms the peer's wake endpoint via the activity manager and then
// re-applies its routed allowed IPs, so traffic to a routed subnet can wake an idle routing
// peer. The routed prefixes must be re-applied after the wake endpoint exists because the
// listener creates it with the overlay /32 only.
func (m *Manager) armActivityListener(peerCfg lazyconn.PeerConfig) error {
if err := m.activityManager.MonitorPeerActivity(peerCfg); err != nil {
return err
}
m.armRoutedAllowedIPs(&peerCfg)
return nil
}
func (m *Manager) armRoutedAllowedIPs(peerCfg *lazyconn.PeerConfig) {
if m.reconcileAllowedIPs == nil {
return
}
if err := m.reconcileAllowedIPs(peerCfg.PublicKey); err != nil {
peerCfg.Log.Errorf("failed to reconcile routed allowed IPs on wake endpoint: %v", err)
}
}
func (m *Manager) shouldDeferIdleForHA(inactivePeers map[string]struct{}, peerID string) bool {
m.routesMu.RLock()
defer m.routesMu.RUnlock()
@@ -611,7 +577,7 @@ func (m *Manager) onPeerInactivityTimedOut(peerIDs map[string]struct{}) {
mp.peerCfg.Log.Infof("start activity monitor")
if err := m.armActivityListener(*mp.peerCfg); err != nil {
if err := m.activityManager.MonitorPeerActivity(*mp.peerCfg); err != nil {
mp.peerCfg.Log.Errorf("failed to create activity monitor: %v", err)
continue
}

View File

@@ -11,14 +11,12 @@ import (
// MobileDependency collect all dependencies for mobile platform
type MobileDependency struct {
// Android and iOS
NetworkChangeListener listener.NetworkChangeListener
// Android only
TunAdapter device.TunAdapter
IFaceDiscover stdnet.ExternalIFaceDiscover
HostDNSAddresses []netip.AddrPort
DnsReadyListener dns.ReadyListener
TunAdapter device.TunAdapter
IFaceDiscover stdnet.ExternalIFaceDiscover
NetworkChangeListener listener.NetworkChangeListener
HostDNSAddresses []netip.AddrPort
DnsReadyListener dns.ReadyListener
// iOS only
DnsManager dns.IosDnsManager

View File

@@ -813,14 +813,19 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
}
// GetSessionExpiresAt returns the most recently recorded SSO session deadline,
// or the zero value when no deadline is tracked. A deadline in the past is
// returned as-is: it means the session has expired, and consumers (tray row,
// CLI status) render it as "expired" rather than hiding it — masking it as
// "none" would blank the UI at the exact moment it should say the session
// ended.
// or the zero value when no deadline is tracked. A deadline that has already
// slipped into the past reports as "none": once the session has expired it is
// no longer a meaningful countdown, and the sessionwatch.Watcher does not
// arm a timer at the deadline itself to clear it (only the two pre-expiry
// warnings). Without this guard the UI would keep painting a stale
// "expires in …" against a moment that has passed until the next login,
// extend, or teardown rewrote the value.
func (d *Status) GetSessionExpiresAt() time.Time {
d.mux.Lock()
defer d.mux.Unlock()
if !d.sessionExpiresAt.IsZero() && d.sessionExpiresAt.Before(time.Now()) {
return time.Time{}
}
return d.sessionExpiresAt
}

View File

@@ -54,19 +54,15 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
w.relaySupportedOnRemotePeer.Store(true)
// the relayManager will return with error in case if the connection has lost with relay server
currentRelayAddress, _, err := w.relayManager.RelayInstanceAddress()
_, _, err := w.relayManager.RelayInstanceAddress()
if err != nil {
w.log.Errorf("failed to handle new offer: %s", err)
return
}
srv := w.preferredRelayServer(currentRelayAddress, remoteOfferAnswer.RelaySrvAddress)
var serverIP netip.Addr
if srv == remoteOfferAnswer.RelaySrvAddress {
serverIP = remoteOfferAnswer.RelaySrvIP
}
relayedConn, err := w.relayManager.OpenConn(w.peerCtx, srv, w.config.Key, serverIP)
preferForeign := !w.isController
remoteRelayServer := relayClient.RelayServer{Addr: remoteOfferAnswer.RelaySrvAddress, IP: remoteOfferAnswer.RelaySrvIP}
relayedConn, err := w.relayManager.OpenConn(w.peerCtx, remoteRelayServer, w.config.Key, preferForeign)
if err != nil {
if errors.Is(err, relayClient.ErrConnAlreadyExists) {
w.log.Debugf("handled offer by reusing existing relay connection")
@@ -80,14 +76,13 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
w.relayedConn = relayedConn
w.relayLock.Unlock()
err = w.relayManager.AddCloseListener(srv, w.onRelayClientDisconnected)
if err != nil {
log.Errorf("failed to add close listener: %s", err)
if err := w.relayManager.AddCloseListener(relayedConn.RemoteAddr().String(), w.onRelayClientDisconnected); err != nil {
w.log.Errorf("failed to add close listener: %s", err)
_ = relayedConn.Close()
return
}
w.log.Debugf("peer conn opened via Relay: %s", srv)
w.log.Debugf("peer conn opened via Relay: %s", relayedConn.RemoteAddr())
go w.conn.onRelayConnectionIsReady(RelayConnInfo{
relayedConn: relayedConn,
rosenpassPubKey: remoteOfferAnswer.RosenpassPubKey,
@@ -126,13 +121,6 @@ func (w *WorkerRelay) isRelaySupported(answer *OfferAnswer) bool {
return answer.RelaySrvAddress != ""
}
func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress string) string {
if w.isController {
return myRelayAddress
}
return remoteRelayAddress
}
func (w *WorkerRelay) onRelayClientDisconnected() {
go w.conn.onRelayDisconnected()
}

View File

@@ -96,7 +96,6 @@ type ConfigInput struct {
BlockLANAccess *bool
BlockInbound *bool
DisableIPv6 *bool
SyncMessageVersion *int
DisableNotifications *bool
@@ -138,7 +137,6 @@ type Config struct {
BlockLANAccess bool
BlockInbound bool
DisableIPv6 bool
SyncMessageVersion *int
DisableNotifications *bool
@@ -589,12 +587,6 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}
if input.SyncMessageVersion != nil && *input.SyncMessageVersion != *config.SyncMessageVersion {
log.Infof("setting SyncMessageVersion to %v", *input.SyncMessageVersion)
*config.SyncMessageVersion = *input.SyncMessageVersion
updated = true
}
if input.DisableNotifications != nil && (config.DisableNotifications == nil || *input.DisableNotifications != *config.DisableNotifications) {
if *input.DisableNotifications {
log.Infof("disabling notifications")

View File

@@ -95,7 +95,7 @@ func (d *DnsInterceptor) RemoveRoute() error {
// AllowedIPs should use real IPs
if d.currentPeerKey != "" {
if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil {
if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err))
}
}
@@ -172,7 +172,7 @@ func (d *DnsInterceptor) removeAllowedIP(realPrefix netip.Prefix) error {
}
// AllowedIPs use real IPs
if _, err := d.allowedIPsRefcounter.Decrement(realPrefix, d.currentPeerKey); err != nil {
if _, err := d.allowedIPsRefcounter.Decrement(realPrefix); err != nil {
return fmt.Errorf("remove allowed IP %s: %v", realPrefix, err)
}
@@ -205,7 +205,7 @@ func (d *DnsInterceptor) RemoveAllowedIPs() error {
for _, prefixes := range d.interceptedDomains {
for _, prefix := range prefixes {
// AllowedIPs use real IPs
if _, err := d.allowedIPsRefcounter.Decrement(prefix, d.currentPeerKey); err != nil {
if _, err := d.allowedIPsRefcounter.Decrement(prefix); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %v", prefix, err))
}
}

View File

@@ -135,7 +135,7 @@ func (r *Route) RemoveAllowedIPs() error {
var merr *multierror.Error
for _, domainPrefixes := range r.dynamicDomains {
for _, prefix := range domainPrefixes {
if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil {
if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err))
}
}
@@ -185,7 +185,7 @@ func (r *Route) startResolver(ctx context.Context) {
}
func (r *Route) update(ctx context.Context) error {
resolved, err := r.resolveDomains(ctx)
resolved, err := r.resolveDomains()
if err != nil {
if len(resolved) == 0 {
return fmt.Errorf("resolve domains: %w", err)
@@ -199,9 +199,9 @@ func (r *Route) update(ctx context.Context) error {
return nil
}
func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
func (r *Route) resolveDomains() (domainMap, error) {
results := make(chan resolveResult)
go r.resolve(ctx, results)
go r.resolve(results)
resolved := domainMap{}
var merr *multierror.Error
@@ -217,7 +217,7 @@ func (r *Route) resolveDomains(ctx context.Context) (domainMap, error) {
return resolved, nberrors.FormatErrorOrNil(merr)
}
func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
func (r *Route) resolve(results chan resolveResult) {
var wg sync.WaitGroup
for _, d := range r.route.Domains {
@@ -225,10 +225,10 @@ func (r *Route) resolve(ctx context.Context, results chan resolveResult) {
go func(domain domain.Domain) {
defer wg.Done()
ips, err := r.getIPsFromResolver(ctx, domain)
ips, err := r.getIPsFromResolver(domain)
if err != nil {
log.Tracef("Failed to resolve domain %s with private resolver: %v", domain.SafeString(), err)
ips, err = lookupHostIPs(ctx, domain)
ips, err = net.LookupIP(domain.PunycodeString())
if err != nil {
results <- resolveResult{domain: domain, err: fmt.Errorf("resolve d %s: %w", domain.SafeString(), err)}
return
@@ -320,7 +320,7 @@ func (r *Route) removeRoutes(prefixes []netip.Prefix) ([]netip.Prefix, error) {
merr = multierror.Append(merr, fmt.Errorf("remove dynamic route for IP %s: %w", prefix, err))
}
if r.currentPeerKey != "" {
if _, err := r.allowedIPsRefcounter.Decrement(prefix, r.currentPeerKey); err != nil {
if _, err := r.allowedIPsRefcounter.Decrement(prefix); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %s: %w", prefix, err))
}
}
@@ -364,20 +364,6 @@ func determinePrefixChanges(oldPrefixes, newPrefixes []netip.Prefix) (toAdd, toR
return
}
// lookupHostIPs resolves d via the system resolver, honoring ctx cancellation.
func lookupHostIPs(ctx context.Context, d domain.Domain) ([]net.IP, error) {
addrs, err := net.DefaultResolver.LookupIPAddr(ctx, d.PunycodeString())
if err != nil {
return nil, err
}
ips := make([]net.IP, 0, len(addrs))
for _, addr := range addrs {
ips = append(ips, addr.IP)
}
return ips, nil
}
func combinePrefixes(oldPrefixes, removedPrefixes, addedPrefixes []netip.Prefix) []netip.Prefix {
prefixSet := make(map[netip.Prefix]struct{})
for _, prefix := range oldPrefixes {

View File

@@ -3,12 +3,11 @@
package dynamic
import (
"context"
"net"
"github.com/netbirdio/netbird/shared/management/domain"
)
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
return lookupHostIPs(ctx, domain)
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
return net.LookupIP(domain.PunycodeString())
}

View File

@@ -3,7 +3,6 @@
package dynamic
import (
"context"
"fmt"
"net"
"time"
@@ -17,7 +16,7 @@ import (
const dialTimeout = 10 * time.Second
func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([]net.IP, error) {
func (r *Route) getIPsFromResolver(domain domain.Domain) ([]net.IP, error) {
privateClient, err := nbdns.GetClientPrivate(r.wgInterface, r.resolverAddr.Addr(), dialTimeout)
if err != nil {
return nil, fmt.Errorf("error while creating private client: %s", err)
@@ -33,7 +32,7 @@ func (r *Route) getIPsFromResolver(ctx context.Context, domain domain.Domain) ([
msg := new(dns.Msg)
msg.SetQuestion(fqdn, qtype)
response, _, err := nbdns.ExchangeWithFallback(ctx, privateClient, msg, r.resolverAddr.String())
response, _, err := nbdns.ExchangeWithFallback(nil, privateClient, msg, r.resolverAddr.String())
if err != nil {
if queryErr == nil {
queryErr = fmt.Errorf("DNS query for %s (type %d) after %s: %w", domain.SafeString(), qtype, time.Since(startTime), err)

View File

@@ -52,10 +52,6 @@ type Manager interface {
UpdateRoutes(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error
ClassifyRoutes(newRoutes []*route.Route) (map[route.ID]*route.Route, route.HAMap)
TriggerSelection(route.HAMap)
SelectRoutes(ids []route.NetID, appendRoute bool) error
DeselectRoutes(ids []route.NetID) error
SelectAllRoutes()
DeselectAllRoutes()
GetRouteSelector() *routeselector.RouteSelector
GetClientRoutes() route.HAMap
GetSelectedClientRoutes() route.HAMap
@@ -65,7 +61,6 @@ type Manager interface {
InitialRouteRange() []string
SetFirewall(firewall.Manager) error
SetDNSForwarderPort(port uint16)
ReconcilePeerAllowedIPs(peerKey string) error
Stop(stateManager *statemanager.Manager)
}
@@ -220,7 +215,7 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) {
)
}
m.allowedIPsRefCounter = refcounter.NewAllowedIPs(
m.allowedIPsRefCounter = refcounter.New(
func(prefix netip.Prefix, peerKey string) (string, error) {
// save peerKey to use it in the remove function
return peerKey, m.wgInterface.AddAllowedIP(peerKey, prefix)
@@ -237,30 +232,6 @@ func (m *DefaultManager) setupRefCounters(useNoop bool) {
)
}
// ReconcilePeerAllowedIPs re-applies every routed allowed IP currently tracked for the peer
// onto the WireGuard device. The allowed-IP refcounter only calls its AddFunc (which pushes to
// the device) on a prefix's 0->1 transition, so a peer whose device entry was rebuilt without a
// matching refcounter change — e.g. a lazy connection cycling through idle->wake, which recreates
// the WireGuard peer with the overlay /32 only — ends up missing routed prefixes the refcounter
// still considers installed, and nothing retries. Calling this when the peer's WireGuard entry is
// (re)created restores convergence. It is add-only and idempotent: AddAllowedIP is update-only, so
// prefixes are re-added to an existing peer and an absent peer is left untouched.
func (m *DefaultManager) ReconcilePeerAllowedIPs(peerKey string) error {
if m.allowedIPsRefCounter == nil {
return nil
}
return m.allowedIPsRefCounter.ReapplyMatching(
func(out string) bool { return out == peerKey },
func(prefix netip.Prefix) error {
if err := m.wgInterface.AddAllowedIP(peerKey, prefix); err != nil {
return fmt.Errorf("add allowed IP %s for peer %s: %w", prefix, peerKey, err)
}
return nil
},
)
}
// Init sets up the routing
func (m *DefaultManager) Init() error {
m.routeSelector = m.initSelector()
@@ -804,7 +775,7 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI
var info exitNodeInfo
for haID, routes := range clientRoutes {
if !isExitNodeRoutes(routes) {
if !m.isExitNodeRoute(routes) {
continue
}
@@ -824,6 +795,13 @@ func (m *DefaultManager) collectExitNodeInfo(clientRoutes route.HAMap) exitNodeI
return info
}
func (m *DefaultManager) isExitNodeRoute(routes []*route.Route) bool {
if len(routes) == 0 {
return false
}
return route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network)
}
func (m *DefaultManager) categorizeUserSelection(netID route.NetID, info *exitNodeInfo) {
if m.routeSelector.IsSelected(netID) {
info.userSelected = append(info.userSelected, netID)

View File

@@ -16,8 +16,6 @@ type MockManager struct {
ClassifyRoutesFunc func(routes []*route.Route) (map[route.ID]*route.Route, route.HAMap)
UpdateRoutesFunc func(updateSerial uint64, serverRoutes map[route.ID]*route.Route, clientRoutes route.HAMap, useNewDNSRoute bool) error
TriggerSelectionFunc func(haMap route.HAMap)
SelectRoutesFunc func(ids []route.NetID, appendRoute bool) error
DeselectRoutesFunc func(ids []route.NetID) error
GetRouteSelectorFunc func() *routeselector.RouteSelector
GetClientRoutesFunc func() route.HAMap
GetSelectedClientRoutesFunc func() route.HAMap
@@ -57,30 +55,6 @@ func (m *MockManager) TriggerSelection(networks route.HAMap) {
}
}
// SelectRoutes mock implementation of SelectRoutes from Manager interface
func (m *MockManager) SelectRoutes(ids []route.NetID, appendRoute bool) error {
if m.SelectRoutesFunc != nil {
return m.SelectRoutesFunc(ids, appendRoute)
}
return nil
}
// DeselectRoutes mock implementation of DeselectRoutes from Manager interface
func (m *MockManager) DeselectRoutes(ids []route.NetID) error {
if m.DeselectRoutesFunc != nil {
return m.DeselectRoutesFunc(ids)
}
return nil
}
// SelectAllRoutes mock implementation of SelectAllRoutes from Manager interface
func (m *MockManager) SelectAllRoutes() {
}
// DeselectAllRoutes mock implementation of DeselectAllRoutes from Manager interface
func (m *MockManager) DeselectAllRoutes() {
}
// GetRouteSelector mock implementation of GetRouteSelector from Manager interface
func (m *MockManager) GetRouteSelector() *routeselector.RouteSelector {
if m.GetRouteSelectorFunc != nil {
@@ -138,11 +112,6 @@ func (m *MockManager) SetFirewall(firewall.Manager) error {
func (m *MockManager) SetDNSForwarderPort(port uint16) {
}
// ReconcilePeerAllowedIPs mock implementation of ReconcilePeerAllowedIPs from Manager interface
func (m *MockManager) ReconcilePeerAllowedIPs(peerKey string) error {
return nil
}
// Stop mock implementation of Stop from Manager interface
func (m *MockManager) Stop(stateManager *statemanager.Manager) {
if m.StopFunc != nil {

View File

@@ -3,6 +3,7 @@
package notifier
import (
"container/list"
"net/netip"
"slices"
"sort"
@@ -15,12 +16,20 @@ import (
type Notifier struct {
mu sync.Mutex
cond *sync.Cond
currentPrefixes []string
listener listener.NetworkChangeListener
queue *list.List
closed bool
}
func NewNotifier() *Notifier {
return &Notifier{}
n := &Notifier{
queue: list.New(),
}
n.cond = sync.NewCond(&n.mu)
go n.deliverLoop()
return n
}
func (n *Notifier) SetListener(listener listener.NetworkChangeListener) {
@@ -50,19 +59,44 @@ func (n *Notifier) OnNewPrefixes(prefixes []netip.Prefix) {
sort.Strings(newNets)
n.mu.Lock()
defer n.mu.Unlock()
if slices.Equal(n.currentPrefixes, newNets) {
n.mu.Unlock()
return
}
n.currentPrefixes = newNets
if n.listener != nil {
n.listener.OnNetworkChanged(strings.Join(n.currentPrefixes, ","))
}
routes := strings.Join(n.currentPrefixes, ",")
n.queue.PushBack(routes)
n.cond.Signal()
n.mu.Unlock()
}
func (n *Notifier) Close() {
n.mu.Lock()
n.closed = true
n.cond.Signal()
n.mu.Unlock()
}
func (n *Notifier) GetInitialRouteRanges() []string {
return nil
}
func (n *Notifier) deliverLoop() {
for {
n.mu.Lock()
for n.queue.Len() == 0 && !n.closed {
n.cond.Wait()
}
if n.closed && n.queue.Len() == 0 {
n.mu.Unlock()
return
}
routes := n.queue.Remove(n.queue.Front()).(string)
l := n.listener
n.mu.Unlock()
if l != nil {
l.OnNetworkChanged(routes)
}
}
}

View File

@@ -1,90 +0,0 @@
//go:build !windows
package routemanager
import (
"net"
"net/netip"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.zx2c4.com/wireguard/tun/netstack"
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/wgaddr"
"github.com/netbirdio/netbird/client/internal/routemanager/refcounter"
)
// reconcileWGMock is a minimal iface.WGIface that only records AddAllowedIP calls; every other
// method is an inert stub because ReconcilePeerAllowedIPs exercises none of them.
type reconcileWGMock struct {
mu sync.Mutex
adds map[string][]netip.Prefix
}
func (m *reconcileWGMock) AddAllowedIP(peerKey string, allowedIP netip.Prefix) error {
m.mu.Lock()
defer m.mu.Unlock()
if m.adds == nil {
m.adds = map[string][]netip.Prefix{}
}
m.adds[peerKey] = append(m.adds[peerKey], allowedIP)
return nil
}
func (m *reconcileWGMock) added(peerKey string) []netip.Prefix {
m.mu.Lock()
defer m.mu.Unlock()
return m.adds[peerKey]
}
func (m *reconcileWGMock) RemoveAllowedIP(string, netip.Prefix) error { return nil }
func (m *reconcileWGMock) Name() string { return "utun-test" }
func (m *reconcileWGMock) Address() wgaddr.Address { return wgaddr.Address{} }
func (m *reconcileWGMock) ToInterface() *net.Interface { return nil }
func (m *reconcileWGMock) IsUserspaceBind() bool { return false }
func (m *reconcileWGMock) GetFilter() device.PacketFilter { return nil }
func (m *reconcileWGMock) GetDevice() *device.FilteredDevice { return nil }
func (m *reconcileWGMock) GetNet() *netstack.Net { return nil }
// TestReconcilePeerAllowedIPs verifies the declarative reconcile re-applies every routed prefix
// tracked for the peer (self-heal, independent of refcount level) and stays scoped to that peer.
func TestReconcilePeerAllowedIPs(t *testing.T) {
wg := &reconcileWGMock{}
m := &DefaultManager{wgInterface: wg}
m.allowedIPsRefCounter = refcounter.NewAllowedIPs(
func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
func(netip.Prefix, string) error { return nil },
)
peerA1 := netip.MustParsePrefix("10.0.0.0/24")
peerA2 := netip.MustParsePrefix("10.1.0.0/24")
peerB1 := netip.MustParsePrefix("10.2.0.0/24")
for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
_, err := m.allowedIPsRefCounter.Increment(prefix, peer)
require.NoError(t, err)
}
// Extra reference: reconcile must still re-apply the prefix even though its refcount never
// hit 0 again (the exact case the plain incremental path skips).
_, err := m.allowedIPsRefCounter.Increment(peerA1, "peerA")
require.NoError(t, err)
require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, wg.added("peerA"),
"reconcile must re-apply all routed prefixes of the peer")
assert.Empty(t, wg.added("peerB"), "reconcile must not touch another peer's prefixes")
}
// TestReconcilePeerAllowedIPsNoCounter verifies reconcile is a safe no-op before the refcounter is
// set up.
func TestReconcilePeerAllowedIPsNoCounter(t *testing.T) {
wg := &reconcileWGMock{}
m := &DefaultManager{wgInterface: wg}
require.NoError(t, m.ReconcilePeerAllowedIPs("peerA"))
assert.Empty(t, wg.added("peerA"))
}

View File

@@ -1,206 +0,0 @@
package refcounter
import (
"errors"
"fmt"
"net/netip"
"sort"
"sync"
"github.com/hashicorp/go-multierror"
nberrors "github.com/netbirdio/netbird/client/errors"
)
// allowedIPsEntry holds the per-peer reference counts for a single prefix and which peer is
// currently installed in WireGuard. WireGuard allows a prefix on exactly one peer, so at most
// one peer is active at a time even when several peers reference the prefix.
type allowedIPsEntry struct {
// peers maps a peerKey to the number of references holding the prefix for that peer.
peers map[string]int
// active is the peerKey currently installed in WireGuard for this prefix ("" if none).
active string
// total is the sum of all per-peer reference counts (kept in sync with peers).
total int
}
// AllowedIPsRefCounter is a peer-aware reference counter for WireGuard AllowedIPs.
//
// The generic Counter keys only by prefix and remembers a single Out value set by the first
// caller, which it never changes. That is wrong for AllowedIPs: two independent watchers (or
// multiple resolved domains) can reference the same prefix through different peers, and when the
// peer currently installed in WireGuard releases its last reference the prefix must be handed over
// to a surviving peer instead of being left pointing at the released one.
//
// It calls add/remove (which program WireGuard) only on the transitions that matter:
// - add on the first reference for a prefix, or when swapping the active peer;
// - remove on the last reference for a prefix, or on the old peer during a swap.
type AllowedIPsRefCounter struct {
mu sync.Mutex
entries map[netip.Prefix]*allowedIPsEntry
add AddFunc[netip.Prefix, string, string]
remove RemoveFunc[netip.Prefix, string]
}
// NewAllowedIPs creates a new peer-aware AllowedIPs reference counter.
// add programs a prefix on a peer in WireGuard and returns the peerKey to store as the active peer.
// remove unprograms the prefix from the given peer.
func NewAllowedIPs(add AddFunc[netip.Prefix, string, string], remove RemoveFunc[netip.Prefix, string]) *AllowedIPsRefCounter {
return &AllowedIPsRefCounter{
entries: map[netip.Prefix]*allowedIPsEntry{},
add: add,
remove: remove,
}
}
// Increment adds a reference to prefix for peerKey. WireGuard is programmed only for the first
// reference to a prefix; while a different peer is already installed the prefix is left with it
// (first peer wins, HA at the WireGuard layer is not possible) and only the reference count is kept.
func (rm *AllowedIPsRefCounter) Increment(prefix netip.Prefix, peerKey string) (Ref[string], error) {
rm.mu.Lock()
defer rm.mu.Unlock()
e, ok := rm.entries[prefix]
if !ok {
e = &allowedIPsEntry{peers: map[string]int{}}
rm.entries[prefix] = e
}
logCallerF("Increasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]",
prefix, peerKey, e.peers[peerKey], e.peers[peerKey]+1, e.total, e.total+1, e.active)
// Program WireGuard only when nothing is installed yet for this prefix.
if e.active == "" {
out, err := rm.add(prefix, peerKey)
if errors.Is(err, ErrIgnore) {
if e.total == 0 {
delete(rm.entries, prefix)
}
return Ref[string]{Count: e.total, Out: e.active}, nil
}
if err != nil {
if e.total == 0 {
delete(rm.entries, prefix)
}
return Ref[string]{}, fmt.Errorf("failed to add allowed IP %v for peer %s: %w", prefix, peerKey, err)
}
e.active = out
}
e.peers[peerKey]++
e.total++
return Ref[string]{Count: e.total, Out: e.active}, nil
}
// Decrement removes a reference to prefix for peerKey. When the peer currently installed in
// WireGuard releases its last reference, the prefix is swapped to a surviving peer if one exists,
// otherwise it is removed from WireGuard.
func (rm *AllowedIPsRefCounter) Decrement(prefix netip.Prefix, peerKey string) (Ref[string], error) {
rm.mu.Lock()
defer rm.mu.Unlock()
e, ok := rm.entries[prefix]
if !ok {
logCallerF("No allowed IP reference found for prefix %v", prefix)
return Ref[string]{}, nil
}
if e.peers[peerKey] > 0 {
logCallerF("Decreasing allowed IP ref count for prefix %v peer %s [peer %d -> %d, total %d -> %d, active %q]",
prefix, peerKey, e.peers[peerKey], e.peers[peerKey]-1, e.total, e.total-1, e.active)
e.peers[peerKey]--
e.total--
if e.peers[peerKey] == 0 {
delete(e.peers, peerKey)
}
} else {
logCallerF("No allowed IP reference found for prefix %v peer %s", prefix, peerKey)
}
// If the peer currently installed in WireGuard still holds references, nothing to reprogram.
// Keying the check on the active peer (not the one just released) makes this self-healing:
// a prior swap whose remove/add failed leaves e.active pointing at a peer with no references,
// and this retries the hand-off on the next Decrement instead of getting stuck.
if e.active != "" && e.peers[e.active] > 0 {
return Ref[string]{Count: e.total, Out: e.active}, nil
}
// Detach the stale/gone active peer from WireGuard before reprogramming.
if e.active != "" {
if err := rm.remove(prefix, e.active); err != nil {
return Ref[string]{Count: e.total, Out: e.active}, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err)
}
e.active = ""
}
// Hand the prefix over to a surviving peer, or drop the entry when none remain.
if survivor, ok := pickSurvivor(e.peers); ok {
out, err := rm.add(prefix, survivor)
if err != nil {
return Ref[string]{Count: e.total, Out: ""}, fmt.Errorf("swap allowed IP %v to peer %s: %w", prefix, survivor, err)
}
e.active = out
return Ref[string]{Count: e.total, Out: e.active}, nil
}
delete(rm.entries, prefix)
return Ref[string]{Count: 0, Out: ""}, nil
}
// Flush removes all prefixes from WireGuard and clears the counter.
func (rm *AllowedIPsRefCounter) Flush() error {
rm.mu.Lock()
defer rm.mu.Unlock()
var merr *multierror.Error
for prefix, e := range rm.entries {
if e.active == "" {
continue
}
logCallerF("Flushing allowed IP for prefix %v peer %s", prefix, e.active)
if err := rm.remove(prefix, e.active); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove allowed IP %v for peer %s: %w", prefix, e.active, err))
}
}
clear(rm.entries)
return nberrors.FormatErrorOrNil(merr)
}
// ReapplyMatching calls apply for every prefix whose currently installed (active) peer satisfies
// pred, holding the lock for the whole pass. It is used to re-push allowed IPs onto a peer whose
// WireGuard entry was rebuilt (e.g. a lazy connection cycling idle->wake) without a matching
// refcounter change, which would otherwise leave the prefix installed in the counter but missing
// on the device. Only the active peer is considered — a prefix that lost its installed peer to a
// failed swap is skipped here and reconciled by the next Increment/Decrement.
func (rm *AllowedIPsRefCounter) ReapplyMatching(pred func(out string) bool, apply func(key netip.Prefix) error) error {
rm.mu.Lock()
defer rm.mu.Unlock()
var merr *multierror.Error
for prefix, e := range rm.entries {
if e.active != "" && pred(e.active) {
if err := apply(prefix); err != nil {
merr = multierror.Append(merr, err)
}
}
}
return nberrors.FormatErrorOrNil(merr)
}
// pickSurvivor deterministically selects a peer still referencing the prefix. WireGuard cannot do
// multipath for a single prefix, so any surviving peer is a valid winner; the choice is made stable
// (lowest peerKey) for predictable behavior and testability.
func pickSurvivor(peers map[string]int) (string, bool) {
if len(peers) == 0 {
return "", false
}
keys := make([]string, 0, len(peers))
for k := range peers {
keys = append(keys, k)
}
sort.Strings(keys)
return keys[0], true
}

View File

@@ -1,241 +0,0 @@
package refcounter
import (
"errors"
"net/netip"
"testing"
)
// fakeWG models WireGuard's cryptokey routing: a prefix can be installed on exactly one peer.
// failAdd/failRemove make the next add/remove fail once, to exercise the self-healing error paths.
type fakeWG struct {
installed map[netip.Prefix]string
adds int
removes int
failAdd bool
failRemove bool
}
func newFakeWG() *fakeWG {
return &fakeWG{installed: map[netip.Prefix]string{}}
}
func (f *fakeWG) counter() *AllowedIPsRefCounter {
return NewAllowedIPs(
func(prefix netip.Prefix, peerKey string) (string, error) {
if f.failAdd {
f.failAdd = false
return "", errors.New("add failed")
}
f.adds++
f.installed[prefix] = peerKey
return peerKey, nil
},
func(prefix netip.Prefix, peerKey string) error {
if f.failRemove {
f.failRemove = false
return errors.New("remove failed")
}
f.removes++
// only clear if this peer is the one installed, mirroring wg semantics
if f.installed[prefix] == peerKey {
delete(f.installed, prefix)
}
return nil
},
)
}
func mustPrefix(t *testing.T, s string) netip.Prefix {
t.Helper()
p, err := netip.ParsePrefix(s)
if err != nil {
t.Fatalf("parse prefix %q: %v", s, err)
}
return p
}
func mustIncrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] {
t.Helper()
ref, err := c.Increment(p, peer)
if err != nil {
t.Fatalf("Increment(%v, %s): %v", p, peer, err)
}
return ref
}
func mustDecrement(t *testing.T, c *AllowedIPsRefCounter, p netip.Prefix, peer string) Ref[string] {
t.Helper()
ref, err := c.Decrement(p, peer)
if err != nil {
t.Fatalf("Decrement(%v, %s): %v", p, peer, err)
}
return ref
}
// TestAllowedIPs_SwapOnActivePeerRemoval reproduces the reported bug: two networks with the same
// prefix routed by different peers. Removing the network whose peer is installed must hand the
// prefix over to the surviving peer instead of leaving it on the removed one.
func TestAllowedIPs_SwapOnActivePeerRemoval(t *testing.T) {
f := newFakeWG()
c := f.counter()
p := mustPrefix(t, "10.44.8.0/24")
mustIncrement(t, c, p, "peerA")
mustIncrement(t, c, p, "peerB")
// First peer wins while both are present.
if got := f.installed[p]; got != "peerA" {
t.Fatalf("expected peerA installed, got %q", got)
}
// Remove the active peer's network -> must swap to peerB.
mustDecrement(t, c, p, "peerA")
if got := f.installed[p]; got != "peerB" {
t.Fatalf("BUG: prefix stuck on removed peer, want peerB got %q", got)
}
// Remove the last one -> prefix gone.
mustDecrement(t, c, p, "peerB")
if _, ok := f.installed[p]; ok {
t.Fatalf("expected prefix removed, still installed on %q", f.installed[p])
}
}
// TestAllowedIPs_RemoveNonActivePeer removing a non-installed peer must not touch WireGuard.
func TestAllowedIPs_RemoveNonActivePeer(t *testing.T) {
f := newFakeWG()
c := f.counter()
p := mustPrefix(t, "10.44.8.0/24")
mustIncrement(t, c, p, "peerA")
mustIncrement(t, c, p, "peerB")
removesBefore := f.removes
mustDecrement(t, c, p, "peerB")
if f.installed[p] != "peerA" {
t.Fatalf("active peer must stay peerA, got %q", f.installed[p])
}
if f.removes != removesBefore {
t.Fatalf("removing a non-active peer must not call wg remove")
}
}
// TestAllowedIPs_SamePeerMultipleRefs two references via the same peer must keep the prefix until
// the last reference is released (the reason the per-peer count must be an int, not a set).
func TestAllowedIPs_SamePeerMultipleRefs(t *testing.T) {
f := newFakeWG()
c := f.counter()
p := mustPrefix(t, "10.44.8.0/24")
mustIncrement(t, c, p, "peerA")
mustIncrement(t, c, p, "peerA")
if f.adds != 1 {
t.Fatalf("expected a single wg add for the same peer, got %d", f.adds)
}
mustDecrement(t, c, p, "peerA")
if f.installed[p] != "peerA" {
t.Fatalf("prefix must stay while a reference remains, got %q", f.installed[p])
}
if f.removes != 0 {
t.Fatalf("no wg remove expected while a reference remains, got %d", f.removes)
}
mustDecrement(t, c, p, "peerA")
if _, ok := f.installed[p]; ok {
t.Fatalf("prefix must be removed after last reference")
}
}
// TestAllowedIPs_RefCountAndActive checks the Ref returned to callers (used for the HA-disabled log).
func TestAllowedIPs_RefCountAndActive(t *testing.T) {
f := newFakeWG()
c := f.counter()
p := mustPrefix(t, "10.44.8.0/24")
ref := mustIncrement(t, c, p, "peerA")
if ref.Count != 1 || ref.Out != "peerA" {
t.Fatalf("want {1, peerA}, got {%d, %q}", ref.Count, ref.Out)
}
ref = mustIncrement(t, c, p, "peerB")
if ref.Count != 2 || ref.Out != "peerA" {
t.Fatalf("want {2, peerA}, got {%d, %q}", ref.Count, ref.Out)
}
}
// TestAllowedIPs_Flush removes everything installed and clears the counter.
func TestAllowedIPs_Flush(t *testing.T) {
f := newFakeWG()
c := f.counter()
p1 := mustPrefix(t, "10.44.8.0/24")
p2 := mustPrefix(t, "10.44.9.0/24")
mustIncrement(t, c, p1, "peerA")
mustIncrement(t, c, p2, "peerB")
if err := c.Flush(); err != nil {
t.Fatal(err)
}
if len(f.installed) != 0 {
t.Fatalf("expected all prefixes removed, got %v", f.installed)
}
// After flush, a fresh increment must add again.
mustIncrement(t, c, p1, "peerC")
if f.installed[p1] != "peerC" {
t.Fatalf("counter not reset after flush")
}
}
// TestAllowedIPs_SelfHealAfterSwapAddError ensures a failed add during a swap does not permanently
// strand the prefix: the next Decrement (or Increment) must retry and install a surviving peer.
func TestAllowedIPs_SelfHealAfterSwapAddError(t *testing.T) {
f := newFakeWG()
c := f.counter()
p := mustPrefix(t, "10.44.8.0/24")
mustIncrement(t, c, p, "peerA")
mustIncrement(t, c, p, "peerB")
mustIncrement(t, c, p, "peerC")
// Removing the active peerA triggers a swap to a survivor; make the add fail once.
f.failAdd = true
if _, err := c.Decrement(p, "peerA"); err == nil {
t.Fatalf("expected error from failed swap add")
}
if _, ok := f.installed[p]; ok {
t.Fatalf("nothing should be installed after a failed swap add, got %q", f.installed[p])
}
// A later Decrement of a non-active survivor must retry the hand-off (self-heal), not stay stuck.
ref := mustDecrement(t, c, p, "peerC")
if got := f.installed[p]; got == "" {
t.Fatalf("self-heal failed: prefix left unrouted after add recovered")
}
if ref.Out == "" {
t.Fatalf("expected an active peer after self-heal, got empty")
}
}
// TestAllowedIPs_SelfHealAfterRemoveError ensures a failed remove during a swap is retried instead
// of leaving e.active stuck on a peer that no longer holds references.
func TestAllowedIPs_SelfHealAfterRemoveError(t *testing.T) {
f := newFakeWG()
c := f.counter()
p := mustPrefix(t, "10.44.8.0/24")
mustIncrement(t, c, p, "peerA")
mustIncrement(t, c, p, "peerB")
// Releasing active peerA must detach it (remove) then add peerB; fail the remove once.
f.failRemove = true
if _, err := c.Decrement(p, "peerA"); err == nil {
t.Fatalf("expected error from failed remove")
}
// Next Decrement of the non-active survivor retries: removes stale peerA, installs peerB.
mustDecrement(t, c, p, "peerB")
// peerB had only one ref, so after retry the prefix is fully released.
if _, ok := f.installed[p]; ok {
t.Fatalf("expected prefix released after self-heal, still on %q", f.installed[p])
}
}

View File

@@ -94,26 +94,6 @@ func (rm *Counter[Key, I, O]) Get(key Key) (Ref[O], bool) {
return ref, ok
}
// ReapplyMatching calls apply for every key whose stored Out satisfies pred, holding the
// counter lock for the whole pass. Running apply under the lock keeps it atomic with respect
// to Increment/Decrement: a prefix dropped to zero is removed from the map (and had its
// RemoveFunc called) before this pass observes it, so a stale key can never be re-applied.
// pred and apply are invoked under the lock, so they must not call back into the counter.
func (rm *Counter[Key, I, O]) ReapplyMatching(pred func(out O) bool, apply func(key Key) error) error {
rm.mu.Lock()
defer rm.mu.Unlock()
var merr *multierror.Error
for key, ref := range rm.refCountMap {
if pred(ref.Out) {
if err := apply(key); err != nil {
merr = multierror.Append(merr, err)
}
}
}
return nberrors.FormatErrorOrNil(merr)
}
// Increment increments the reference count for the given key.
// If this is the first reference to the key, the AddFunc is called.
func (rm *Counter[Key, I, O]) Increment(key Key, in I) (Ref[O], error) {

View File

@@ -1,47 +0,0 @@
package refcounter
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestReapplyMatching verifies ReapplyMatching invokes apply for exactly the keys whose stored
// Out satisfies the predicate (no duplicates for multiply-referenced keys) — the primitive
// ReconcilePeerAllowedIPs relies on to re-apply a single peer's routed prefixes.
func TestReapplyMatching(t *testing.T) {
rc := New[netip.Prefix, string, string](
func(_ netip.Prefix, peerKey string) (string, error) { return peerKey, nil },
func(netip.Prefix, string) error { return nil },
)
peerA1 := netip.MustParsePrefix("10.0.0.0/24")
peerA2 := netip.MustParsePrefix("10.1.0.0/24")
peerB1 := netip.MustParsePrefix("10.2.0.0/24")
for prefix, peer := range map[netip.Prefix]string{peerA1: "peerA", peerA2: "peerA", peerB1: "peerB"} {
_, err := rc.Increment(prefix, peer)
require.NoError(t, err)
}
// a second reference must not make the key applied twice
_, err := rc.Increment(peerA1, "peerA")
require.NoError(t, err)
var applied []netip.Prefix
err = rc.ReapplyMatching(
func(out string) bool { return out == "peerA" },
func(key netip.Prefix) error { applied = append(applied, key); return nil },
)
require.NoError(t, err)
assert.ElementsMatch(t, []netip.Prefix{peerA1, peerA2}, applied)
var none []netip.Prefix
err = rc.ReapplyMatching(
func(out string) bool { return out == "missing" },
func(key netip.Prefix) error { none = append(none, key); return nil },
)
require.NoError(t, err)
assert.Empty(t, none)
}

View File

@@ -5,7 +5,5 @@ import "net/netip"
// RouteRefCounter is a Counter for Route, it doesn't take any input on Increment and doesn't use any output on Decrement
type RouteRefCounter = Counter[netip.Prefix, struct{}, struct{}]
// AllowedIPsRefCounter tracks WireGuard AllowedIPs per prefix. Unlike the generic Counter it is peer-aware:
// a prefix can be claimed by several peers at once and WireGuard allows a given prefix on exactly one peer,
// so the counter records the per-peer reference count and swaps the installed peer when the active one is released.
// See allowedips.go.
// AllowedIPsRefCounter is a Counter for AllowedIPs, it takes a peer key on Increment and passes it back to Decrement
type AllowedIPsRefCounter = Counter[netip.Prefix, string, string]

View File

@@ -1,138 +0,0 @@
package routemanager
import (
"fmt"
"slices"
"github.com/hashicorp/go-multierror"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/route"
)
// SelectRoutes selects the routes with the given network IDs and applies the
// new selection. V4/v6 exit-node pairs are expanded automatically. Exit nodes
// are mutually exclusive: if the selection activates an exit node, every other
// available exit node is deselected so two can't be active at once. With
// appendRoute=false the previous selection is replaced instead of extended.
func (m *DefaultManager) SelectRoutes(ids []route.NetID, appendRoute bool) error {
if err := m.selectRoutes(ids, appendRoute); err != nil {
return err
}
m.TriggerSelection(m.GetClientRoutes())
return nil
}
// DeselectRoutes removes the routes with the given network IDs from the
// selection and applies the change. V4/v6 exit-node pairs are expanded
// automatically.
func (m *DefaultManager) DeselectRoutes(ids []route.NetID) error {
if err := m.deselectRoutes(ids); err != nil {
return err
}
m.TriggerSelection(m.GetClientRoutes())
return nil
}
func (m *DefaultManager) deselectRoutes(ids []route.NetID) error {
routesMap := m.GetClientRoutesWithNetID()
routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap)
log.Debugf("deselecting routes with ids: %v", routes)
if err := m.routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil {
return fmt.Errorf("deselect routes: %w", err)
}
return nil
}
// SelectAllRoutes selects every available route and applies the selection.
// Exit nodes stay mutually exclusive: at most one remains active.
func (m *DefaultManager) SelectAllRoutes() {
m.selectAllRoutes()
m.TriggerSelection(m.GetClientRoutes())
}
func (m *DefaultManager) selectAllRoutes() {
m.routeSelector.SelectAllRoutes()
// Select-all wipes every explicit selection, so exit nodes fall back to
// management's auto-apply flags — which may mark several at once.
// Reconcile immediately so at most one exit node stays active instead of
// waiting for the next network map to enforce it.
m.mux.Lock()
defer m.mux.Unlock()
m.updateRouteSelectorFromManagement(m.clientRoutes)
}
// DeselectAllRoutes deselects every route and applies the change.
func (m *DefaultManager) DeselectAllRoutes() {
m.routeSelector.DeselectAllRoutes()
m.TriggerSelection(m.GetClientRoutes())
}
func (m *DefaultManager) selectRoutes(ids []route.NetID, appendRoute bool) error {
routesMap := m.GetClientRoutesWithNetID()
routes := route.ExpandV6ExitPairs(slices.Clone(ids), routesMap)
allIDs := maps.Keys(routesMap)
log.Debugf("selecting routes with ids: %v", routes)
// A partial failure (e.g. an unknown ID in the request) still selects the
// valid routes, so exclusivity below must run regardless of the error.
var merr *multierror.Error
if err := m.routeSelector.SelectRoutes(routes, appendRoute, allIDs); err != nil {
merr = multierror.Append(merr, fmt.Errorf("select routes: %w", err))
}
// Exit nodes are mutually exclusive: if this selection activates an
// exit node, deselect every other available exit node so two can't be
// selected at once. Non-exit route selections are left untouched.
if requestActivatesExitNode(routes, routesMap) {
if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 {
if err := m.routeSelector.DeselectRoutes(others, allIDs); err != nil {
merr = multierror.Append(merr, fmt.Errorf("deselect sibling exit nodes: %w", err))
}
}
}
return nberrors.FormatErrorOrNil(merr)
}
func isExitNodeRoutes(routes []*route.Route) bool {
return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network))
}
// requestActivatesExitNode reports whether any requested NetID maps to an exit
// node (default route) in the current route table.
func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool {
for _, id := range requested {
if isExitNodeRoutes(routesMap[id]) {
return true
}
}
return false
}
// otherExitNodeIDs returns every available exit-node NetID that is not in the
// requested set — the siblings to deselect so a single exit node stays active.
func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID {
keep := make(map[route.NetID]struct{}, len(requested))
for _, id := range requested {
keep[id] = struct{}{}
}
var others []route.NetID
for id, routes := range routesMap {
if !isExitNodeRoutes(routes) {
continue
}
if _, ok := keep[id]; ok {
continue
}
others = append(others, id)
}
return others
}

View File

@@ -1,129 +0,0 @@
package routemanager
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/routeselector"
"github.com/netbirdio/netbird/route"
)
func v6ExitRoute(netID, peer string) *route.Route {
return &route.Route{
NetID: route.NetID(netID),
Network: netip.MustParsePrefix("::/0"),
Peer: peer,
}
}
func newSelectionTestManager() *DefaultManager {
return &DefaultManager{
routeSelector: routeselector.NewRouteSelector(),
clientRoutes: route.HAMap{
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", true)},
"exitA-v6|::/0": {v6ExitRoute("exitA-v6", "p1")},
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", true)},
"lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
},
}
}
func TestSelectRoutes_ExitNodeExclusivity(t *testing.T) {
m := newSelectionTestManager()
// Selecting an exit node selects its v6 pair and deselects the sibling.
require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true))
assert.True(t, m.routeSelector.IsSelected("exitA"), "exitA should be selected")
assert.True(t, m.routeSelector.IsSelected("exitA-v6"), "the v6 pair follows its v4 base")
assert.False(t, m.routeSelector.IsSelected("exitB"), "the sibling exit node must be deselected")
// Switching to the sibling deselects the previous exit node and its v6 pair.
require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true))
assert.True(t, m.routeSelector.IsSelected("exitB"), "exitB should now be selected")
assert.False(t, m.routeSelector.IsSelected("exitA"), "the previous exit node must be deselected")
assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "the previous exit node's v6 pair must be deselected")
assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
// Selecting a non-exit route leaves the active exit node alone.
require.NoError(t, m.selectRoutes([]route.NetID{"lan"}, true))
assert.True(t, m.routeSelector.IsSelected("exitB"), "selecting a non-exit route keeps the exit node")
// Deselecting the active exit node turns every exit node off.
require.NoError(t, m.deselectRoutes([]route.NetID{"exitB"}))
assert.False(t, m.routeSelector.IsSelected("exitB"), "exitB should be deselected")
assert.False(t, m.routeSelector.IsSelected("exitA"), "exitA stays deselected")
assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit route selection is untouched")
}
func TestSelectRoutes_PartialErrorStillEnforcesExclusivity(t *testing.T) {
// The unknown ID must be reported, but the valid exit node in the same
// request is still selected — so its sibling must still be deselected.
// Both orderings are covered: processing must continue past the invalid
// ID wherever it sits in the request.
requests := map[string][]route.NetID{
"invalid id first": {"missing", "exitB"},
"invalid id last": {"exitB", "missing"},
}
for name, ids := range requests {
t.Run(name, func(t *testing.T) {
m := newSelectionTestManager()
require.NoError(t, m.selectRoutes([]route.NetID{"exitA"}, true))
err := m.selectRoutes(ids, true)
assert.Error(t, err, "unknown id must be reported")
assert.True(t, m.routeSelector.IsSelected("exitB"), "valid exit node from the request is selected")
assert.False(t, m.routeSelector.IsSelected("exitA"), "sibling exit node must be deselected despite the error")
assert.False(t, m.routeSelector.IsSelected("exitA-v6"), "sibling's v6 pair must be deselected too")
})
}
}
func TestSelectAllRoutes_KeepsSingleExitNode(t *testing.T) {
// Both exit nodes are marked for auto-apply by management
// (SkipAutoApply=false), the state where select-all could turn on two at
// once without the immediate reconciliation.
m := &DefaultManager{
routeSelector: routeselector.NewRouteSelector(),
clientRoutes: route.HAMap{
"exitA|0.0.0.0/0": {exitRoute("exitA", "p1", false)},
"exitB|0.0.0.0/0": {exitRoute("exitB", "p2", false)},
"lan|192.168.1.0/24": {{NetID: "lan", Network: netip.MustParsePrefix("192.168.1.0/24"), Peer: "p3"}},
},
}
require.NoError(t, m.selectRoutes([]route.NetID{"exitB"}, true))
m.selectAllRoutes()
assert.True(t, m.routeSelector.IsSelected("lan"), "non-exit routes are all selected")
assert.True(t, m.routeSelector.IsSelected("exitA"), "the deterministic management pick stays active")
assert.False(t, m.routeSelector.IsSelected("exitB"), "select-all must not leave a second exit node active")
}
func TestSelectRoutes_UnknownRoute(t *testing.T) {
m := newSelectionTestManager()
assert.Error(t, m.selectRoutes([]route.NetID{"missing"}, true), "selecting an unavailable route must fail")
assert.Error(t, m.deselectRoutes([]route.NetID{"missing"}), "deselecting an unavailable route must fail")
}
func TestExitNodeSelectionHelpers(t *testing.T) {
routesMap := map[route.NetID][]*route.Route{
"exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}},
"exitB": {{Network: netip.MustParsePrefix("::/0")}},
"lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}},
}
assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node")
assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node")
assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node")
assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node")
others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"})
assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored")
}

View File

@@ -15,11 +15,6 @@ type Route struct {
route *route.Route
routeRefCounter *refcounter.RouteRefCounter
allowedIPsRefcounter *refcounter.AllowedIPsRefCounter
// currentPeerKey is the routing peer this watcher currently has the prefix installed on
// (the HA winner elected by the watcher). It can differ from route.Peer and change on
// failover, so it is recorded on AddAllowedIPs and used on RemoveAllowedIPs to decrement
// the exact peer that was incremented.
currentPeerKey string
}
func NewRoute(params common.HandlerParams) *Route {
@@ -57,15 +52,12 @@ func (r *Route) AddAllowedIPs(peerKey string) error {
ref.Out,
)
}
r.currentPeerKey = peerKey
return nil
}
func (r *Route) RemoveAllowedIPs() error {
var err error
if _, decErr := r.allowedIPsRefcounter.Decrement(r.route.Network, r.currentPeerKey); decErr != nil {
err = fmt.Errorf("remove allowed IP %s: %w", r.route.Network, decErr)
if _, err := r.allowedIPsRefcounter.Decrement(r.route.Network); err != nil {
return err
}
r.currentPeerKey = ""
return err
return nil
}

View File

@@ -20,8 +20,6 @@ const (
rpFilterPath = "net.ipv4.conf.all.rp_filter"
rpFilterInterfacePath = "net.ipv4.conf.%s.rp_filter"
srcValidMarkPath = "net.ipv4.conf.all.src_valid_mark"
percentEscape = "%25"
dotEscape = "%2E"
)
type iface interface {
@@ -58,11 +56,7 @@ func Setup(wgIface iface) (map[string]int, error) {
continue
}
// Escape '%' and '.' so they survive the dot-to-slash conversion in Set()
safeName := strings.ReplaceAll(intf.Name, "%", percentEscape)
safeName = strings.ReplaceAll(safeName, ".", dotEscape)
i := fmt.Sprintf(rpFilterInterfacePath, safeName)
i := fmt.Sprintf(rpFilterInterfacePath, intf.Name)
oldVal, err := Set(i, 2, true)
if err != nil {
result = multierror.Append(result, err)
@@ -76,11 +70,7 @@ func Setup(wgIface iface) (map[string]int, error) {
// Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1
func Set(key string, desiredValue int, onlyIfOne bool) (int, error) {
path := strings.ReplaceAll(key, ".", "/")
// Unescape interface dots and percent signs
path = strings.ReplaceAll(path, dotEscape, ".")
path = strings.ReplaceAll(path, percentEscape, "%")
path = fmt.Sprintf("/proc/sys/%s", path)
path := fmt.Sprintf("/proc/sys/%s", strings.ReplaceAll(key, ".", "/"))
currentValue, err := os.ReadFile(path)
if err != nil {
return -1, fmt.Errorf("read sysctl %s: %w", key, err)

View File

@@ -1,124 +0,0 @@
package tunnelnotifier
import (
"container/list"
"sync"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/listener"
)
type eventKind int
const (
eventRoutes eventKind = iota
eventIfaceIP
eventIfaceIPv6
eventDNS
)
var (
_ listener.NetworkChangeListener = (*Notifier)(nil)
_ dns.IosDnsManager = (*Notifier)(nil)
)
type event struct {
kind eventKind
payload string
}
type Notifier struct {
mu sync.Mutex
cond *sync.Cond
queue *list.List
closed bool
done chan struct{}
listener listener.NetworkChangeListener
dnsManager dns.IosDnsManager
}
func New(l listener.NetworkChangeListener, dm dns.IosDnsManager) *Notifier {
n := &Notifier{
queue: list.New(),
done: make(chan struct{}),
listener: l,
dnsManager: dm,
}
n.cond = sync.NewCond(&n.mu)
go n.deliverLoop()
return n
}
func (n *Notifier) OnNetworkChanged(routes string) {
n.enqueue(event{kind: eventRoutes, payload: routes})
}
func (n *Notifier) SetInterfaceIP(ip string) {
n.enqueue(event{kind: eventIfaceIP, payload: ip})
}
func (n *Notifier) SetInterfaceIPv6(ip string) {
n.enqueue(event{kind: eventIfaceIPv6, payload: ip})
}
func (n *Notifier) ApplyDns(config string) {
n.enqueue(event{kind: eventDNS, payload: config})
}
// Close stops accepting new events and blocks until the delivery loop has
// drained all queued events and exited.
func (n *Notifier) Close() {
n.mu.Lock()
n.closed = true
n.cond.Signal()
n.mu.Unlock()
<-n.done
}
func (n *Notifier) enqueue(ev event) {
n.mu.Lock()
defer n.mu.Unlock()
if n.closed {
return
}
n.queue.PushBack(ev)
n.cond.Signal()
}
func (n *Notifier) deliverLoop() {
defer close(n.done)
for {
n.mu.Lock()
for n.queue.Len() == 0 && !n.closed {
n.cond.Wait()
}
if n.closed && n.queue.Len() == 0 {
n.mu.Unlock()
return
}
ev := n.queue.Remove(n.queue.Front()).(event)
l := n.listener
dm := n.dnsManager
n.mu.Unlock()
switch ev.kind {
case eventRoutes:
if l != nil {
l.OnNetworkChanged(ev.payload)
}
case eventIfaceIP:
if l != nil {
l.SetInterfaceIP(ev.payload)
}
case eventIfaceIPv6:
if l != nil {
l.SetInterfaceIPv6(ev.payload)
}
case eventDNS:
if dm != nil {
dm.ApplyDns(ev.payload)
}
}
}
}

View File

@@ -1,192 +0,0 @@
package tunnelnotifier
import (
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type call struct {
kind string
payload string
}
type recorder struct {
mu sync.Mutex
calls []call
inFlight atomic.Int32
overlap atomic.Bool
delay time.Duration
}
func (r *recorder) record(kind, payload string) {
if r.inFlight.Add(1) != 1 {
r.overlap.Store(true)
}
if r.delay > 0 {
time.Sleep(r.delay)
}
r.mu.Lock()
r.calls = append(r.calls, call{kind: kind, payload: payload})
r.mu.Unlock()
r.inFlight.Add(-1)
}
func (r *recorder) count() int {
r.mu.Lock()
defer r.mu.Unlock()
return len(r.calls)
}
func (r *recorder) snapshot() []call {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]call, len(r.calls))
copy(out, r.calls)
return out
}
type fakeListener struct {
rec *recorder
}
func (f *fakeListener) OnNetworkChanged(routes string) {
f.rec.record("routes", routes)
}
func (f *fakeListener) SetInterfaceIP(ip string) {
f.rec.record("ip", ip)
}
func (f *fakeListener) SetInterfaceIPv6(ip string) {
f.rec.record("ipv6", ip)
}
type fakeDNSManager struct {
rec *recorder
}
func (f *fakeDNSManager) ApplyDns(config string) {
f.rec.record("dns", config)
}
func TestFIFOOrder(t *testing.T) {
rec := &recorder{}
n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
defer n.Close()
n.SetInterfaceIP("10.0.0.1")
n.SetInterfaceIPv6("fd00::1")
n.ApplyDns(`{"domains":[]}`)
n.OnNetworkChanged("10.0.0.0/8,192.168.0.0/16")
n.ApplyDns(`{"domains":["example.com"]}`)
require.Eventually(t, func() bool { return rec.count() == 5 }, time.Second, time.Millisecond)
expected := []call{
{kind: "ip", payload: "10.0.0.1"},
{kind: "ipv6", payload: "fd00::1"},
{kind: "dns", payload: `{"domains":[]}`},
{kind: "routes", payload: "10.0.0.0/8,192.168.0.0/16"},
{kind: "dns", payload: `{"domains":["example.com"]}`},
}
assert.Equal(t, expected, rec.snapshot())
}
func TestNoOverlappingCalls(t *testing.T) {
rec := &recorder{delay: 100 * time.Microsecond}
n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
defer n.Close()
const producers = 8
const perProducer = 25
var wg sync.WaitGroup
for i := 0; i < producers; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
for j := 0; j < perProducer; j++ {
payload := fmt.Sprintf("%d-%d", id, j)
switch j % 4 {
case 0:
n.OnNetworkChanged(payload)
case 1:
n.SetInterfaceIP(payload)
case 2:
n.SetInterfaceIPv6(payload)
case 3:
n.ApplyDns(payload)
}
}
}(i)
}
wg.Wait()
require.Eventually(t, func() bool { return rec.count() == producers*perProducer }, 5*time.Second, time.Millisecond)
assert.False(t, rec.overlap.Load())
}
func TestDNSAndRoutesInterleaved(t *testing.T) {
rec := &recorder{delay: 100 * time.Microsecond}
n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
defer n.Close()
const events = 50
var wg sync.WaitGroup
wg.Add(2)
go func() {
defer wg.Done()
for i := 0; i < events; i++ {
n.ApplyDns(fmt.Sprintf("dns-%d", i))
}
}()
go func() {
defer wg.Done()
for i := 0; i < events; i++ {
n.OnNetworkChanged(fmt.Sprintf("routes-%d", i))
}
}()
wg.Wait()
require.Eventually(t, func() bool { return rec.count() == 2*events }, 5*time.Second, time.Millisecond)
assert.False(t, rec.overlap.Load())
var dnsSeen, routesSeen int
for _, c := range rec.snapshot() {
switch c.kind {
case "dns":
assert.Equal(t, fmt.Sprintf("dns-%d", dnsSeen), c.payload)
dnsSeen++
case "routes":
assert.Equal(t, fmt.Sprintf("routes-%d", routesSeen), c.payload)
routesSeen++
}
}
assert.Equal(t, events, dnsSeen)
assert.Equal(t, events, routesSeen)
}
func TestCloseDrainsQueue(t *testing.T) {
rec := &recorder{delay: time.Millisecond}
n := New(&fakeListener{rec: rec}, &fakeDNSManager{rec: rec})
const events = 20
for i := 0; i < events; i++ {
n.OnNetworkChanged(fmt.Sprintf("routes-%d", i))
}
n.Close()
require.Equal(t, events, rec.count(), "Close must not return before all queued events are delivered")
n.OnNetworkChanged("after-close")
n.ApplyDns("after-close")
time.Sleep(50 * time.Millisecond)
assert.Equal(t, events, rec.count())
}

View File

@@ -13,6 +13,7 @@ import (
"time"
log "github.com/sirupsen/logrus"
"golang.org/x/exp/maps"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
@@ -636,18 +637,23 @@ func (c *Client) SelectRoute(id string) error {
}
routeManager := engine.GetRouteManager()
routeSelector := routeManager.GetRouteSelector()
if id == "All" {
log.Debugf("select all routes")
routeManager.SelectAllRoutes()
return nil
}
log.Debugf("select route with id: %s", id)
if err := routeManager.SelectRoutes(toNetIDs([]string{id}), true); err != nil {
log.Debugf("error when selecting routes: %s", err)
return err
routeSelector.SelectAllRoutes()
} else {
log.Debugf("select route with id: %s", id)
routes := toNetIDs([]string{id})
routesMap := routeManager.GetClientRoutesWithNetID()
routes = route.ExpandV6ExitPairs(routes, routesMap)
if err := routeSelector.SelectRoutes(routes, true, maps.Keys(routesMap)); err != nil {
log.Debugf("error when selecting routes: %s", err)
return fmt.Errorf("select routes: %w", err)
}
}
routeManager.TriggerSelection(routeManager.GetClientRoutes())
return nil
}
func (c *Client) DeselectRoute(id string) error {
@@ -661,17 +667,21 @@ func (c *Client) DeselectRoute(id string) error {
}
routeManager := engine.GetRouteManager()
routeSelector := routeManager.GetRouteSelector()
if id == "All" {
log.Debugf("deselect all routes")
routeManager.DeselectAllRoutes()
return nil
}
log.Debugf("deselect route with id: %s", id)
if err := routeManager.DeselectRoutes(toNetIDs([]string{id})); err != nil {
log.Debugf("error when deselecting routes: %s", err)
return err
routeSelector.DeselectAllRoutes()
} else {
log.Debugf("deselect route with id: %s", id)
routes := toNetIDs([]string{id})
routesMap := routeManager.GetClientRoutesWithNetID()
routes = route.ExpandV6ExitPairs(routes, routesMap)
if err := routeSelector.DeselectRoutes(routes, maps.Keys(routesMap)); err != nil {
log.Debugf("error when deselecting routes: %s", err)
return fmt.Errorf("deselect routes: %w", err)
}
}
routeManager.TriggerSelection(routeManager.GetClientRoutes())
return nil
}

View File

@@ -1,12 +0,0 @@
//go:build ios
package NetBirdSDK
import "github.com/netbirdio/netbird/version"
// GoClientVersion returns the NetBird Go client version that was baked into
// the framework at compile time via
// -ldflags "-X github.com/netbirdio/netbird/version.version=<version>".
func GoClientVersion() string {
return version.NetbirdVersion()
}

View File

@@ -8,6 +8,7 @@ import (
"sort"
"strings"
"golang.org/x/exp/maps"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
@@ -160,11 +161,30 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
return nil, fmt.Errorf("no route manager")
}
routeSelector := routeManager.GetRouteSelector()
if req.GetAll() {
routeManager.SelectAllRoutes()
} else if err := routeManager.SelectRoutes(toNetIDs(req.GetNetworkIDs()), req.GetAppend()); err != nil {
return nil, err
routeSelector.SelectAllRoutes()
} else {
routes := toNetIDs(req.GetNetworkIDs())
routesMap := routeManager.GetClientRoutesWithNetID()
routes = route.ExpandV6ExitPairs(routes, routesMap)
netIdRoutes := maps.Keys(routesMap)
if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
return nil, fmt.Errorf("select routes: %w", err)
}
// Exit nodes are mutually exclusive: if this selection activates an
// exit node, deselect every other available exit node so two can't be
// selected at once. Non-exit route selections are left untouched.
if requestActivatesExitNode(routes, routesMap) {
if others := otherExitNodeIDs(routesMap, routes); len(others) > 0 {
if err := routeSelector.DeselectRoutes(others, netIdRoutes); err != nil {
return nil, fmt.Errorf("deselect sibling exit nodes: %w", err)
}
}
}
}
routeManager.TriggerSelection(routeManager.GetClientRoutes())
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
@@ -204,11 +224,19 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
return nil, fmt.Errorf("no route manager")
}
routeSelector := routeManager.GetRouteSelector()
if req.GetAll() {
routeManager.DeselectAllRoutes()
} else if err := routeManager.DeselectRoutes(toNetIDs(req.GetNetworkIDs())); err != nil {
return nil, err
routeSelector.DeselectAllRoutes()
} else {
routes := toNetIDs(req.GetNetworkIDs())
routesMap := routeManager.GetClientRoutesWithNetID()
routes = route.ExpandV6ExitPairs(routes, routesMap)
netIdRoutes := maps.Keys(routesMap)
if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil {
return nil, fmt.Errorf("deselect routes: %w", err)
}
}
routeManager.TriggerSelection(routeManager.GetClientRoutes())
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
@@ -233,3 +261,37 @@ func toNetIDs(routes []string) []route.NetID {
return netIDs
}
func isExitNodeRoutes(routes []*route.Route) bool {
return len(routes) > 0 && (route.IsV4DefaultRoute(routes[0].Network) || route.IsV6DefaultRoute(routes[0].Network))
}
// requestActivatesExitNode reports whether any requested NetID maps to an exit
// node (default route) in the current route table.
func requestActivatesExitNode(requested []route.NetID, routesMap map[route.NetID][]*route.Route) bool {
for _, id := range requested {
if isExitNodeRoutes(routesMap[id]) {
return true
}
}
return false
}
// otherExitNodeIDs returns every available exit-node NetID that is not in the
// requested set — the siblings to deselect so a single exit node stays active.
func otherExitNodeIDs(routesMap map[route.NetID][]*route.Route, requested []route.NetID) []route.NetID {
keep := make(map[route.NetID]struct{}, len(requested))
for _, id := range requested {
keep[id] = struct{}{}
}
var others []route.NetID
for id, routes := range routesMap {
if !isExitNodeRoutes(routes) {
continue
}
if _, ok := keep[id]; ok {
continue
}
others = append(others, id)
}
return others
}

View File

@@ -0,0 +1,26 @@
package server
import (
"net/netip"
"testing"
"github.com/stretchr/testify/assert"
"github.com/netbirdio/netbird/route"
)
func TestExitNodeSelectionHelpers(t *testing.T) {
routesMap := map[route.NetID][]*route.Route{
"exitA": {{Network: netip.MustParsePrefix("0.0.0.0/0")}},
"exitB": {{Network: netip.MustParsePrefix("::/0")}},
"lan": {{Network: netip.MustParsePrefix("192.168.0.0/16")}},
}
assert.True(t, requestActivatesExitNode([]route.NetID{"exitA"}, routesMap), "v4 default route is an exit node")
assert.True(t, requestActivatesExitNode([]route.NetID{"exitB"}, routesMap), "v6 default route is an exit node")
assert.False(t, requestActivatesExitNode([]route.NetID{"lan"}, routesMap), "lan route is not an exit node")
assert.False(t, requestActivatesExitNode([]route.NetID{"missing"}, routesMap), "unknown id is not an exit node")
others := otherExitNodeIDs(routesMap, []route.NetID{"exitB"})
assert.ElementsMatch(t, []route.NetID{"exitA"}, others, "only the other exit node is a sibling; the lan route is ignored")
}

View File

@@ -1081,10 +1081,7 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
if err := s.cleanupConnection(); err != nil {
s.mutex.Unlock()
if errors.Is(err, ErrServiceNotUp) {
log.Debugf("Down called while service not up: %v", err)
return nil, err
}
// todo review to update the status in case any type of error
log.Errorf("failed to shut down properly: %v", err)
return nil, err
}
@@ -1157,7 +1154,7 @@ func (s *Server) cleanupConnection() error {
// making the run loop the sole owner of engine shutdown.
if engine != nil {
if err := engine.Stop(); err != nil {
log.Errorf("failed to stop engine during cleanup: %v", err)
return err
}
}

View File

@@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi
// prepareCommandEnv prepares environment variables for command execution on Windows
func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string {
username, domain := parseUsername(localUser.Username)
username, domain := s.parseUsername(localUser.Username)
userEnv, err := s.getUserEnvironment(logger, username, domain)
if err != nil {
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
@@ -383,7 +383,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
return false
}
username, domain := parseUsername(localUser.Username)
username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
req := PtyExecutionRequest{

View File

@@ -1,15 +0,0 @@
//go:build !windows
package server
// isProcessElevated is only meaningful on Windows; other platforms use the
// effective UID check in isCurrentProcessPrivileged.
func isProcessElevated() bool {
return false
}
// isWindowsAccountPrivileged is only reachable on Windows. Fail closed if it
// is ever called on another platform.
func isWindowsAccountPrivileged(string) bool {
return true
}

View File

@@ -1,223 +0,0 @@
//go:build windows
package server
import (
"fmt"
"strings"
"unsafe"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
var (
netapi32 = windows.NewLazySystemDLL("netapi32.dll")
procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups")
)
const (
// lgIncludeIndirect makes NetUserGetLocalGroups also return local groups
// the user belongs to through a global group.
lgIncludeIndirect = 0x1
maxPreferredLength = 0xFFFFFFFF
)
// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0.
type localGroupUsersInfo0 struct {
name *uint16
}
// isProcessElevated reports whether the current process token is elevated
// (TokenElevation): true for elevated administrators, the built-in
// Administrator, administrators with UAC disabled, and SYSTEM; false for
// standard users and administrators running with a UAC-filtered token.
func isProcessElevated() bool {
return windows.GetCurrentProcessToken().IsElevated()
}
// isWindowsAccountPrivileged reports whether the account is privileged on this
// machine: a well-known service account, a built-in Administrator (RID 500),
// or a member of the local Administrators group, directly or through nested
// groups. Evaluation errors count as privileged so policy checks fail closed.
func isWindowsAccountPrivileged(username string) bool {
sid, _, _, err := windows.LookupSID("", username)
if err != nil {
log.Warnf("privilege check: SID lookup for %q failed, treating as privileged: %v", username, err)
return true
}
if isPrivilegedUserSID(sid) {
return true
}
member, err := isLocalAdminsMember(username)
if err != nil {
log.Warnf("privilege check: cannot determine Administrators membership for %q, treating as privileged: %v", username, err)
return true
}
return member
}
// isPrivilegedUserSID reports whether the SID itself identifies a privileged
// principal, without consulting group membership.
func isPrivilegedUserSID(sid *windows.SID) bool {
wellKnown := []windows.WELL_KNOWN_SID_TYPE{
windows.WinLocalSystemSid,
windows.WinLocalServiceSid,
windows.WinNetworkServiceSid,
windows.WinBuiltinAdministratorsSid,
}
for _, sidType := range wellKnown {
if sid.IsWellKnown(sidType) {
return true
}
}
return isBuiltinAdministratorSID(sid)
}
// isBuiltinAdministratorSID reports whether the SID is a machine or domain
// built-in Administrator account (S-1-5-21-...-500). RID 500 is reserved for
// that account; it can be renamed but cannot be removed from the
// Administrators group.
func isBuiltinAdministratorSID(sid *windows.SID) bool {
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
return false
}
count := sid.SubAuthorityCount()
if count < 2 || sid.SubAuthority(0) != 21 {
return false
}
return sid.SubAuthority(uint32(count-1)) == 500
}
// isLocalAdminsMember reports whether the account is a member of the local
// Administrators group.
//
// Local accounts are checked against the local SAM, which is authoritative for
// them and, unlike a token, cannot under-report: UAC filters the tokens of
// local administrators, and a filtered token carries Administrators as
// deny-only, which a membership check on the token would read as "not a
// member". Domain accounts are exempt from that filtering, so for them an S4U
// token is preferred because its group list is LSA's transitive expansion and
// therefore covers nested and universal groups plus the machine's own local
// groups. NetUserGetLocalGroups expands only one global-group hop but needs no
// logon, so it serves as the fallback when no token can be obtained.
func isLocalAdminsMember(username string) (bool, error) {
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
if err != nil {
return false, fmt.Errorf("create Administrators SID: %w", err)
}
account, domain := parseUsername(username)
if NewPrivilegeDropper().isLocalUser(domain) {
return localGroupsContainSID(account, adminSid)
}
member, s4uErr := s4uTokenIsMember(account, domain, adminSid)
if s4uErr == nil {
return member, nil
}
log.Debugf("privilege check: S4U membership check for %q failed, falling back to local group enumeration: %v", username, s4uErr)
member, err = localGroupsContainSID(buildUserCpn(account, domain), adminSid)
if err != nil {
return false, fmt.Errorf("S4U check: %w; local group enumeration: %w", s4uErr, err)
}
return member, nil
}
// s4uTokenIsMember obtains an S4U token for the account and checks whether the
// given SID is enabled in it.
func s4uTokenIsMember(account, domain string, sid *windows.SID) (bool, error) {
token, err := generateS4UUserToken(log.NewEntry(log.StandardLogger()), account, domain)
if err != nil {
return false, err
}
defer func() {
if err := windows.CloseHandle(token); err != nil {
log.Debugf("close S4U token: %v", err)
}
}()
return windows.Token(token).IsMember(sid)
}
// localGroupsContainSID reports whether the wanted group is among the local
// groups the account belongs to, directly or through a global group.
//
// The wanted SID is resolved to its group name once and compared against the
// enumerated names. Well-known SIDs resolve from a static table, so that lookup
// needs no domain controller, and it keeps the comparison correct for a renamed
// or localized group because both sides then carry the new name. Resolving each
// enumerated name back to a SID instead would add a lookup per group that can
// block until it times out while a domain controller is unreachable, and cannot
// change the outcome: the names enumerated here are local groups of this
// machine, whose names are unique, so a name match identifies the group.
//
// A failure to resolve the wanted SID is returned rather than reported as
// "not a member", so a privilege check built on this fails closed.
func localGroupsContainSID(username string, want *windows.SID) (bool, error) {
wantName, _, _, err := want.LookupAccount("")
if err != nil {
return false, fmt.Errorf("resolve group SID %s to a name: %w", want, err)
}
groups, err := netUserGetLocalGroups(username)
if err != nil {
return false, err
}
for _, group := range groups {
if strings.EqualFold(group, wantName) {
return true, nil
}
}
return false, nil
}
// netUserGetLocalGroups returns the names of the local groups the account is a
// member of, including indirect membership through global groups.
func netUserGetLocalGroups(username string) ([]string, error) {
name16, err := windows.UTF16PtrFromString(username)
if err != nil {
return nil, fmt.Errorf("convert username: %w", err)
}
var buf *byte
var entriesRead, totalEntries uint32
status, _, _ := procNetUserGetLocalGroups.Call(
0, // local server
uintptr(unsafe.Pointer(name16)),
0, // level 0: LOCALGROUP_USERS_INFO_0
lgIncludeIndirect,
uintptr(unsafe.Pointer(&buf)),
maxPreferredLength,
uintptr(unsafe.Pointer(&entriesRead)),
uintptr(unsafe.Pointer(&totalEntries)),
)
if status != 0 {
return nil, fmt.Errorf("NetUserGetLocalGroups for %q: status %d", username, status)
}
if buf == nil {
return nil, nil
}
defer func() {
if err := windows.NetApiBufferFree(buf); err != nil {
log.Debugf("free NetApi buffer: %v", err)
}
}()
// MAX_PREFERRED_LENGTH makes the API allocate as much as it needs, so a
// short read is not expected. Report it rather than silently returning a
// subset of the account's groups.
if entriesRead != totalEntries {
return nil, fmt.Errorf("NetUserGetLocalGroups for %q returned %d of %d groups", username, entriesRead, totalEntries)
}
entries := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buf)), entriesRead)
groups := make([]string, 0, entriesRead)
for _, entry := range entries {
groups = append(groups, windows.UTF16PtrToString(entry.name))
}
return groups, nil
}

View File

@@ -1,256 +0,0 @@
//go:build windows
package server
import (
"os/user"
"testing"
"unsafe"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// filterNormalAccount limits NetUserEnum to normal user accounts.
const filterNormalAccount = 0x2
// userInfo0 mirrors USER_INFO_0.
type userInfo0 struct {
name *uint16
}
func mustParseSID(t *testing.T, s string) *windows.SID {
t.Helper()
sid, err := windows.StringToSid(s)
require.NoError(t, err, "parse SID %s", s)
return sid
}
// localAccountNames returns the names of the local user accounts.
func localAccountNames(t *testing.T) []string {
t.Helper()
var buf *byte
var entriesRead, totalEntries, resume uint32
err := windows.NetUserEnum(nil, 0, filterNormalAccount, &buf, maxPreferredLength,
&entriesRead, &totalEntries, &resume)
require.NoError(t, err, "enumerate local users")
t.Cleanup(func() {
require.NoError(t, windows.NetApiBufferFree(buf), "free NetApi buffer")
})
entries := unsafe.Slice((*userInfo0)(unsafe.Pointer(buf)), entriesRead)
names := make([]string, 0, entriesRead)
for _, entry := range entries {
names = append(names, windows.UTF16PtrToString(entry.name))
}
return names
}
// localAccountNameByRID returns the name of the local account carrying the
// given RID. Accounts such as Administrator and Guest can be renamed and are
// localized, so tests must not name them literally.
func localAccountNameByRID(t *testing.T, rid uint32) string {
t.Helper()
for _, name := range localAccountNames(t) {
sid, _, _, err := windows.LookupSID("", name)
if err != nil {
continue
}
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
continue
}
count := sid.SubAuthorityCount()
if count < 2 || sid.SubAuthority(0) != 21 {
continue
}
if sid.SubAuthority(uint32(count-1)) == rid {
return name
}
}
t.Fatalf("no local account with RID %d", rid)
return ""
}
// wellKnownAccountName resolves a well-known SID to the qualified account name
// the local system uses for it, which is localized.
func wellKnownAccountName(t *testing.T, sidType windows.WELL_KNOWN_SID_TYPE) string {
t.Helper()
sid, err := windows.CreateWellKnownSid(sidType)
require.NoError(t, err, "create well-known SID")
name, domain, _, err := sid.LookupAccount("")
require.NoError(t, err, "resolve %s to an account name", sid)
if domain == "" {
return name
}
return domain + `\` + name
}
func TestIsBuiltinAdministratorSID(t *testing.T) {
tests := []struct {
name string
sid string
want bool
}{
{"machine_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
{"domain_administrator", "S-1-5-21-3390233681-4087452608-412898826-500", true},
{"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
{"guest_account", "S-1-5-21-1111111111-2222222222-3333333333-501", false},
{"domain_admins_group", "S-1-5-21-1111111111-2222222222-3333333333-512", false},
{"system", "S-1-5-18", false},
{"administrators_group", "S-1-5-32-544", false},
{"non_nt_authority", "S-1-1-0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isBuiltinAdministratorSID(mustParseSID(t, tt.sid))
assert.Equal(t, tt.want, result, "RID 500 detection for %s", tt.sid)
})
}
}
func TestIsPrivilegedUserSID(t *testing.T) {
tests := []struct {
name string
sid string
want bool
}{
{"local_system", "S-1-5-18", true},
{"local_service", "S-1-5-19", true},
{"network_service", "S-1-5-20", true},
{"administrators_group", "S-1-5-32-544", true},
{"builtin_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
{"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
{"users_group", "S-1-5-32-545", false},
{"everyone", "S-1-1-0", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isPrivilegedUserSID(mustParseSID(t, tt.sid))
assert.Equal(t, tt.want, result, "SID privilege classification for %s", tt.sid)
})
}
}
func TestIsWindowsAccountPrivileged(t *testing.T) {
tests := []struct {
name string
username string
want bool
}{
{"system", wellKnownAccountName(t, windows.WinLocalSystemSid), true},
{"local_service", wellKnownAccountName(t, windows.WinLocalServiceSid), true},
{"network_service", wellKnownAccountName(t, windows.WinNetworkServiceSid), true},
{"administrators_group", wellKnownAccountName(t, windows.WinBuiltinAdministratorsSid), true},
// The built-in Administrator (RID 500) and Guest (RID 501) accounts
// exist on every Windows installation, though they may be disabled.
{"builtin_administrator", localAccountNameByRID(t, 500), true},
{"guest", localAccountNameByRID(t, 501), false},
// Unresolvable accounts fail closed.
{"nonexistent_user", "netbird-no-such-user", true},
{"empty_username", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isWindowsAccountPrivileged(tt.username)
assert.Equal(t, tt.want, result, "account privilege classification for %q", tt.username)
})
}
}
func TestIsProcessElevated(t *testing.T) {
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
// Token(0) makes CheckTokenMembership evaluate the caller's own token.
member, err := windows.Token(0).IsMember(adminSid)
require.NoError(t, err, "check own Administrators membership")
elevated := isProcessElevated()
t.Logf("member of Administrators: %v, token elevated: %v", member, elevated)
// An enabled Administrators SID in the token implies an elevated token.
// CI runs this test as SYSTEM, which satisfies both.
if member {
assert.True(t, elevated, "token with enabled Administrators membership must report elevated")
}
}
// TestS4UMembershipAgreesWithLocalGroups exercises the S4U token path used
// for domain accounts. S4U logons need the TCB privilege, so the test runs
// only as SYSTEM (which is how CI executes the suite). For local accounts the
// token's Administrators membership must agree with the SAM enumeration.
func TestS4UMembershipAgreesWithLocalGroups(t *testing.T) {
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
require.NoError(t, err, "create SYSTEM SID")
current, err := user.Current()
require.NoError(t, err, "get current user")
if current.Uid != system.String() {
t.Skipf("S4U logon requires SYSTEM (running as %s)", current.Username)
}
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
checked := 0
for _, name := range localAccountNames(t) {
viaToken, err := s4uTokenIsMember(name, ".", adminSid)
if err != nil {
// Disabled or logon-restricted accounts cannot get an S4U logon.
t.Logf("skipping %s: %v", name, err)
continue
}
viaSAM, err := localGroupsContainSID(name, adminSid)
require.NoError(t, err, "enumerate local groups for %s", name)
assert.Equal(t, viaSAM, viaToken, "S4U token and SAM enumeration must agree on Administrators membership for %s", name)
checked++
}
t.Logf("checked %d local accounts via S4U", checked)
}
// TestLocalGroupsContainSID_Administrator checks the positive case against the
// built-in Administrator, a member of Administrators on every installation.
func TestLocalGroupsContainSID_Administrator(t *testing.T) {
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
administrator := localAccountNameByRID(t, 500)
member, err := localGroupsContainSID(administrator, adminSid)
require.NoError(t, err, "enumerate local groups for %s", administrator)
assert.True(t, member, "%s is a member of the Administrators group", administrator)
}
// TestLocalGroupsContainSID_UnresolvableGroupFailsClosed covers a wanted SID
// that resolves to no group: the error must surface rather than being reported
// as "not a member", so the privilege check treats the account as privileged.
func TestLocalGroupsContainSID_UnresolvableGroupFailsClosed(t *testing.T) {
unknown := mustParseSID(t, "S-1-5-21-1111111111-2222222222-3333333333-4444")
_, err := localGroupsContainSID(localAccountNameByRID(t, 500), unknown)
require.Error(t, err, "must report an error when the wanted group cannot be identified")
}
func TestLocalGroupsContainSID_Guest(t *testing.T) {
guestsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid)
require.NoError(t, err, "create Guests SID")
adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
require.NoError(t, err, "create Administrators SID")
guest := localAccountNameByRID(t, 501)
inGuests, err := localGroupsContainSID(guest, guestsSid)
require.NoError(t, err, "enumerate local groups for %s", guest)
assert.True(t, inGuests, "%s is a member of the Guests group", guest)
inAdmins, err := localGroupsContainSID(guest, adminsSid)
require.NoError(t, err, "enumerate local groups for %s", guest)
assert.False(t, inAdmins, "%s is not a member of the Administrators group", guest)
}

View File

@@ -420,13 +420,6 @@ func TestServer_PortConflictHandling(t *testing.T) {
func TestServer_IsPrivilegedUser(t *testing.T) {
// Windows classification depends on account SIDs and group membership, and
// the accounts involved carry localized, renameable names. It is covered by
// TestIsWindowsAccountPrivileged, which resolves them from well-known SIDs.
if runtime.GOOS == "windows" {
t.Skip("covered by TestIsWindowsAccountPrivileged")
}
tests := []struct {
username string
expected bool
@@ -447,11 +440,39 @@ func TestServer_IsPrivilegedUser(t *testing.T) {
expected: false,
description: "empty username should not be privileged",
},
{
username: "Administrator",
expected: false,
description: "Administrator should not be privileged on non-Windows systems",
},
}
// Add Windows-specific tests
if runtime.GOOS == "windows" {
tests = append(tests, []struct {
username string
expected bool
description string
}{
{
username: "Administrator",
expected: true,
description: "Administrator should be considered privileged on Windows",
},
{
username: "administrator",
expected: true,
description: "administrator should be considered privileged on Windows (case insensitive)",
},
}...)
} else {
// On non-Windows systems, Administrator should not be privileged
tests = append(tests, []struct {
username string
expected bool
description string
}{
{
username: "Administrator",
expected: false,
description: "Administrator should not be privileged on non-Windows systems",
},
}...)
}
for _, tt := range tests {

View File

@@ -17,7 +17,7 @@ import (
// createSftpCommand creates a Windows SFTP command with user switching.
// The caller must close the returned token handle after starting the process.
func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, windows.Token, error) {
username, domain := parseUsername(targetUser.Username)
username, domain := s.parseUsername(targetUser.Username)
netbirdPath, err := os.Executable()
if err != nil {

View File

@@ -16,6 +16,11 @@ var (
ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges")
)
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
func isPlatformUnix() bool {
return getCurrentOS() != "windows"
}
// Dependency injection variables for testing - allows mocking dynamic runtime checks
var (
getCurrentUser = currentUserWithGetent
@@ -24,9 +29,6 @@ var (
getIsProcessPrivileged = isCurrentProcessPrivileged
getEuid = os.Geteuid
getProcessElevated = isProcessElevated
getWindowsAccountPrivileged = isWindowsAccountPrivileged
)
const (
@@ -63,13 +65,6 @@ type PrivilegeCheckResult struct {
RequiresUserSwitching bool
}
// privilegeCheckContext holds all context needed for privilege checking
type privilegeCheckContext struct {
currentUser *user.User
currentUserPrivileged bool
allowRoot bool
}
// CheckPrivileges performs comprehensive privilege checking for all SSH features.
// This is the single source of truth for privilege decisions across the SSH server.
func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult {
@@ -180,42 +175,6 @@ func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, err
return u, nil
}
// SetAllowRootLogin configures root login access
func (s *Server) SetAllowRootLogin(allow bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.allowRootLogin = allow
}
// userNameLookup performs user lookup with root login permission check
func (s *Server) userNameLookup(username string) (*user.User, error) {
result, err := s.userPrivilegeCheck(username)
if err != nil {
return nil, err
}
return result.User, nil
}
// userPrivilegeCheck performs user lookup with full privilege check result
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return result, result.Error
}
return result, nil
}
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
func isPlatformUnix() bool {
return getCurrentOS() != "windows"
}
// isSameResolvedUser compares two resolved user identities
func isSameResolvedUser(user1, user2 *user.User) bool {
if user1 == nil || user2 == nil {
@@ -224,6 +183,13 @@ func isSameResolvedUser(user1, user2 *user.User) bool {
return user1.Uid == user2.Uid
}
// privilegeCheckContext holds all context needed for privilege checking
type privilegeCheckContext struct {
currentUser *user.User
currentUserPrivileged bool
allowRoot bool
}
// isSameUser checks if two usernames refer to the same user
// SECURITY: This function must be conservative - it should only return true
// when we're certain both usernames refer to the exact same user identity
@@ -287,24 +253,159 @@ func isWindowsSameUser(requestedUsername, currentUsername string) bool {
return strings.EqualFold(reqDomain, curDomain)
}
// SetAllowRootLogin configures root login access
func (s *Server) SetAllowRootLogin(allow bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.allowRootLogin = allow
}
// userNameLookup performs user lookup with root login permission check
func (s *Server) userNameLookup(username string) (*user.User, error) {
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return nil, result.Error
}
return result.User, nil
}
// userPrivilegeCheck performs user lookup with full privilege check result
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
result := s.CheckPrivileges(PrivilegeCheckRequest{
RequestedUsername: username,
FeatureSupportsUserSwitch: true,
FeatureName: FeatureSSHLogin,
})
if !result.Allowed {
return result, result.Error
}
return result, nil
}
// isPrivilegedUsername checks if the given username represents a privileged user across platforms.
// On Unix: root.
// On Windows: well-known service accounts, built-in Administrator accounts,
// and members of the local Administrators group; handles domain-qualified
// usernames like "DOMAIN\user" or "user@domain.com".
// On Unix: root
// On Windows: Administrator, SYSTEM (case-insensitive)
// Handles domain-qualified usernames like "DOMAIN\Administrator" or "user@domain.com"
func isPrivilegedUsername(username string) bool {
if getCurrentOS() != "windows" {
return username == "root"
}
return getWindowsAccountPrivileged(username)
bareUsername := username
// Handle Windows domain format: DOMAIN\username
if idx := strings.LastIndex(username, `\`); idx != -1 {
bareUsername = username[idx+1:]
}
// Handle email-style format: username@domain.com
if idx := strings.Index(bareUsername, "@"); idx != -1 {
bareUsername = bareUsername[:idx]
}
return isWindowsPrivilegedUser(bareUsername)
}
// isWindowsPrivilegedUser checks if a bare username (domain already stripped) represents a Windows privileged account
func isWindowsPrivilegedUser(bareUsername string) bool {
// common privileged usernames (case insensitive)
privilegedNames := []string{
"administrator",
"admin",
"root",
"system",
"localsystem",
"networkservice",
"localservice",
}
usernameLower := strings.ToLower(bareUsername)
for _, privilegedName := range privilegedNames {
if usernameLower == privilegedName {
return true
}
}
// computer accounts (ending with $) are not privileged by themselves
// They only gain privileges through group membership or specific SIDs
if targetUser, err := lookupUser(bareUsername); err == nil {
return isWindowsPrivilegedSID(targetUser.Uid)
}
return false
}
// isWindowsPrivilegedSID checks if a Windows SID represents a privileged account
func isWindowsPrivilegedSID(sid string) bool {
privilegedSIDs := []string{
"S-1-5-18", // Local System (SYSTEM)
"S-1-5-19", // Local Service (NT AUTHORITY\LOCAL SERVICE)
"S-1-5-20", // Network Service (NT AUTHORITY\NETWORK SERVICE)
"S-1-5-32-544", // Administrators group (BUILTIN\Administrators)
"S-1-5-500", // Built-in Administrator account (local machine RID 500)
}
for _, privilegedSID := range privilegedSIDs {
if sid == privilegedSID {
return true
}
}
// Check for domain administrator accounts (RID 500 in any domain)
// Format: S-1-5-21-domain-domain-domain-500
// This is reliable as RID 500 is reserved for the domain Administrator account
if strings.HasPrefix(sid, "S-1-5-21-") && strings.HasSuffix(sid, "-500") {
return true
}
// Check for other well-known privileged RIDs in domain contexts
// RID 512 = Domain Admins group, RID 516 = Domain Controllers group
if strings.HasPrefix(sid, "S-1-5-21-") {
if strings.HasSuffix(sid, "-512") || // Domain Admins group
strings.HasSuffix(sid, "-516") || // Domain Controllers group
strings.HasSuffix(sid, "-519") { // Enterprise Admins group
return true
}
}
return false
}
// isCurrentProcessPrivileged checks if the current process is running with elevated privileges.
// On Unix systems, this means running as root (UID 0).
// On Windows, this means the process token is elevated (administrators, SYSTEM).
// On Windows, this means running as Administrator or SYSTEM.
func isCurrentProcessPrivileged() bool {
if getCurrentOS() == "windows" {
return getProcessElevated()
return isWindowsElevated()
}
return getEuid() == 0
}
// isWindowsElevated checks if the current process is running with elevated privileges on Windows
func isWindowsElevated() bool {
currentUser, err := getCurrentUser()
if err != nil {
log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err)
return false
}
if isWindowsPrivilegedSID(currentUser.Uid) {
log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid)
return true
}
if isPrivilegedUsername(currentUser.Username) {
log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username)
return true
}
log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid)
return false
}

View File

@@ -4,7 +4,6 @@ import (
"errors"
"os/user"
"runtime"
"strings"
"testing"
"github.com/stretchr/testify/assert"
@@ -28,8 +27,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
originalLookupUser := lookupUser
originalGetCurrentOS := getCurrentOS
originalGetEuid := getEuid
originalGetProcessElevated := getProcessElevated
originalGetWindowsAccountPrivileged := getWindowsAccountPrivileged
// Reset caches to ensure clean test state
// Set test values - inject platform dependencies
getCurrentUser = func() (*user.User, error) {
@@ -54,31 +53,16 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
return euid
}
// Simulate the Windows token elevation check based on the fixture user:
// the built-in Administrator (RID 500) and SYSTEM run elevated.
getProcessElevated = func() bool {
// Mock privilege detection based on the test user
getIsProcessPrivileged = func() bool {
if currentUser == nil {
return false
}
return currentUser.Uid == "S-1-5-18" || strings.HasSuffix(currentUser.Uid, "-500")
}
// Simulate the Windows account classifier for the fixture accounts.
// "root" does not exist on Windows; the real classifier fails closed on
// unresolvable accounts, so it counts as privileged here too.
getWindowsAccountPrivileged = func(username string) bool {
bare := username
if idx := strings.LastIndex(bare, `\`); idx != -1 {
bare = bare[idx+1:]
}
if idx := strings.Index(bare, "@"); idx != -1 {
bare = bare[:idx]
}
switch strings.ToLower(bare) {
case "administrator", "system", "root":
// Check both username and SID for Windows systems
if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) {
return true
}
return false
return isPrivilegedUsername(currentUser.Username)
}
// Return cleanup function
@@ -87,8 +71,10 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
lookupUser = originalLookupUser
getCurrentOS = originalGetCurrentOS
getEuid = originalGetEuid
getProcessElevated = originalGetProcessElevated
getWindowsAccountPrivileged = originalGetWindowsAccountPrivileged
getIsProcessPrivileged = isCurrentProcessPrivileged
// Reset caches after test
}
}
@@ -435,9 +421,6 @@ func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) {
}
func TestPrivilegedUsernameDetection(t *testing.T) {
// Windows classification is syscall-backed (SID resolution, group
// membership) and is covered by privileges_windows_test.go; here only the
// Unix logic and the platform dispatch are exercised.
tests := []struct {
name string
username string
@@ -449,9 +432,25 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
{"unix_regular_user", "alice", "linux", false},
{"unix_root_capital", "Root", "linux", false}, // Case-sensitive
// Windows dispatch to the (mocked) account classifier
// Windows tests
{"windows_administrator", "Administrator", "windows", true},
{"windows_system", "SYSTEM", "windows", true},
{"windows_admin", "admin", "windows", true},
{"windows_admin_lowercase", "administrator", "windows", true}, // Case-insensitive
{"windows_domain_admin", "DOMAIN\\Administrator", "windows", true},
{"windows_email_admin", "admin@domain.com", "windows", true},
{"windows_regular_user", "alice", "windows", false},
{"windows_domain_user", "DOMAIN\\alice", "windows", false},
{"windows_localsystem", "localsystem", "windows", true},
{"windows_networkservice", "networkservice", "windows", true},
{"windows_localservice", "localservice", "windows", true},
// Computer accounts (these depend on current user context in real implementation)
{"windows_computer_account", "WIN2K19-C2$", "windows", false}, // Computer account by itself not privileged
{"windows_domain_computer", "DOMAIN\\COMPUTER$", "windows", false}, // Domain computer account
// Cross-platform
{"root_on_windows", "root", "windows", true}, // Root should be privileged everywhere
}
for _, tt := range tests {
@@ -461,7 +460,49 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
defer cleanup()
result := isPrivilegedUsername(tt.username)
assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform)
assert.Equal(t, tt.privileged, result)
})
}
}
func TestWindowsPrivilegedSIDDetection(t *testing.T) {
tests := []struct {
name string
sid string
privileged bool
description string
}{
// Well-known system accounts
{"system_account", "S-1-5-18", true, "Local System (SYSTEM)"},
{"local_service", "S-1-5-19", true, "Local Service"},
{"network_service", "S-1-5-20", true, "Network Service"},
{"administrators_group", "S-1-5-32-544", true, "Administrators group"},
{"builtin_administrator", "S-1-5-500", true, "Built-in Administrator"},
// Domain accounts
{"domain_administrator", "S-1-5-21-1234567890-1234567890-1234567890-500", true, "Domain Administrator (RID 500)"},
{"domain_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-512", true, "Domain Admins group"},
{"domain_controllers_group", "S-1-5-21-1234567890-1234567890-1234567890-516", true, "Domain Controllers group"},
{"enterprise_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-519", true, "Enterprise Admins group"},
// Regular users
{"regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1001", false, "Regular domain user"},
{"another_regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1234", false, "Another regular user"},
{"local_user", "S-1-5-21-1234567890-1234567890-1234567890-1000", false, "Local regular user"},
// Groups that are not privileged
{"domain_users", "S-1-5-21-1234567890-1234567890-1234567890-513", false, "Domain Users group"},
{"power_users", "S-1-5-32-547", false, "Power Users group"},
// Invalid SIDs
{"malformed_sid", "S-1-5-invalid", false, "Malformed SID"},
{"empty_sid", "", false, "Empty SID"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := isWindowsPrivilegedSID(tt.sid)
assert.Equal(t, tt.privileged, result, "Failed for %s: %s", tt.description, tt.sid)
})
}
}

View File

@@ -91,7 +91,7 @@ func validateUsernameFormat(username string) error {
func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) {
logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty)
username, _ := parseUsername(localUser.Username)
username, _ := s.parseUsername(localUser.Username)
if err := validateUsername(username); err != nil {
return nil, nil, fmt.Errorf("invalid username %q: %w", username, err)
}
@@ -102,7 +102,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
// createUserSwitchCommand creates a command with Windows user switching.
// Returns the command and a cleanup function that must be called after starting the process.
func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
username, domain := parseUsername(localUser.Username)
username, domain := s.parseUsername(localUser.Username)
shell := getUserShell(localUser.Uid)
@@ -138,7 +138,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session,
}
// parseUsername extracts username and domain from a Windows username
func parseUsername(fullUsername string) (username, domain string) {
func (s *Server) parseUsername(fullUsername string) (username, domain string) {
// Handle DOMAIN\username format
if idx := strings.LastIndex(fullUsername, `\`); idx != -1 {
domain = fullUsername[:idx]

View File

@@ -79,15 +79,13 @@ type Info struct {
EnableSSHLocalPortForwarding bool
EnableSSHRemotePortForwarding bool
DisableSSHAuth bool
SyncMessageVersion *int
}
func (i *Info) SetFlags(
rosenpassEnabled, rosenpassPermissive bool,
serverSSHAllowed *bool,
disableClientRoutes, disableServerRoutes,
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int,
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool,
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
disableSSHAuth *bool,
) {
@@ -105,8 +103,6 @@ func (i *Info) SetFlags(
i.BlockInbound = blockInbound
i.DisableIPv6 = disableIPv6
i.SyncMessageVersion = syncMessageVersion
if enableSSHRoot != nil {
i.EnableSSHRoot = *enableSSHRoot
}

View File

@@ -51,7 +51,7 @@ func autostartDisabledByMDM(policy *mdm.Policy) bool {
// netbirdFootprintExists reports whether the machine already carries NetBird
// daemon config or state, meaning this is not a genuinely fresh install. It is
// the update-safety gate for the autostart default: upgrading users always
// have a footprint, so an update can never trigger a autostart entry write.
// have a footprint, so an update can never trigger a login-item write.
func netbirdFootprintExists() bool {
candidates := []string{
profilemanager.DefaultConfigPath,
@@ -69,23 +69,9 @@ func netbirdFootprintExists() bool {
// applyAutostartDefault runs the one-time launch-on-login default for genuinely
// fresh installs. The autostartInitialized marker is persisted before any
// enable attempt so a crash mid-flow degrades to "never enabled" instead of
// retrying autostart entry writes on every launch. A user's later disable in
// retrying login-item writes on every launch. A user's later disable in
// Settings is never overridden: the marker guarantees at-most-once, ever.
func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, prefs *preferences.Store, prefsFileExisted bool) {
mdmDisabled := autostartDisabledByMDM(mdm.LoadPolicy())
if mdmDisabled {
if enabled, err := autostart.IsEnabled(ctx); err != nil {
log.Warnf("MDM disableAutostart: read autostart state: %v", err)
} else if enabled {
if err := autostart.SetEnabled(ctx, false); err != nil {
log.Warnf("MDM disableAutostart: force off failed: %v", err)
} else {
log.Info("MDM disableAutostart enforced: autostart turned off")
}
}
}
priorFootprint := netbirdFootprintExists() || prefsFileExisted
if prefs.Get().AutostartInitialized {
@@ -98,7 +84,7 @@ func applyAutostartDefault(ctx context.Context, autostart *services.Autostart, p
state := autostartDefaultState{
supported: autostart.Supported(ctx),
mdmDisabled: mdmDisabled,
mdmDisabled: autostartDisabledByMDM(mdm.LoadPolicy()),
priorInstall: priorFootprint,
}
enable, reason := shouldEnableAutostartDefault(state)

View File

@@ -1,13 +1,10 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangleIcon, DownloadIcon } from "lucide-react";
import { Browser } from "@wailsio/runtime";
import { Version } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
import { useStatus } from "@/contexts/StatusContext.tsx";
const RELEASES_URL = "https://github.com/netbirdio/netbird/releases/latest";
const RC_RELEASES_URL = "https://pkgs.netbird.io/releases/rc";
function openUrl(url: string) {
Browser.OpenURL(url).catch(() => globalThis.open(url, "_blank"));
@@ -15,26 +12,7 @@ function openUrl(url: string) {
export const DaemonOutdatedOverlay = () => {
const { t } = useTranslation();
const { status, isDaemonOutdated } = useStatus();
const [guiVersion, setGuiVersion] = useState<string>("-");
const clientVersion = status?.daemonVersion ?? "—";
const isRc = /-rc/i.test(guiVersion) || /-rc/i.test(clientVersion);
const downloadUrl = isRc ? RC_RELEASES_URL : RELEASES_URL;
useEffect(() => {
if (!isDaemonOutdated) return;
let cancelled = false;
Version.GUI()
.then((v) => {
if (!cancelled) setGuiVersion(v);
})
.catch((err) => console.error("[DaemonOutdatedOverlay] GUI version error", err));
return () => {
cancelled = true;
};
}, [isDaemonOutdated]);
const { isDaemonOutdated } = useStatus();
if (!isDaemonOutdated) return null;
@@ -60,37 +38,10 @@ export const DaemonOutdatedOverlay = () => {
<p className={"text-sm text-nb-gray-300"}>{t("daemon.outdated.description")}</p>
</div>
<div className={"flex flex-col items-center gap-0.5 text-center"}>
<p className={"text-sm font-semibold text-nb-gray-100"}>
{clientVersion === "development" ? (
<span>
{t("settings.about.clientName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.client", { version: clientVersion })
)}
</p>
<p className={"text-sm font-medium text-nb-gray-250"}>
{guiVersion === "development" ? (
<span>
{t("settings.about.guiName")}{" "}
<span className={"font-mono text-yellow-400"}>
{t("settings.about.development")}
</span>
</span>
) : (
t("settings.about.gui", { version: guiVersion })
)}
</p>
</div>
<div className={"wails-no-draggable"}>
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(downloadUrl)}>
<Button variant={"primary"} size={"xs"} onClick={() => openUrl(RELEASES_URL)}>
<DownloadIcon size={14} />
{t("daemon.outdated.download")}
{t("update.card.getInstaller")}
</Button>
</div>
</div>

View File

@@ -28,7 +28,6 @@ type ProfileContextValue = {
loaded: boolean;
refresh: () => Promise<void>;
switchProfile: (id: string) => Promise<void>;
switchProfileNoConnect: (id: string) => Promise<void>;
addProfile: (name: string) => Promise<string>;
removeProfile: (id: string) => Promise<void>;
renameProfile: (id: string, newName: string) => Promise<void>;
@@ -113,16 +112,6 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
[username, refresh],
);
// Manage-profiles variant: switches without connecting, so the user can
// still adjust the management URL before bringing the connection up.
const switchProfileNoConnect = useCallback(
async (id: string) => {
await ProfileSwitcher.SwitchActiveNoConnect({ profileName: id, username });
await refresh();
},
[username, refresh],
);
// addProfile creates a profile by display name and returns the
// daemon-generated ID, so the caller can immediately address it by ID.
const addProfile = useCallback(
@@ -169,7 +158,6 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
loaded,
refresh,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,
@@ -183,7 +171,6 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => {
loaded,
refresh,
switchProfile,
switchProfileNoConnect,
addProfile,
removeProfile,
renameProfile,

View File

@@ -2,7 +2,6 @@ import { type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Browser } from "@wailsio/runtime";
import { DownloadIcon, NotepadText } from "lucide-react";
import { Update as UpdateSvc } from "@bindings/services";
import { Button } from "@/components/buttons/Button";
import { useClientVersion } from "@/contexts/ClientVersionContext";
import { cn } from "@/lib/cn";
@@ -15,12 +14,6 @@ function openUrl(url: string) {
});
}
function openInstallerDownload() {
UpdateSvc.DownloadURL()
.then(openUrl)
.catch(() => openUrl(GITHUB_RELEASES));
}
export function UpdateVersionCard() {
const { t } = useTranslation();
const { updateVersion, enforced, triggerUpdate } = useClientVersion();
@@ -44,7 +37,11 @@ export function UpdateVersionCard() {
{t("update.card.installNow")}
</Button>
) : (
<Button variant={"primary"} size={"xs"} onClick={openInstallerDownload}>
<Button
variant={"primary"}
size={"xs"}
onClick={() => openUrl(GITHUB_RELEASES)}
>
<DownloadIcon size={14} />
{t("update.card.getInstaller")}
</Button>

View File

@@ -45,7 +45,7 @@ export function ProfilesTab() {
activeProfileId,
loaded,
username,
switchProfileNoConnect,
switchProfile,
addProfile,
removeProfile,
renameProfile,
@@ -100,7 +100,7 @@ export function ProfilesTab() {
confirmLabel: t("profile.switch.confirm"),
});
if (!ok) return;
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfileNoConnect(id));
await guarded(i18next.t("profile.error.switchTitle"), () => switchProfile(id));
};
const handleDeregister = async (id: string, name: string) => {
@@ -129,13 +129,14 @@ export function ProfilesTab() {
await guarded(i18next.t("profile.error.createTitle"), async () => {
const id = await addProfile(name);
// SetConfig is keyed by the new profile's ID, so it writes the
// not-yet-active profile before the switch makes it current.
// not-yet-active profile. Write before switching so any reconnect
// targets the right deployment.
if (!isNetbirdCloud(managementUrl)) {
await SettingsSvc.SetConfig(
new SetConfigParams({ profileName: id, username, managementUrl }),
);
}
await switchProfileNoConnect(id);
await switchProfile(id);
});
};

View File

@@ -73,13 +73,6 @@ export default function SessionExpirationDialog() {
let offCancel: (() => void) | undefined;
// Return the dialog to its interactive state and dismiss the browser popup
const resetDialog = () => {
offCancel?.();
WindowManager.CloseBrowserLogin().catch(console.error);
setBusy(false);
};
try {
const start = await Session.RequestExtend({ hint: "" });
const uri = start.verificationUriComplete || start.verificationUri;
@@ -112,22 +105,25 @@ export default function SessionExpirationDialog() {
if (outcome.kind === "cancel") {
waitPromise.cancel?.();
waitPromise.catch(() => {});
resetDialog();
return;
}
// Another surface owns this flow; keep the dialog open to retry.
if (outcome.result.preempted) {
resetDialog();
return;
}
WindowManager.CloseRenewFlow().catch(console.error);
// Close before the popup so the restore can't flash this window back.
WindowManager.CloseSessionExpiration().catch(console.error);
} catch (e) {
resetDialog();
await errorDialog({
Title: t("sessionExpiration.extendFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
offCancel?.();
WindowManager.CloseBrowserLogin().catch(console.error);
setBusy(false);
}
}, [busy, t]);
@@ -143,11 +139,12 @@ export default function SessionExpirationDialog() {
});
WindowManager.CloseSessionExpiration().catch(console.error);
} catch (e) {
setBusy(false);
await errorDialog({
Title: t("sessionExpiration.logoutFailedTitle"),
Message: formatErrorMessage(e),
});
} finally {
setBusy(false);
}
}, [busy, t]);

View File

@@ -22,9 +22,6 @@ type WelcomeStepTrayProps = {
export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>) {
const { t } = useTranslation();
const trayScreenshot = trayScreenshotForOS();
// macOS has no tray — the icon sits in the menu bar, so the copy says so.
const titleKey = isMacOS() ? "welcome.titleMac" : "welcome.title";
const descriptionKey = isMacOS() ? "welcome.descriptionMac" : "welcome.description";
return (
<>
@@ -39,9 +36,9 @@ export function WelcomeStepTray({ onContinue }: Readonly<WelcomeStepTrayProps>)
<div className={"flex w-full flex-col gap-1"}>
<DialogHeading id={"nb-welcome-title"} align={"left"}>
{t(titleKey)}
{t("welcome.title")}
</DialogHeading>
<DialogDescription align={"left"}>{t(descriptionKey)}</DialogDescription>
<DialogDescription align={"left"}>{t("welcome.description")}</DialogDescription>
</div>
<DialogActions>

View File

@@ -1034,15 +1034,9 @@
"welcome.title": {
"message": "Suchen Sie NetBird in der Taskleiste"
},
"welcome.titleMac": {
"message": "Suchen Sie NetBird in der Menüleiste"
},
"welcome.description": {
"message": "NetBird läuft in Ihrer Taskleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
},
"welcome.descriptionMac": {
"message": "NetBird läuft in Ihrer Menüleiste. Klicken Sie auf das Symbol, um sich zu verbinden, Profile zu wechseln oder die Einstellungen zu öffnen."
},
"welcome.continue": {
"message": "Weiter"
},
@@ -1299,13 +1293,10 @@
"message": "Dokumentation"
},
"daemon.outdated.title": {
"message": "NetBird Client ist veraltet"
"message": "NetBird-Dienst ist veraltet"
},
"daemon.outdated.description": {
"message": "Die neue GUI ist nicht mit Ihrem älteren Client kompatibel. Aktualisieren Sie Ihren Client, um die neue Anwendung zu verwenden."
},
"daemon.outdated.download": {
"message": "Neueste Version herunterladen"
"message": "Aktualisieren Sie den NetBird-Dienst, um diese App zu verwenden."
},
"error.jwt_clock_skew": {
"message": "Anmeldung fehlgeschlagen: Die Uhr dieses Geräts ist nicht mit dem Server synchron. Bitte synchronisieren Sie die Systemuhr und versuchen Sie es erneut."

View File

@@ -1377,19 +1377,11 @@
},
"welcome.title": {
"message": "Look for NetBird in your tray",
"description": "Heading on the first onboarding step, pointing the user to the tray icon. Shown on Windows and Linux; macOS uses welcome.titleMac."
},
"welcome.titleMac": {
"message": "Look for NetBird in your menu bar",
"description": "Heading on the first onboarding step on macOS, pointing the user to the menu bar icon. Use your language's Apple term for the macOS menu bar."
"description": "Heading on the first onboarding step, pointing the user to the tray icon. 'tray' = system tray / menu bar."
},
"welcome.description": {
"message": "NetBird lives in your tray. Click the icon to connect, switch profiles, or open settings.",
"description": "Body of the first onboarding step explaining the tray icon. Shown on Windows and Linux; macOS uses welcome.descriptionMac."
},
"welcome.descriptionMac": {
"message": "NetBird lives in your menu bar. Click the icon to connect, switch profiles, or open settings.",
"description": "Body of the first onboarding step on macOS explaining the menu bar icon. Use your language's Apple term for the macOS menu bar."
"description": "Body of the first onboarding step explaining the tray icon."
},
"welcome.continue": {
"message": "Continue",
@@ -1732,16 +1724,12 @@
"description": "Documentation link on the daemon-unavailable overlay."
},
"daemon.outdated.title": {
"message": "NetBird Client Is Outdated",
"description": "Title of the overlay shown when the NetBird client (daemon) is too old to drive this UI."
"message": "NetBird Service Is Outdated",
"description": "Title of the overlay shown when the NetBird background service is too old to drive this UI."
},
"daemon.outdated.description": {
"message": "The new GUI isn't compatible with the older NetBird client. Update your client to use the new application.",
"description": "Body of the daemon-outdated overlay explaining that the GUI is newer than the client and the client must be updated."
},
"daemon.outdated.download": {
"message": "Download Latest",
"description": "Button on the daemon-outdated overlay that opens the download page for the latest release."
"message": "Update the NetBird service to use this app.",
"description": "Body of the daemon-outdated overlay telling the user to upgrade the service."
},
"error.jwt_clock_skew": {
"message": "Sign-in failed: this device's clock is out of sync with the server. Please sync your system clock and try again.",

View File

@@ -1034,15 +1034,9 @@
"welcome.title": {
"message": "Busque NetBird en su bandeja del sistema"
},
"welcome.titleMac": {
"message": "Busque NetBird en su barra de menús"
},
"welcome.description": {
"message": "NetBird reside en su bandeja del sistema. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
},
"welcome.descriptionMac": {
"message": "NetBird reside en su barra de menús. Haga clic en el icono para conectarse, cambiar de perfil o abrir la configuración."
},
"welcome.continue": {
"message": "Continuar"
},
@@ -1299,13 +1293,10 @@
"message": "Documentación"
},
"daemon.outdated.title": {
"message": "NetBird Client está desactualizado"
"message": "El servicio de NetBird está desactualizado"
},
"daemon.outdated.description": {
"message": "La nueva GUI no es compatible con su cliente anterior. Actualice su cliente para usar la nueva aplicación."
},
"daemon.outdated.download": {
"message": "Descargar la última versión"
"message": "Actualice el servicio de NetBird para usar esta aplicación."
},
"error.jwt_clock_skew": {
"message": "Error al iniciar sesión: el reloj de este dispositivo no está sincronizado con el servidor. Sincronice el reloj del sistema e inténtelo de nuevo."

View File

@@ -1034,15 +1034,9 @@
"welcome.title": {
"message": "Cherchez NetBird dans votre barre détat système"
},
"welcome.titleMac": {
"message": "Cherchez NetBird dans votre barre des menus"
},
"welcome.description": {
"message": "NetBird se trouve dans votre barre détat système. Cliquez sur licône pour vous connecter, changer de profil ou ouvrir les paramètres."
},
"welcome.descriptionMac": {
"message": "NetBird se trouve dans votre barre des menus. Cliquez sur licône pour vous connecter, changer de profil ou ouvrir les paramètres."
},
"welcome.continue": {
"message": "Continuer"
},
@@ -1299,13 +1293,10 @@
"message": "Documentation"
},
"daemon.outdated.title": {
"message": "Le Client NetBird est obsolète"
"message": "Le service NetBird est obsolète"
},
"daemon.outdated.description": {
"message": "La nouvelle GUI n'est pas compatible avec votre ancien client. Mettez à jour votre client pour utiliser la nouvelle application."
},
"daemon.outdated.download": {
"message": "Télécharger la dernière version"
"message": "Mettez à jour le service NetBird pour utiliser cette application."
},
"error.jwt_clock_skew": {
"message": "Échec de la connexion : lhorloge de cet appareil nest pas synchronisée avec le serveur. Veuillez synchroniser lhorloge de votre système et réessayer."

View File

@@ -1034,15 +1034,9 @@
"welcome.title": {
"message": "Keresse a NetBirdöt a tálcán"
},
"welcome.titleMac": {
"message": "Keresse a NetBirdöt a menüsorban"
},
"welcome.description": {
"message": "A NetBird a tálcán fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
},
"welcome.descriptionMac": {
"message": "A NetBird a menüsorban fut. Kattintson az ikonra a csatlakozáshoz, profilváltáshoz vagy a beállítások megnyitásához."
},
"welcome.continue": {
"message": "Folytatás"
},
@@ -1299,13 +1293,10 @@
"message": "Dokumentáció"
},
"daemon.outdated.title": {
"message": "A NetBird Kliens elavult"
"message": "A NetBird szolgáltatás elavult"
},
"daemon.outdated.description": {
"message": "Az új GUI nem kompatibilis a régebbi klienseddel. Frissítsd a klienst az új alkalmazás használatához."
},
"daemon.outdated.download": {
"message": "Legújabb letöltése"
"message": "Frissítsd a NetBird szolgáltatást az alkalmazás használatához."
},
"error.jwt_clock_skew": {
"message": "A bejelentkezés sikertelen: az eszköz órája eltér a szerverétől. Kérjük, szinkronizálja a rendszer óráját, majd próbálja újra."

View File

@@ -1034,15 +1034,9 @@
"welcome.title": {
"message": "Cerchi NetBird nella tray"
},
"welcome.titleMac": {
"message": "Cerchi NetBird nella barra dei menu"
},
"welcome.description": {
"message": "NetBird risiede nella tray. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
},
"welcome.descriptionMac": {
"message": "NetBird risiede nella barra dei menu. Clicchi sull'icona per connettersi, cambiare profilo o aprire le impostazioni."
},
"welcome.continue": {
"message": "Continua"
},
@@ -1299,13 +1293,10 @@
"message": "Documentazione"
},
"daemon.outdated.title": {
"message": "NetBird Client è obsoleto"
"message": "Il servizio NetBird è obsoleto"
},
"daemon.outdated.description": {
"message": "La nuova GUI non è compatibile con il tuo client precedente. Aggiorna il client per usare la nuova applicazione."
},
"daemon.outdated.download": {
"message": "Scarica l'ultima versione"
"message": "Aggiorna il servizio NetBird per usare questa app."
},
"error.jwt_clock_skew": {
"message": "Accesso non riuscito: l'orologio di questo dispositivo non è sincronizzato con il server. Sincronizzi l'orologio di sistema e riprovi."

View File

@@ -1034,15 +1034,9 @@
"welcome.title": {
"message": "トレイの NetBird を確認してください"
},
"welcome.titleMac": {
"message": "メニューバーの NetBird を確認してください"
},
"welcome.description": {
"message": "NetBird はトレイに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
},
"welcome.descriptionMac": {
"message": "NetBird はメニューバーに常駐します。アイコンをクリックして、接続、プロファイルの切り替え、設定を開くことができます。"
},
"welcome.continue": {
"message": "続ける"
},

View File

@@ -1034,15 +1034,9 @@
"welcome.title": {
"message": "Procure o NetBird na sua bandeja"
},
"welcome.titleMac": {
"message": "Procure o NetBird na sua barra de menus"
},
"welcome.description": {
"message": "O NetBird fica na sua bandeja. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
},
"welcome.descriptionMac": {
"message": "O NetBird fica na sua barra de menus. Clique no ícone para conectar, alternar perfis ou abrir as configurações."
},
"welcome.continue": {
"message": "Continuar"
},
@@ -1299,13 +1293,10 @@
"message": "Documentação"
},
"daemon.outdated.title": {
"message": "O NetBird Client está desatualizado"
"message": "O serviço NetBird está desatualizado"
},
"daemon.outdated.description": {
"message": "A nova GUI não é compatível com o seu cliente mais antigo. Atualize o seu cliente para usar o novo aplicativo."
},
"daemon.outdated.download": {
"message": "Baixar a versão mais recente"
"message": "Atualize o serviço NetBird para usar este aplicativo."
},
"error.jwt_clock_skew": {
"message": "Falha no login: o relógio deste dispositivo está fora de sincronia com o servidor. Sincronize o relógio do sistema e tente novamente."

View File

@@ -1034,15 +1034,9 @@
"welcome.title": {
"message": "Найдите NetBird в системном трее"
},
"welcome.titleMac": {
"message": "Найдите NetBird в строке меню"
},
"welcome.description": {
"message": "NetBird находится в системном трее. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
},
"welcome.descriptionMac": {
"message": "NetBird находится в строке меню. Нажмите на значок, чтобы подключиться, переключить профиль или открыть настройки."
},
"welcome.continue": {
"message": "Продолжить"
},
@@ -1299,13 +1293,10 @@
"message": "Документация"
},
"daemon.outdated.title": {
"message": "Клиент NetBird устарел"
"message": "Служба NetBird устарела"
},
"daemon.outdated.description": {
"message": "Новый GUI несовместим с вашим более старым клиентом. Обновите клиент, чтобы использовать новое приложение."
},
"daemon.outdated.download": {
"message": "Скачать последнюю версию"
"message": "Обновите службу NetBird, чтобы использовать это приложение."
},
"error.jwt_clock_skew": {
"message": "Не удалось войти: часы этого устройства рассинхронизированы с сервером. Синхронизируйте системные часы и повторите попытку."

View File

@@ -1034,15 +1034,9 @@
"welcome.title": {
"message": "在托盘中查找 NetBird"
},
"welcome.titleMac": {
"message": "在菜单栏中查找 NetBird"
},
"welcome.description": {
"message": "NetBird 驻留在您的托盘中。点击图标即可连接、切换配置文件或打开设置。"
},
"welcome.descriptionMac": {
"message": "NetBird 驻留在您的菜单栏中。点击图标即可连接、切换配置文件或打开设置。"
},
"welcome.continue": {
"message": "继续"
},
@@ -1299,13 +1293,10 @@
"message": "文档"
},
"daemon.outdated.title": {
"message": "NetBird 客户端版本过旧"
"message": "NetBird 服务版本过旧"
},
"daemon.outdated.description": {
"message": "新版 GUI 与您较旧的客户端不兼容。请更新客户端以使用应用。"
},
"daemon.outdated.download": {
"message": "下载最新版本"
"message": "请更新 NetBird 服务以使用应用。"
},
"error.jwt_clock_skew": {
"message": "登录失败:此设备的时钟与服务器不同步。请同步您的系统时钟后重试。"

View File

@@ -281,9 +281,6 @@ func newApplication(onSecondInstance func()) *application.App {
Linux: application.LinuxOptions{
ProgramName: "netbird",
},
Windows: application.WindowsOptions{
WndProcInterceptor: endSessionInterceptor(),
},
SingleInstance: &application.SingleInstanceOptions{
UniqueID: "io.netbird.ui",
OnSecondInstanceLaunch: func(_ application.SecondInstanceData) {
@@ -370,9 +367,6 @@ func newMainWindow(app *application.App, prefStore *preferences.Store) *applicat
// Hide instead of quit on close; "really quit" is reached via tray -> Quit.
window.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
if services.ShuttingDown() {
return
}
e.Cancel()
window.Hide()
})

View File

@@ -10,7 +10,7 @@ import (
"github.com/wailsapp/wails/v3/pkg/application"
)
// Autostart facade over Wails' AutostartManager. The OS autostart entry registration
// Autostart facade over Wails' AutostartManager. The OS login-item registration
// is the single source of truth; nothing is mirrored to preferences.
type Autostart struct {
mgr *application.AutostartManager

View File

@@ -12,15 +12,13 @@ import (
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
// ProfileSwitcher holds the switch policy shared by the tray and React
// frontend so both flip profiles identically. SwitchActive (plain selection:
// header dropdown, tray submenu) always connects after the switch;
// SwitchActiveNoConnect (manage-profiles screen) never does, so the user can
// still adjust the management URL before connecting. prevStatus from
// DaemonFeed.Get at entry only decides the teardown:
// ProfileSwitcher holds the reconnect policy shared by the tray and React
// frontend so both flip profiles identically. The policy keys off prevStatus
// from DaemonFeed.Get at SwitchActive entry:
//
// Connected/Connecting/NeedsLogin/LoginFailed/SessionExpired → Down first.
// Idle → no Down.
// Connected/Connecting → Switch + Down + Up; optimistic Connecting paint.
// NeedsLogin/LoginFailed/SessionExpired → Switch + Down; clear stale error for re-login.
// Idle → Switch only.
type ProfileSwitcher struct {
profiles *Profiles
connection *Connection
@@ -31,40 +29,29 @@ func NewProfileSwitcher(profiles *Profiles, connection *Connection, feed *Daemon
return &ProfileSwitcher{profiles: profiles, connection: connection, feed: feed}
}
// SwitchActive switches to the named profile and always connects afterwards.
// SwitchActive switches to the named profile applying the reconnect policy.
func (s *ProfileSwitcher) SwitchActive(ctx context.Context, p ProfileRef) error {
return s.switchActive(ctx, p, true)
}
// SwitchActiveNoConnect switches to the named profile without connecting,
// tearing down any existing connection first.
func (s *ProfileSwitcher) SwitchActiveNoConnect(ctx context.Context, p ProfileRef) error {
return s.switchActive(ctx, p, false)
}
func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connect bool) error {
prevStatus := ""
if s.feed != nil {
if st, err := s.feed.Get(ctx); err == nil {
prevStatus = st.Status
} else {
log.Warnf("profileswitcher: get status: %v", err)
}
if st, err := s.feed.Get(ctx); err == nil {
prevStatus = st.Status
} else {
log.Warnf("profileswitcher: get status: %v", err)
}
needsDown := strings.EqualFold(prevStatus, StatusConnected) ||
strings.EqualFold(prevStatus, StatusConnecting) ||
wasActive := strings.EqualFold(prevStatus, StatusConnected) ||
strings.EqualFold(prevStatus, StatusConnecting)
needsDown := wasActive ||
strings.EqualFold(prevStatus, StatusNeedsLogin) ||
strings.EqualFold(prevStatus, StatusLoginFailed) ||
strings.EqualFold(prevStatus, StatusSessionExpired)
log.Infof("profileswitcher: switch profile=%q prevStatus=%q connect=%v needsDown=%v",
p.ProfileName, prevStatus, connect, needsDown)
log.Infof("profileswitcher: switch profile=%q prevStatus=%q wasActive=%v needsDown=%v",
p.ProfileName, prevStatus, wasActive, needsDown)
// Optimistic Connecting paint plus stale-push suppression during Down (see
// DaemonFeed suppression table); also arms the login-watch that pops
// browser-login when the new profile turns out to need SSO.
if connect && s.feed != nil {
// Optimistic Connecting paint only when wasActive: those prevStatuses emit
// stale Connected + transient Idle pushes during Down that must be
// suppressed until Up resumes the stream (see DaemonFeed suppression table).
if wasActive {
s.feed.BeginProfileSwitch()
}
@@ -89,9 +76,9 @@ func (s *ProfileSwitcher) switchActive(ctx context.Context, p ProfileRef, connec
}
}
if connect {
if wasActive {
if err := s.connection.Up(ctx, UpParams(p)); err != nil {
return fmt.Errorf("connect %q: %w", p.ProfileName, err)
return fmt.Errorf("reconnect %q: %w", p.ProfileName, err)
}
}

View File

@@ -1,24 +0,0 @@
package services
import "sync/atomic"
var (
sessionEnding atomic.Bool
quitting atomic.Bool
)
func BeginSessionEnd() {
sessionEnding.Store(true)
}
func AbortSessionEnd() {
sessionEnding.Store(false)
}
func BeginShutdown() {
quitting.Store(true)
}
func ShuttingDown() bool {
return sessionEnding.Load() || quitting.Load()
}

View File

@@ -10,7 +10,6 @@ import (
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/ui/updater"
"github.com/netbirdio/netbird/version"
)
// UpdateResult mirrors TriggerUpdateResponse.
@@ -34,12 +33,6 @@ func (s *Update) GetState() updater.State {
return s.holder.Get()
}
// DownloadURL returns the platform-appropriate installer download link for
// manual (non-enforced) updates.
func (s *Update) DownloadURL() string {
return version.DownloadUrl()
}
// Quit exits the app. Scheduled off the calling goroutine so the JS caller's
// response returns before the runtime tears down.
func (s *Update) Quit() {

View File

@@ -154,9 +154,6 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
})
// Hide (not destroy) on close to keep React state; reset to General for a flash-free reopen.
s.settings.RegisterHook(events.Common.WindowClosing, func(e *application.WindowEvent) {
if ShuttingDown() {
return
}
e.Cancel()
s.app.Event.Emit(EventSettingsOpen, "general")
s.settings.Hide()
@@ -188,38 +185,37 @@ func (s *WindowManager) OpenBrowserLogin(uri string) {
startURL = "/#/dialog/browser-login?uri=" + url.QueryEscape(uri)
}
s.hideOtherWindowsLocked("browser-login")
// Prefer the main window's screen (multi-monitor); falls back to OS-default centering.
var screen *application.Screen
if s.mainWindow != nil {
if sc, err := s.mainWindow.GetScreen(); err == nil {
screen = sc
}
}
opts := DialogWindowOptions("browser-login", s.title("window.title.signIn"), startURL, s.linuxIcon)
// Not always-on-top: it would obscure the browser tab the user logs in through.
opts.AlwaysOnTop = false
opts.InitialPosition = application.WindowCentered
// Open on the active (where users cursor is) display, like the session-expiration dialog.
opts.Screen = s.getScreenBasedOnCursorPosition()
opts.Screen = screen
s.browserLogin = s.app.Window.NewWithOptions(opts)
bl := s.browserLogin
// Red-X close means cancel: emit the event so startLogin() tears down the SSO wait.
bl.OnWindowEvent(events.Common.WindowClosing, func(_ *application.WindowEvent) {
s.app.Event.Emit(EventBrowserLoginCancel)
s.mu.Lock()
// Only a live user red-X still has this registered; programmatic closers
// nil s.browserLogin first and clean up themselves. Guarding here stops a
// stale close event from wiping a replacement popup's state.
userClosed := s.browserLogin == bl
if userClosed {
s.browserLogin = nil
s.restoreHiddenWindowsLocked()
}
s.browserLogin = nil
s.restoreHiddenWindowsLocked()
s.mu.Unlock()
if userClosed {
s.app.Event.Emit(EventBrowserLoginCancel)
}
})
s.centerOnCursorScreen(s.browserLogin)
s.centerWhenReady(s.browserLogin)
return
}
if uri != "" {
s.browserLogin.SetURL("/#/dialog/browser-login?uri=" + url.QueryEscape(uri))
}
s.centerOnCursorScreen(s.browserLogin)
s.browserLogin.Show()
s.browserLogin.Focus()
s.centerWhenReady(s.browserLogin)
}
// BrowserLoginWindow returns the live SSO popup, or nil. While non-nil it is the
@@ -242,15 +238,6 @@ func (s *WindowManager) CloseBrowserLogin() {
s.mu.Lock()
w := s.browserLogin
s.browserLogin = nil
// The WindowClosing hook no-ops on a programmatic close, so restore here —
// but only if a popup was actually open. The frontend calls this even when no
// popup was ever shown (e.g. resetDialog() after an early RequestExtend failure,
// or connection.ts's catch path), and hiddenForLogin is shared with
// OpenInstallProgress, so an unconditional restore could re-show windows a
// still-running install-progress is hiding.
if w != nil {
s.restoreHiddenWindowsLocked()
}
s.mu.Unlock()
if w != nil {
w.Close()
@@ -292,35 +279,6 @@ func (s *WindowManager) CloseSessionExpiration() {
}
}
// CloseRenewFlow tears down the SSO session-renewal UI in a single call: it
// closes the browser-login popup and the session-expiration window together.
func (s *WindowManager) CloseRenewFlow() {
s.mu.Lock()
bl := s.browserLogin
se := s.sessionExpiration
s.browserLogin = nil
s.sessionExpiration = nil
if se != nil {
kept := s.hiddenForLogin[:0]
for _, w := range s.hiddenForLogin {
if w != se {
kept = append(kept, w)
}
}
s.hiddenForLogin = kept
}
s.restoreHiddenWindowsLocked()
s.mu.Unlock()
// Close after unlock so the re-entrant handlers can take s.mu.
if bl != nil {
bl.Close()
}
if se != nil {
se.Close()
}
}
// OpenInstallProgress shows the install-progress window and hides the rest for the duration
// (restored on close). It owns its own result polling since the daemon restarts mid-install.
func (s *WindowManager) OpenInstallProgress(version string) {
@@ -396,9 +354,6 @@ func (s *WindowManager) CloseWelcome() {
// OpenError shows the custom error dialog; title/message are pre-localised and ride in the
// start URL. A second error replaces the open one via SetURL. Singleton, destroyed on close.
func (s *WindowManager) OpenError(title, message string) {
if ShuttingDown() {
return
}
s.mu.Lock()
defer s.mu.Unlock()
startURL := errorDialogURL(title, message)

View File

@@ -1,14 +0,0 @@
package services
import "github.com/wailsapp/wails/v3/pkg/w32"
// Wails assigns w32.AllowDarkModeForWindow only on builds >= 18334 but calls it
// without a nil check when a window requests the Dark theme, crashing older
// builds such as Windows Server 2019 (17763). Those builds still get a dark
// title bar via the pre-20H1 DWM attribute that w32.SetTheme applies, so a
// no-op stub keeps the Dark theme fully working there.
func init() {
if w32.AllowDarkModeForWindow == nil {
w32.AllowDarkModeForWindow = func(w32.HWND, bool) uintptr { return 0 }
}
}

View File

@@ -1,7 +0,0 @@
//go:build !windows && !android && !ios && !freebsd && !js
package main
func endSessionInterceptor() func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) {
return nil
}

View File

@@ -1,36 +0,0 @@
//go:build windows
package main
import (
"os"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/ui/services"
)
const (
wmQueryEndSession = 0x0011
wmEndSession = 0x0016
)
func endSessionInterceptor() func(hwnd uintptr, msg uint32, wParam, lParam uintptr) (uintptr, bool) {
return func(_ uintptr, msg uint32, wParam, _ uintptr) (uintptr, bool) {
switch msg {
case wmQueryEndSession:
services.BeginSessionEnd()
return 1, true
case wmEndSession:
if wParam == 0 {
services.AbortSessionEnd()
return 0, true
}
log.Info("windows session is ending; exiting immediately")
os.Exit(0)
return 0, true
default:
return 0, false
}
}
}

View File

@@ -30,10 +30,9 @@ const (
statusError = "Error"
quitDownTimeout = 5 * time.Second
urlGitHubRepo = "https://github.com/netbirdio/netbird"
urlDocs = "https://docs.netbird.io"
urlGitHubRepo = "https://github.com/netbirdio/netbird"
urlGitHubReleases = "https://github.com/netbirdio/netbird/releases/latest"
urlDocs = "https://docs.netbird.io"
)
// TrayServices bundles the services the tray menu needs, grouped so NewTray
@@ -316,7 +315,8 @@ func (t *Tray) relayoutMenu() {
if sessionDeadline.IsZero() {
t.sessionExpiresItem.SetHidden(true)
} else {
t.sessionExpiresItem.SetLabel(t.sessionRowLabel(sessionDeadline))
remaining := t.formatSessionRemaining(time.Until(sessionDeadline))
t.sessionExpiresItem.SetLabel(t.loc.T("tray.session.expiresIn", "remaining", remaining))
t.sessionExpiresItem.SetHidden(false)
}
}
@@ -446,29 +446,11 @@ func (t *Tray) buildMenu() *application.Menu {
menu.AddSeparator()
menu.Add(t.loc.T("tray.menu.quit")).
SetAccelerator("CmdOrCtrl+Q").
OnClick(func(*application.Context) { t.handleQuit() })
OnClick(func(*application.Context) { t.app.Quit() })
return menu
}
func (t *Tray) handleQuit() {
services.BeginShutdown()
t.profileMu.Lock()
if t.switchCancel != nil {
t.switchCancel()
t.switchCancel = nil
}
t.profileMu.Unlock()
t.svc.DaemonFeed.CancelProfileSwitch()
ctx, cancel := context.WithTimeout(context.Background(), quitDownTimeout)
defer cancel()
if err := t.svc.Connection.Down(ctx); err != nil {
log.Errorf("disconnect on quit: %v", err)
}
t.app.Quit()
}
// handleConnect receives the clicked item from the buildMenu closure —
// t.upItem is menuMu-guarded and must not be read here.
func (t *Tray) handleConnect(upItem *application.MenuItem) {

View File

@@ -25,9 +25,6 @@ type sendFn func(notifications.NotificationOptions) error
// event-dispatch goroutine that panic is fatal process-wide; recover() turns
// it into a logged no-op.
func safeSendNotification(send sendFn, what string, opts notifications.NotificationOptions) (err error) {
if services.ShuttingDown() {
return nil
}
defer func() {
if r := recover(); r != nil {
log.Errorf("notify %s: recovered from panic (notification bus unavailable): %v", what, r)

Some files were not shown because too many files have changed in this diff Show More