Compare commits

..

10 Commits

Author SHA1 Message Date
Theodor S. Midtlien
8a46353ae2 Add tests 2026-07-21 15:40:39 +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
356 changed files with 6302 additions and 25049 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

@@ -234,22 +234,12 @@ cd client/ui
task dev
```
Pass daemon flags after `--`, pointing the UI at the socket the daemon serves:
Pass daemon flags after `--`:
```
task dev -- --daemon-addr=unix:///var/run/netbird.sock # Linux, macOS
task dev -- --daemon-addr=npipe://netbird # Windows
task dev -- --daemon-addr=tcp://127.0.0.1:41731
```
On Windows the daemon serves a named pipe (`npipe://netbird`). Which path that
ends up being depends on what the daemon may create: as a service or elevated it
serves `\\.\pipe\ProtectedPrefix\Administrators\netbird`, which no unprivileged
process can take from it, and otherwise it falls back to `\\.\pipe\netbird`.
Clients try both and check who owns the pipe before using the plain one. Avoid
`tcp://127.0.0.1:41731`: loopback TCP carries no caller identity, so the daemon
refuses the operations that require an administrator and you will not exercise
those paths.
Production build (frontend assets embedded into the binary, output in `client/ui/bin/`):
```

View File

@@ -7,7 +7,6 @@ import (
"fmt"
"os"
"slices"
"strings"
"sync"
"time"
@@ -76,24 +75,6 @@ type Client struct {
connectClient *internal.ConnectClient
config *profilemanager.Config
cacheDir string
stateChangeMu sync.Mutex
stateChangeSubID string
eventSub *peer.EventSubscription
// Closed to stop the watch goroutines from delivering buffered items to a
// listener that has been removed or replaced. See stopStateChangeWatchLocked.
stateChangeDone chan struct{}
// Latched "the server wants an interactive login": survives the engine
// restarts that replace the run loop's context state. See Client.Status.
// Guarded by loginRequiredMu together with loginCleared, which counts
// clears so a stale observation cannot re-latch over one.
loginRequiredMu sync.Mutex
loginRequired bool
loginCleared uint64
extendMu sync.Mutex
extendCancel context.CancelFunc
}
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
@@ -167,16 +148,11 @@ func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroid
if err != nil {
return err
}
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
c.setState(cfg, cacheDir, connectClient)
// This path runs the interactive SSO flow, so reaching here means the peer
// is authenticated again — release the latch Status() reports from. Clear
// only once the fresh connect client is installed: until then Status()
// still reads the previous run's context state, which holds the NeedsLogin
// that prompted this login, and would re-latch what was just cleared.
c.clearLoginRequired()
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
}
@@ -301,7 +277,7 @@ func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (strin
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false)
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path)
if err != nil {
return "", fmt.Errorf("upload debug bundle: %w", err)
}
@@ -323,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))
@@ -340,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
}
@@ -484,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 {
@@ -503,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
@@ -553,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

@@ -1,309 +0,0 @@
//go:build android
package android
import (
"context"
"fmt"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/auth/sessionwatch"
"github.com/netbirdio/netbird/client/internal/peer"
cProto "github.com/netbirdio/netbird/client/proto"
)
// StateChangeListener receives client state notifications.
//
// OnStateChanged is a payload-free wake-up whenever the state snapshot
// changed: connection state, the run-loop status label (e.g. NeedsLogin) or
// the session deadline. It mirrors the daemon's SubscribeStatus stream
// trigger — on each signal the consumer pulls the fresh values via
// Status() / SessionExpiresAtUnix().
//
// OnSessionExpiring forwards the engine's session-expiry warnings, fired at
// sessionwatch.WarningLead before the deadline and again at FinalWarningLead
// (finalWarning true). The second one is suppressed when the user dismissed
// the first via DismissSessionWarning. The daemon turns the same events into
// its tray notification.
type StateChangeListener interface {
OnStateChanged()
OnSessionExpiring(expiresAtUnix int64, leadMinutes int64, finalWarning bool)
}
// Status returns the connect run-loop's status label — the same value the
// desktop daemon serves in StatusResponse.Status. "NeedsLogin" means the
// management server rejected the peer and an interactive login is required.
//
// The label is latched: the run loop keeps its status in a per-run context
// state, which a restart replaces with a fresh Idle one, so an engine restart
// (network change, always-on) would otherwise erase the fact that the peer
// still needs to log in. Only a successful interactive login or extend clears
// it — see clearLoginRequired.
func (c *Client) Status() string {
latched, generation := c.loginRequiredState()
if latched {
return string(internal.StatusNeedsLogin)
}
cc := c.getConnectClient()
if cc == nil {
return string(internal.StatusIdle)
}
status := cc.Status()
if status == internal.StatusNeedsLogin {
c.latchLoginRequired(generation)
}
return string(status)
}
func (c *Client) loginRequiredState() (bool, uint64) {
c.loginRequiredMu.Lock()
defer c.loginRequiredMu.Unlock()
return c.loginRequired, c.loginCleared
}
// latchLoginRequired records a NeedsLogin observation, unless a clear landed
// while the caller was reading the run loop's status: cc.Status() is read
// outside the lock, so a login or extend completing in that window would
// otherwise be undone by this stale observation, stranding the UI on
// "login required" over a healthy session.
func (c *Client) latchLoginRequired(observedGeneration uint64) {
c.loginRequiredMu.Lock()
defer c.loginRequiredMu.Unlock()
if c.loginCleared != observedGeneration {
return
}
c.loginRequired = true
}
// clearLoginRequired releases the latch after a successful interactive login
// or session extend, and invalidates any observation already in flight.
func (c *Client) clearLoginRequired() {
c.loginRequiredMu.Lock()
defer c.loginRequiredMu.Unlock()
c.loginRequired = false
c.loginCleared++
}
// SessionExpiresAtUnix returns the SSO session deadline as unix seconds, or 0
// when no deadline is known (not SSO-registered, expiry disabled, or the
// engine has not received one yet). A past value means the session expired.
// Mirror of StatusResponse.sessionExpiresAt on the desktop daemon.
func (c *Client) SessionExpiresAtUnix() int64 {
deadline := c.recorder.GetSessionExpiresAt()
if deadline.IsZero() {
return 0
}
return deadline.Unix()
}
// SetStateChangeListener registers the state notification listener.
// Replaces any previously registered listener; remove it with
// RemoveStateChangeListener.
func (c *Client) SetStateChangeListener(listener StateChangeListener) {
c.stateChangeMu.Lock()
defer c.stateChangeMu.Unlock()
c.stopStateChangeWatchLocked()
if listener == nil {
return
}
// Both subscriptions are buffered (one pending tick, ten pending events),
// so unsubscribing is not enough to stop callbacks: the loops would drain
// what is already queued and deliver it to a listener the caller has
// already removed or replaced. Gate every callback on this registration's
// own signal, which is closed before unsubscribing.
done := make(chan struct{})
c.stateChangeDone = done
id, ch := c.recorder.SubscribeToStateChanges()
c.stateChangeSubID = id
// The channel is closed by UnsubscribeFromStateChanges, which ends the
// goroutine. Ticks are coalesced (buffer of one), so a burst of changes
// wakes the listener once.
go func() {
for range ch {
select {
case <-done:
return
default:
}
listener.OnStateChanged()
}
}()
c.eventSub = c.recorder.SubscribeToEvents()
go watchSessionWarnings(c.eventSub, listener, done)
}
// RemoveStateChangeListener unregisters the state notification listener.
func (c *Client) RemoveStateChangeListener() {
c.stateChangeMu.Lock()
defer c.stateChangeMu.Unlock()
c.stopStateChangeWatchLocked()
}
// DismissSessionWarning records the user's "Dismiss" on the first expiry
// warning and suppresses the final one for the current deadline. A refreshed
// deadline re-arms both. No-op while the engine is not running.
func (c *Client) DismissSessionWarning() {
cc := c.getConnectClient()
if cc == nil {
return
}
engine := cc.Engine()
if engine == nil {
return
}
engine.DismissSessionWarning()
}
// ExtendAuthSession runs the interactive SSO flow to obtain a fresh JWT and
// asks the management server to extend the session deadline. The tunnel is
// untouched: no resync, no reconnect. Async; the result arrives on the
// listener. Mirror of the daemon's RequestExtendAuthSession /
// WaitExtendAuthSession RPC pair, with URLOpener playing the "UI opens the
// browser" role.
//
// Only one flow may be in flight: the PKCE step binds a fixed loopback port,
// so a second concurrent flow would fail on that bind. Call
// CancelExtendAuthSession when the user abandons the browser.
func (c *Client) ExtendAuthSession(urlOpener URLOpener, isAndroidTV bool, resultListener ErrListener) {
ctx, err := c.beginExtend()
if err != nil {
resultListener.OnError(err)
return
}
go func() {
defer c.endExtend()
if err := c.extendAuthSession(ctx, urlOpener, isAndroidTV); err != nil {
resultListener.OnError(err)
return
}
resultListener.OnSuccess()
}()
}
// CancelExtendAuthSession aborts an in-flight ExtendAuthSession. The tunnel is
// left alone — unlike the login flow, which cancels the whole client context
// by stopping the engine. Without this the abandoned PKCE wait keeps its
// loopback port for the full flow timeout and blocks every later attempt.
// No-op when no flow is running.
func (c *Client) CancelExtendAuthSession() {
c.extendMu.Lock()
defer c.extendMu.Unlock()
if c.extendCancel != nil {
c.extendCancel()
}
}
func (c *Client) stopStateChangeWatchLocked() {
// Signal first, unsubscribe second: closing the channels only stops new
// items, and the loops would still hand whatever is buffered to a listener
// that is no longer registered.
if c.stateChangeDone != nil {
close(c.stateChangeDone)
c.stateChangeDone = nil
}
if c.stateChangeSubID != "" {
c.recorder.UnsubscribeFromStateChanges(c.stateChangeSubID)
c.stateChangeSubID = ""
}
if c.eventSub != nil {
// Closes the channel, which ends watchSessionWarnings.
c.recorder.UnsubscribeFromEvents(c.eventSub)
c.eventSub = nil
}
}
// watchSessionWarnings forwards the engine's session-expiry warnings to the
// listener. The event stream also carries unrelated traffic — network-map
// updates on every sync, DNS and route errors — so everything but an
// AUTHENTICATION event carrying the session-warning marker is dropped. Exits
// when the subscription is closed by UnsubscribeFromEvents, or earlier when
// done is closed — the stream buffers up to ten events, and a deregistered
// listener must not receive the ones already queued.
func watchSessionWarnings(sub *peer.EventSubscription, listener StateChangeListener, done <-chan struct{}) {
for ev := range sub.Events() {
select {
case <-done:
return
default:
}
if ev.GetCategory() != cProto.SystemEvent_AUTHENTICATION {
continue
}
meta := ev.GetMetadata()
if meta[sessionwatch.MetaSessionWarning] != "true" {
// Other AUTHENTICATION events exist (e.g. a deadline rejected as
// out of range); they carry no warning marker.
continue
}
deadline, err := sessionwatch.ParseExpiresAt(meta[sessionwatch.MetaSessionExpiresAt])
if err != nil {
log.Warnf("session warning event with unparsable deadline: %v", err)
continue
}
lead, err := sessionwatch.ParseLeadMinutes(meta[sessionwatch.MetaSessionLeadMinutes])
if err != nil {
// Informational only — the deadline above is what drives the UI.
lead = 0
}
listener.OnSessionExpiring(deadline.Unix(), int64(lead),
meta[sessionwatch.MetaSessionFinal] == "true")
}
}
func (c *Client) beginExtend() (context.Context, error) {
c.extendMu.Lock()
defer c.extendMu.Unlock()
if c.extendCancel != nil {
return nil, fmt.Errorf("session extend already in progress")
}
ctx, cancel := context.WithCancel(context.Background())
c.extendCancel = cancel
return ctx, nil
}
func (c *Client) endExtend() {
c.extendMu.Lock()
defer c.extendMu.Unlock()
if c.extendCancel != nil {
c.extendCancel()
c.extendCancel = nil
}
}
func (c *Client) extendAuthSession(ctx context.Context, urlOpener URLOpener, isAndroidTV bool) error {
cfg, _, cc := c.stateSnapshot()
if cfg == nil || cc == nil {
return fmt.Errorf("engine is not running")
}
engine := cc.Engine()
if engine == nil {
return fmt.Errorf("engine is not initialized")
}
authClient, err := auth.NewAuth(ctx, cfg.PrivateKey, cfg.ManagementURL, cfg)
if err != nil {
return fmt.Errorf("failed to create auth client: %v", err)
}
defer authClient.Close()
a := &Auth{ctx: ctx, config: cfg}
tokenInfo, err := a.foregroundGetTokenInfo(authClient, urlOpener, isAndroidTV)
if err != nil {
return fmt.Errorf("interactive sso login failed: %v", err)
}
if _, err := engine.ExtendAuthSession(ctx, tokenInfo.GetTokenToUse()); err != nil {
return err
}
c.clearLoginRequired()
go urlOpener.OnLoginSuccess()
return nil
}

View File

@@ -1,66 +0,0 @@
package cmd
import (
"errors"
"fmt"
"strings"
"google.golang.org/genproto/googleapis/rpc/errdetails"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// daemonCallError prepares a daemon error for display. A refusal the daemon
// raised because the operation needs root/administrator is already guidance
// written for the user, so it is surfaced on its own instead of buried under the
// gRPC envelope and the name of the RPC that hit it. Anything else is wrapped
// with context as usual.
func daemonCallError(context string, err error) error {
if guidance, ok := privilegeGuidance(err); ok {
return errors.New(guidance)
}
return fmt.Errorf("%s: %w", context, err)
}
// privilegeGuidance renders the daemon's privilege refusal as a summary and the
// command that performs the operation with the privileges it needs. It reports
// false for any other error.
func privilegeGuidance(err error) (string, bool) {
info, ok := privilegeErrorInfo(err)
if !ok {
return "", false
}
summary := info.GetMetadata()[ipcauth.ErrorMetaSummary]
command := info.GetMetadata()[ipcauth.ErrorMetaCommand]
if summary == "" {
// Detail without a summary: fall back to the status message, which
// carries the same text.
summary = strings.TrimSpace(gstatus.Convert(err).Message())
}
if command == "" {
return summary, true
}
return fmt.Sprintf("%s\n\n %s\n", summary, command), true
}
// privilegeErrorInfo returns the daemon's privilege-refusal detail, if the error
// carries one.
func privilegeErrorInfo(err error) (*errdetails.ErrorInfo, bool) {
if err == nil {
return nil, false
}
for _, detail := range gstatus.Convert(err).Details() {
info, ok := detail.(*errdetails.ErrorInfo)
if !ok {
continue
}
if info.GetReason() == ipcauth.ErrorReasonPrivilegeRequired && info.GetDomain() == ipcauth.ErrorDomain {
return info, true
}
}
return nil, false
}

View File

@@ -29,9 +29,8 @@ const errCloseConnection = "Failed to close connection: %v"
var (
logFileCount uint32
systemInfoFlag bool
uploadBundleFlag bool
uploadBundleURLFlag string
uploadBundleInsecureFlag bool
uploadBundleFlag bool
uploadBundleURLFlag string
)
var debugCmd = &cobra.Command{
@@ -175,11 +174,10 @@ func debugBundle(cmd *cobra.Command, _ []string) error {
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
request.UploadInsecure = uploadBundleInsecureFlag
}
resp, err := client.DebugBundle(cmd.Context(), request)
if err != nil {
return daemonCallError("bundle debug", err)
return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message())
}
cmd.Printf("Local file:\n%s\n", resp.GetPath())
@@ -375,11 +373,10 @@ func runForDuration(cmd *cobra.Command, args []string) error {
}
if uploadBundleFlag {
request.UploadURL = uploadBundleURLFlag
request.UploadInsecure = uploadBundleInsecureFlag
}
resp, err := client.DebugBundle(cmd.Context(), request)
if err != nil {
return daemonCallError("bundle debug", err)
return fmt.Errorf("failed to bundle debug: %v", status.Convert(err).Message())
}
if needsRestoreUp {
@@ -527,12 +524,10 @@ func init() {
debugBundleCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
debugBundleCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
debugBundleCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
debugBundleCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Uint32VarP(&logFileCount, "log-file-count", "C", 1, "Number of rotated log files to include in debug bundle")
forCmd.Flags().BoolVarP(&systemInfoFlag, "system-info", "S", true, "Adds system information to the debug bundle")
forCmd.Flags().BoolVarP(&uploadBundleFlag, "upload-bundle", "U", false, "Uploads the debug bundle to a server")
forCmd.Flags().StringVar(&uploadBundleURLFlag, "upload-bundle-url", types.DefaultBundleURL, "Service URL to get an URL to upload the debug bundle")
forCmd.Flags().BoolVar(&uploadBundleInsecureFlag, "upload-bundle-insecure", false, "Allow uploading to an http or untrusted-TLS upload server (self-hosted); requires root")
forCmd.Flags().Bool("capture", false, "Capture packets during the debug duration and include in bundle")
}

View File

@@ -46,7 +46,7 @@ var logoutCmd = &cobra.Command{
}
if _, err := daemonClient.Logout(ctx, req); err != nil {
return daemonCallError("deregister", err)
return fmt.Errorf("deregister: %v", err)
}
cmd.Println("Deregistered successfully")

View File

@@ -20,6 +20,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
@@ -90,7 +91,6 @@ var (
// Don't resolve for service commands — they create the socket, not connect to it.
if !isServiceCmd(cmd) {
daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr)
daemonAddr = daddr.ResolveDaemonAddr(daemonAddr)
}
return nil
},
@@ -143,10 +143,10 @@ func init() {
defaultDaemonAddr := "unix:///var/run/netbird.sock"
if runtime.GOOS == "windows" {
defaultDaemonAddr = daddr.WindowsPipeAddr
defaultDaemonAddr = "tcp://127.0.0.1:41731"
}
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
rootCmd.PersistentFlags().StringVarP(&managementURL, "management-url", "m", "", fmt.Sprintf("Management Service URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultManagementURL))
rootCmd.PersistentFlags().StringVar(&adminURL, "admin-url", "", fmt.Sprintf("Admin Panel URL [http|https]://[host]:[port] (default \"%s\")", profilemanager.DefaultAdminURL))
rootCmd.PersistentFlags().StringVarP(&logLevel, "log-level", "l", "info", "sets NetBird log level")
@@ -269,10 +269,12 @@ func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, e
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
target, opts := daddr.DialTarget(addr)
opts = append(opts, grpc.WithBlock())
return grpc.DialContext(ctx, target, opts...)
return grpc.DialContext(
ctx,
strings.TrimPrefix(addr, "tcp://"),
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
)
}
// WithBackOff execute function in backoff cycle.

View File

@@ -33,15 +33,10 @@ var (
)
type program struct {
ctx context.Context
cancel context.CancelFunc
serv *grpc.Server
jsonServ *http.Server
// jsonClient is the gateway's own connection to the daemon. It is held so
// shutting the gateway down also closes it: nothing else references it once
// the handlers are registered, so its transport goroutines would otherwise
// outlive the server.
jsonClient *grpc.ClientConn
ctx context.Context
cancel context.CancelFunc
serv *grpc.Server
jsonServ *http.Server
jsonServMu sync.Mutex
serverInstance *server.Server
serverInstanceMu sync.Mutex

View File

@@ -5,7 +5,6 @@ package cmd
import (
"context"
"fmt"
"runtime"
"time"
"github.com/kardianos/service"
@@ -14,8 +13,6 @@ import (
"github.com/spf13/cobra"
"google.golang.org/grpc"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
@@ -29,31 +26,6 @@ func validateJSONSocketFlags() error {
return nil
}
// daemonServerOptions installs the transport credentials that expose each
// caller's kernel-authenticated identity to the handlers, which is what lets
// the daemon require root/administrator for privileged operations.
//
// The handshake exchanges no bytes, so older CLI and UI binaries still
// interoperate. Callers on a TCP socket carry no identity at all: the daemon
// keeps serving them, and the privileged operations deny them, so a warning is
// logged to make the loss of functionality visible.
func daemonServerOptions(network string) []grpc.ServerOption {
if network == "tcp" {
log.Warnf("daemon is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
"so privileged operations (SSH root login, SSH auth, enabling the SSH server, management URL changes, "+
"deregistration) will be denied. Use a unix socket, or npipe:// on Windows", daemonAddr)
return nil
}
creds := ipcauth.NewTransportCredentials()
if creds == nil {
log.Warnf("daemon IPC has no peer-identity primitive on %s: privileged operations will be denied", runtime.GOOS)
return nil
}
return []grpc.ServerOption{grpc.Creds(creds)}
}
func (p *program) Start(svc service.Service) error {
// Start should not block. Do the actual work async.
log.Info("starting NetBird service") //nolint
@@ -65,106 +37,68 @@ func (p *program) Start(svc service.Service) error {
// Collect static system and platform information
system.UpdateStaticInfoAsync()
// A daemon installed before named-pipe support has the loopback TCP address
// persisted. Move it to the named pipe so an upgraded daemon can identify
// its callers instead of silently serving an unauthenticated socket.
if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
log.Infof("daemon address %q predates named-pipe support, listening on %q so callers can be identified", daemonAddr, migrated)
daemonAddr = migrated
}
network, _, err := parseListenAddress(daemonAddr)
if err != nil {
return fmt.Errorf("parse daemon address: %w", err)
}
// in any case, even if configuration does not exists we run daemon to serve CLI gRPC API.
p.serv = grpc.NewServer(daemonServerOptions(network)...)
p.serv = grpc.NewServer()
daemonListener, jsonListener, err := listenDaemonSockets()
daemonListener, err := listenOnAddress(daemonAddr)
if err != nil {
return err
return fmt.Errorf("listen daemon interface: %w", err)
}
var jsonListener *socketListener
if enableJSONSocket {
jsonListener, err = listenOnAddress(jsonSocket)
if err != nil {
_ = daemonListener.Close()
return fmt.Errorf("listen daemon JSON interface: %w", err)
}
} else {
removeStaleUnixSocketForAddress(jsonSocket)
}
go func() {
// Fatal here rather than inside serve, so serve's deferred listener
// closes run before the process exits.
if err := p.serve(daemonListener, jsonListener); err != nil {
log.Fatalf("failed to %v", err)
defer daemonListener.Close()
if jsonListener != nil {
defer jsonListener.Close()
}
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
log.Error(err)
return
}
if jsonListener != nil {
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
log.Error(err)
return
}
}
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
if err := serverInstance.Start(); err != nil {
log.Fatalf("failed to start daemon: %v", err)
}
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
p.serverInstanceMu.Lock()
p.serverInstance = serverInstance
p.serverInstanceMu.Unlock()
if jsonListener != nil {
if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
log.Fatalf("failed to start daemon JSON server: %v", err)
}
} else {
log.Debug("daemon JSON socket disabled")
}
log.Printf("started daemon server: %v", daemonListener.address)
if err := p.serv.Serve(daemonListener.Listener); err != nil {
log.Errorf("failed to serve daemon requests: %v", err)
}
}()
return nil
}
// listenDaemonSockets opens the daemon control socket and, when it is enabled, the
// JSON gateway socket. The control socket is closed again if the second one fails,
// so a failed start leaves nothing listening. The returned JSON listener is nil
// when the socket is disabled.
func listenDaemonSockets() (*socketListener, *socketListener, error) {
daemonListener, err := listenOnAddress(daemonAddr)
if err != nil {
return nil, nil, fmt.Errorf("listen daemon interface: %w", err)
}
if !enableJSONSocket {
removeStaleUnixSocketForAddress(jsonSocket)
return daemonListener, nil, nil
}
jsonListener, err := listenOnAddress(jsonSocket)
if err != nil {
if cerr := daemonListener.Close(); cerr != nil {
log.Debugf("close daemon listener: %v", cerr)
}
return nil, nil, fmt.Errorf("listen daemon JSON interface: %w", err)
}
return daemonListener, jsonListener, nil
}
// serve brings up the daemon server on an already-open control socket and blocks
// until it stops. jsonListener is nil when the JSON socket is disabled. A returned
// error means the daemon cannot run at all and the caller is expected to exit; the
// failures it recovers from on its own are logged here.
func (p *program) serve(daemonListener, jsonListener *socketListener) error {
defer daemonListener.Close()
if jsonListener != nil {
defer jsonListener.Close()
}
// chmodUnixSocket is a no-op for a nil listener and for a non-unix one.
if err := daemonListener.chmodUnixSocket("daemon"); err != nil {
log.Error(err)
return nil
}
if err := jsonListener.chmodUnixSocket("daemon JSON"); err != nil {
log.Error(err)
return nil
}
serverInstance := server.New(p.ctx, util.FindFirstLogPath(logFiles), configPath, profilesDisabled, updateSettingsDisabled, captureEnabled, networksDisabled)
if err := serverInstance.Start(); err != nil {
return fmt.Errorf("start daemon: %w", err)
}
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
p.serverInstanceMu.Lock()
p.serverInstance = serverInstance
p.serverInstanceMu.Unlock()
if jsonListener == nil {
log.Debug("daemon JSON socket disabled")
} else if err := p.startJSONGateway(jsonListener, daemonAddr); err != nil {
return fmt.Errorf("start daemon JSON server: %w", err)
}
log.Printf("started daemon server: %v", daemonListener.address)
if err := p.serv.Serve(daemonListener.Listener); err != nil {
log.Errorf("failed to serve daemon requests: %v", err)
}
return nil
}
func (p *program) Stop(srv service.Service) error {
p.serverInstanceMu.Lock()
if p.serverInstance != nil {
@@ -179,13 +113,8 @@ func (p *program) Stop(srv service.Service) error {
p.cancel()
p.jsonServMu.Lock()
jsonServ, jsonClient := p.jsonServ, p.jsonClient
jsonServ := p.jsonServ
p.jsonServMu.Unlock()
if jsonClient != nil {
if err := jsonClient.Close(); err != nil {
log.Debugf("close daemon JSON gateway client: %v", err)
}
}
if jsonServ != nil {
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 2*time.Second)
if err := jsonServ.Shutdown(shutdownCtx); err != nil {

View File

@@ -5,123 +5,27 @@ package cmd
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"sync"
"strings"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
)
// jsonPeerIdentity is the context key under which the connecting HTTP client's
// identity is stashed for the lifetime of its connection.
type jsonPeerIdentity struct{}
// jsonPeerIdentityValue pairs the identity with whether it could be read at
// all, so an unreadable identity is forwarded as "unknown" rather than omitted.
type jsonPeerIdentityValue struct {
id ipcauth.Identity
known bool
}
// jsonConnContext reads the identity of the client connecting to the JSON
// socket and stashes it on the connection's context. The gateway re-dials the
// daemon in-process, so the daemon would otherwise see every JSON request as
// coming from the daemon itself.
func jsonConnContext(ctx context.Context, c net.Conn) context.Context {
value := jsonPeerIdentityValue{}
id, err := ipcauth.ConnIdentity(c)
if err != nil {
log.Warnf("json gateway: cannot read HTTP client identity, privileged operations will be denied for this connection: %v", err)
} else {
value.id = id
value.known = true
}
return context.WithValue(ctx, jsonPeerIdentity{}, value)
}
// forwardIdentity stamps the HTTP client's identity onto every call the gateway
// makes to the daemon.
//
// It is an interceptor on the gateway's client connection rather than a
// runtime.WithMetadata annotator because grpc-gateway skips annotators when no
// request header maps to metadata, which an HTTP/1.0 request with no Host header
// over a unix socket achieves. The daemon would then receive no marker, see its own
// identity as the transport peer, and authorize the request as the daemon itself.
// An interceptor runs for every RPC whatever the request looked like.
func forwardIdentity(ctx context.Context) context.Context {
value, ok := ctx.Value(jsonPeerIdentity{}).(jsonPeerIdentityValue)
if !ok {
// No ConnContext ran for this request, so forward an unknown identity:
// the daemon must not mistake its own identity for the client's.
return ipcauth.WithForwardedIdentity(ctx, ipcauth.Identity{}, false)
}
return ipcauth.WithForwardedIdentity(ctx, value.id, value.known)
}
func forwardIdentityUnary(ctx context.Context, method string, req, reply any, cc *grpc.ClientConn, invoker grpc.UnaryInvoker, opts ...grpc.CallOption) error {
return invoker(forwardIdentity(ctx), method, req, reply, cc, opts...)
}
func forwardIdentityStream(ctx context.Context, desc *grpc.StreamDesc, cc *grpc.ClientConn, method string, streamer grpc.Streamer, opts ...grpc.CallOption) (grpc.ClientStream, error) {
return streamer(forwardIdentity(ctx), desc, cc, method, opts...)
}
// reservedHeaderWarning limits the dropped-header warning to the first occurrence.
var reservedHeaderWarning sync.Once
// jsonIncomingHeaderMatcher keeps an HTTP client from supplying the metadata the
// gateway uses to forward its identity. grpc-gateway turns "Grpc-Metadata-<key>"
// headers into gRPC metadata and joins them ahead of what its annotators add, so
// without this filter a JSON client could send its own x-netbird-fwd-uid and the
// daemon would authorize that instead of the client's real identity.
func jsonIncomingHeaderMatcher(key string) (string, bool) {
mapped, ok := runtime.DefaultHeaderMatcher(key)
if !ok {
return "", false
}
if ipcauth.IsReservedForwardKey(mapped) {
// Warn once: any client can send these on every request, so warning each
// time hands it a way to fill the log. The rest are debug-level.
reservedHeaderWarning.Do(func() {
log.Warnf("json gateway: dropping reserved header %q from a request: only the gateway may set the caller's identity", key)
})
log.Debugf("json gateway: dropping reserved header %q", key)
return "", false
}
return mapped, true
func grpcGatewayEndpoint(addr string) string {
return strings.TrimPrefix(addr, "tcp://")
}
func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint string) error {
if jsonListener.network == "tcp" {
log.Warnf("daemon JSON socket is listening on TCP (%s): callers carry no verifiable identity over TCP, "+
"so privileged operations will be denied for JSON clients", jsonListener.address)
}
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
// grpc.NewClient does not connect until the first request, so registering
// the handler here cannot block daemon startup.
target, opts := daemonaddr.DialTarget(daemonEndpoint)
opts = append(opts,
grpc.WithChainUnaryInterceptor(forwardIdentityUnary),
grpc.WithChainStreamInterceptor(forwardIdentityStream),
)
conn, err := grpc.NewClient(target, opts...)
if err != nil {
return fmt.Errorf("create daemon client for JSON gateway: %w", err)
}
if err := proto.RegisterDaemonServiceHandler(p.ctx, mux, conn); err != nil {
if cerr := conn.Close(); cerr != nil {
log.Debugf("close daemon client after failed JSON gateway registration: %v", cerr)
}
mux := runtime.NewServeMux()
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
if err := proto.RegisterDaemonServiceHandlerFromEndpoint(p.ctx, mux, grpcGatewayEndpoint(daemonEndpoint), opts); err != nil {
return err
}
@@ -131,12 +35,10 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
BaseContext: func(net.Listener) context.Context {
return p.ctx
},
ConnContext: jsonConnContext,
}
p.jsonServMu.Lock()
p.jsonServ = jsonServer
p.jsonClient = conn
p.jsonServMu.Unlock()
go func() {

View File

@@ -1,261 +0,0 @@
//go:build !windows && !ios && !android
package cmd
import (
"context"
"net"
"net/http"
"path/filepath"
"testing"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// The JSON gateway runs inside the daemon and re-dials it locally, so every JSON
// request reaches a handler with the daemon's own identity as the transport peer.
// The gateway therefore forwards its HTTP client's identity as metadata, and the
// daemon authorizes that instead of itself. These tests drive the real wiring
// (jsonConnContext, forwardIdentity, jsonIncomingHeaderMatcher) and check the
// identity a handler would end up authorizing.
// daemonSideCtx is what a handler sees for a gateway-relayed call. The transport
// peer must be this process's own identity: the gateway is the daemon, so the two
// cannot differ, and hardcoding root here instead would describe a state that
// never occurs.
func daemonSideCtx(t *testing.T, md metadata.MD) context.Context {
t.Helper()
self, err := ipcauth.CurrentProcessIdentity()
if err != nil {
t.Skipf("cannot read this process's identity: %v", err)
}
ctx := peer.NewContext(context.Background(), &peer.Peer{
AuthInfo: ipcauth.AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: self,
},
})
return metadata.NewIncomingContext(ctx, md)
}
// gatewayMetadata reproduces what the daemon receives for a JSON request: the
// mux annotates the context from the request's headers, then the interceptor on the
// gateway's client connection stamps the caller's identity. The order matters,
// since the interceptor must win over anything a header put there.
func gatewayMetadata(t *testing.T, req *http.Request, ctx context.Context) metadata.MD {
t.Helper()
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
annotated, err := runtime.AnnotateContext(ctx, mux, req,
"/daemon.DaemonService/SetConfig",
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
if err != nil {
t.Fatalf("annotate: %v", err)
}
md, ok := metadata.FromOutgoingContext(forwardIdentity(annotated))
if !ok {
t.Fatal("the interceptor produced no metadata")
}
return md
}
// clientCtx is the connection context jsonConnContext would have produced for an
// HTTP client whose identity the gateway could read.
func clientCtx(id ipcauth.Identity, known bool) context.Context {
return context.WithValue(context.Background(), jsonPeerIdentity{},
jsonPeerIdentityValue{id: id, known: known})
}
// An HTTP client must not be able to name its own identity. grpc-gateway turns
// Grpc-Metadata-<key> headers into gRPC metadata, so without the header filter and
// the interceptor overwriting the reserved keys, this request would authorize as
// uid 0.
func TestJSONGateway_ForgedIdentityHeaderIsDropped(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Uid", "0")
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Gid", "0")
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd", "1")
req.Header.Set("Grpc-Metadata-X-Netbird-Fwd-Sid", "S-1-5-18")
caller := ipcauth.Identity{UID: 31000, GID: 31000}
md := gatewayMetadata(t, req, clientCtx(caller, true))
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
if !ok {
t.Fatal("the forwarded identity should be usable")
}
if id.IsPrivileged() {
t.Errorf("forged header was believed: authorized as %v", id)
}
if id.UID != caller.UID {
t.Errorf("authorized as uid %d, want the real client %d", id.UID, caller.UID)
}
}
// A request with no headers at all (HTTP/1.0 needs no Host, and a unix socket
// yields no host:port) makes grpc-gateway produce no metadata whatsoever and skip
// its annotators: "if len(pairs) == 0 { return ctx, nil, nil }" in
// runtime/context.go. That is why the identity is stamped by an interceptor
// instead. This is the case that previously reached the gate as the daemon itself.
func TestJSONGateway_HeaderlessRequestIsStillMarkedForwarded(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
if err != nil {
t.Fatal(err)
}
req.Header = http.Header{}
req.Host = ""
caller := ipcauth.Identity{UID: 31000, GID: 31000}
ctx := clientCtx(caller, true)
// Pin the skip path itself: if grpc-gateway ever produced a pair here, this
// test would still pass below while no longer covering what it was written for.
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
annotated, err := runtime.AnnotateContext(ctx, mux, req,
"/daemon.DaemonService/SetConfig",
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
if err != nil {
t.Fatalf("annotate: %v", err)
}
if md, ok := metadata.FromOutgoingContext(annotated); ok {
t.Fatalf("grpc-gateway produced metadata %v for a headerless request; "+
"this test no longer covers the annotator-skip path", md)
}
md := gatewayMetadata(t, req, ctx)
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md))
if !ok {
t.Fatal("the forwarded identity should be usable")
}
if id.UID != caller.UID || id.IsPrivileged() {
t.Errorf("authorized as %v, want the real client uid %d", id, caller.UID)
}
}
// When the gateway cannot read its client's identity (a TCP JSON socket, say) it
// forwards the marker alone. The daemon must then report "unidentified" so the
// privileged operations refuse, rather than falling back to the gateway's own
// identity.
func TestJSONGateway_UnreadableClientIdentityIsUnidentified(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
if err != nil {
t.Fatal(err)
}
md := gatewayMetadata(t, req, clientCtx(ipcauth.Identity{}, false))
if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
t.Errorf("a request with no client identity was authorized as %v", id)
}
}
// A request that never passed through jsonConnContext (no stashed identity) must
// also come out unidentified rather than as the daemon.
func TestJSONGateway_MissingConnContextIsUnidentified(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "http://localhost/daemon.DaemonService/SetConfig", nil)
if err != nil {
t.Fatal(err)
}
md := gatewayMetadata(t, req, context.Background())
if id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, md)); ok {
t.Errorf("a request with no connection context was authorized as %v", id)
}
}
// End to end over a real unix socket: the gateway reads the connecting client's
// identity from the socket itself, so a client cannot present anything else.
func TestJSONGateway_IdentityComesFromTheSocket(t *testing.T) {
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
type observed struct {
md metadata.MD
}
seen := make(chan observed, 1)
srv := &http.Server{
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx, err := runtime.AnnotateContext(r.Context(), mux, r,
"/daemon.DaemonService/SetConfig",
runtime.WithHTTPPathPattern("/daemon.DaemonService/SetConfig"))
if err != nil {
t.Errorf("annotate: %v", err)
return
}
md, _ := metadata.FromOutgoingContext(forwardIdentity(ctx))
seen <- observed{md: md}
w.WriteHeader(http.StatusOK)
}),
ReadHeaderTimeout: 5 * time.Second,
ConnContext: jsonConnContext,
}
sock := filepath.Join(t.TempDir(), "http.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := srv.Close(); err != nil {
t.Logf("close server: %v", err)
}
})
go func() {
if err := srv.Serve(ln); err != nil && err != http.ErrServerClosed {
t.Logf("serve: %v", err)
}
}()
conn, err := net.Dial("unix", sock)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if err := conn.Close(); err != nil {
t.Logf("close conn: %v", err)
}
})
// Forge the identity headers on the wire as well.
request := "POST /daemon.DaemonService/SetConfig HTTP/1.1\r\n" +
"Host: localhost\r\n" +
"Grpc-Metadata-X-Netbird-Fwd: 1\r\n" +
"Grpc-Metadata-X-Netbird-Fwd-Uid: 0\r\n" +
"Content-Length: 0\r\n\r\n"
if _, err := conn.Write([]byte(request)); err != nil {
t.Fatal(err)
}
select {
case got := <-seen:
self, err := ipcauth.CurrentProcessIdentity()
if err != nil {
t.Skipf("cannot read this process's identity: %v", err)
}
// The socket peer is this test process, so that is the identity the
// gateway must forward, not the uid 0 the request asked for.
if uids := got.md.Get("x-netbird-fwd-uid"); len(uids) != 1 {
t.Fatalf("x-netbird-fwd-uid = %v, want exactly the gateway's own value", uids)
}
id, ok := ipcauth.CallerIdentity(daemonSideCtx(t, got.md))
if !ok {
t.Fatal("the forwarded identity should be usable")
}
if id.UID != self.UID {
t.Errorf("authorized as uid %d, want the socket peer %d", id.UID, self.UID)
}
case <-time.After(5 * time.Second):
t.Fatal("the gateway never handled the request")
}
}

View File

@@ -13,7 +13,6 @@ import (
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/configs"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/util"
)
@@ -126,13 +125,6 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
if !rootCmd.PersistentFlags().Changed("daemon-addr") && params.DaemonAddr != "" {
daemonAddr = params.DaemonAddr
// An install that predates named-pipe support has the loopback TCP
// address saved. Callers carry no identity over TCP, so move it to the
// pipe instead of restoring a socket the daemon cannot authorize on.
if migrated, ok := daemonaddr.MigrateLegacy(daemonAddr); ok {
cmd.Printf("Moving the saved daemon address from %s to %s so the daemon can identify its callers\n", daemonAddr, migrated)
daemonAddr = migrated
}
}
if !serviceCmd.PersistentFlags().Changed("json-socket") && params.JSONSocket != "" {

View File

@@ -1,14 +0,0 @@
//go:build !windows
package cmd
import (
"fmt"
"net"
)
// listenNamedPipe is Windows-only: no other platform serves the daemon on a
// named pipe.
func listenNamedPipe(string) (net.Listener, string, error) {
return nil, "", fmt.Errorf("named pipes are only supported on Windows")
}

View File

@@ -1,41 +0,0 @@
//go:build windows
package cmd
import (
"errors"
"fmt"
"net"
"github.com/Microsoft/go-winio"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// listenNamedPipe creates the daemon control pipe and reports the path it ended
// up on. The security descriptor lets any local caller connect, as a Unix socket
// at 0666 does, and the privileged operations are authorized separately from the
// caller's token.
//
// The protected name comes first so that an unprivileged process cannot take the
// name before the service does. Creating it requires being an administrator or
// LocalSystem, so a daemon an ordinary user runs themselves, as in netstack mode,
// falls back to the plain name; clients try both and check who serves them.
func listenNamedPipe(name string) (net.Listener, string, error) {
var errs []error
for _, path := range daemonaddr.PipePaths(name) {
listener, err := winio.ListenPipe(path, &winio.PipeConfig{
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
})
if err != nil {
log.Debugf("not serving the daemon on %s: %v", path, err)
errs = append(errs, fmt.Errorf("%s: %w", path, err))
continue
}
return listener, path, nil
}
return nil, "", errors.Join(errs...)
}

View File

@@ -26,14 +26,6 @@ func listenOnAddress(addr string) (*socketListener, error) {
return nil, err
}
if network == "npipe" {
listener, path, err := listenNamedPipe(address)
if err != nil {
return nil, err
}
return &socketListener{Listener: listener, network: network, address: path}, nil
}
if network == "unix" {
removeStaleUnixSocket(address)
}
@@ -49,11 +41,11 @@ func listenOnAddress(addr string) (*socketListener, error) {
func parseListenAddress(addr string) (string, string, error) {
network, address, ok := strings.Cut(addr, "://")
if !ok || network == "" || address == "" {
return "", "", fmt.Errorf("address must be in [unix|tcp|npipe]://[path|host:port|name] format: %q", addr)
return "", "", fmt.Errorf("address must be in [unix|tcp]://[path|host:port] format: %q", addr)
}
switch network {
case "unix", "tcp", "npipe":
case "unix", "tcp":
return network, address, nil
default:
return "", "", fmt.Errorf("unsupported daemon address protocol: %v", network)

View File

@@ -325,7 +325,7 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
if st, ok := gstatus.FromError(err); ok && st.Code() == codes.Unavailable {
log.Warnf("setConfig method is not available in the daemon: %s", st.Message())
} else {
return daemonCallError("call service setConfig method", err)
return fmt.Errorf("call service setConfig method: %v", err)
}
}
@@ -379,7 +379,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
}
if loginErr != nil {
return daemonCallError("login failed", loginErr)
return fmt.Errorf("login failed: %v", loginErr)
}
if loginResp.NeedsSSOLogin {
@@ -392,7 +392,7 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
ProfileName: &profileID,
Username: &username,
}); err != nil {
return daemonCallError("call service up method", err)
return fmt.Errorf("call service up method: %v", err)
}
return nil

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

@@ -1,15 +0,0 @@
package daemonaddr
// DaemonRunsAsSelf reports whether the daemon listening at addr runs as this very
// user. That is what makes an unprivileged daemon authorize this process for the
// changes it otherwise restricts to root or an administrator, so a client can tell
// up front whether those controls are usable instead of letting a save fail.
//
// It is answered from the ownership of the socket or pipe the daemon created, so it
// costs no round trip and needs no cooperation from the daemon. Ownership that
// cannot be read is reported as false, including for a TCP address, so a caller
// reading this as "the daemon would allow it" fails closed. The daemon remains the
// only thing that authorizes anything: this only decides what a client offers.
func DaemonRunsAsSelf(addr string) bool {
return daemonRunsAsSelf(addr)
}

View File

@@ -1,40 +0,0 @@
//go:build !windows
package daemonaddr
import (
"os"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
)
// daemonRunsAsSelf compares the owner of the daemon's Unix socket with this
// process's uid. Root is not treated specially here: a root caller is privileged
// on its own merits, and a root-owned socket says nothing about the caller.
func daemonRunsAsSelf(addr string) bool {
path, ok := strings.CutPrefix(addr, "unix://")
if !ok {
return false
}
info, err := os.Stat(path)
if err != nil {
log.Debugf("stat daemon socket %s: %v", path, err)
return false
}
// Only a socket says anything about a daemon. A directory or a leftover
// regular file at that path is not one, and reading it as "the daemon runs as
// us" would offer controls the daemon then refuses.
if info.Mode()&os.ModeSocket == 0 {
return false
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return false
}
return stat.Uid == uint32(os.Getuid())
}

View File

@@ -1,62 +0,0 @@
//go:build !windows
package daemonaddr
import (
"net"
"os"
"path/filepath"
"testing"
)
// A socket this user created means the daemon runs as this user, which is the
// rootless case where the daemon delegates its authority to its own identity.
func TestDaemonRunsAsSelf_OwnSocket(t *testing.T) {
path := filepath.Join(t.TempDir(), "netbird.sock")
ln, err := net.Listen("unix", path)
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() {
if err := ln.Close(); err != nil {
t.Logf("close listener: %v", err)
}
})
if !DaemonRunsAsSelf("unix://" + path) {
t.Error("a socket owned by this user must count as the daemon running as us")
}
}
// Everything that is not a readable socket of ours has to answer false, because
// the caller reads a true as "the daemon would authorize me".
func TestDaemonRunsAsSelf_FailsClosed(t *testing.T) {
dir := t.TempDir()
// A socket owned by another user, which is what a root-run daemon looks like
// to an unprivileged client. Only assertable when we are not root ourselves.
rootOwned := "unix:///var/run/netbird.sock"
if _, err := os.Stat("/var/run/netbird.sock"); err == nil && os.Getuid() != 0 {
if DaemonRunsAsSelf(rootOwned) {
t.Error("a socket owned by another user must not count as ours")
}
}
for name, addr := range map[string]string{
"missing socket": "unix://" + filepath.Join(dir, "absent.sock"),
"tcp address": "tcp://127.0.0.1:41731",
"named pipe": "npipe://netbird",
"empty": "",
"no scheme": filepath.Join(dir, "absent.sock"),
"directory": "unix://" + dir,
"unknown scheme": "http://localhost:8080",
"scheme only": "unix://",
"relative socket": "unix://netbird.sock",
} {
t.Run(name, func(t *testing.T) {
if DaemonRunsAsSelf(addr) {
t.Errorf("%q must not count as a daemon running as us", addr)
}
})
}
}

View File

@@ -1,42 +0,0 @@
//go:build windows
package daemonaddr
import (
"context"
"strings"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// daemonRunsAsSelf reads the owner of the daemon's pipe. A daemon running as the
// service account owns its pipe as LocalSystem, and an elevated one as
// BUILTIN\Administrators, so only a daemon the user started themselves matches.
func daemonRunsAsSelf(addr string) bool {
name, ok := strings.CutPrefix(addr, pipeScheme)
if !ok {
return false
}
for _, path := range PipePaths(name) {
// Bounded: this runs on the UI's path for deciding which controls to
// offer, so a pipe that does not answer promptly must not stall it. A
// timeout leaves the caller unprivileged, which only disables controls.
ctx, cancel := context.WithTimeout(context.Background(), probeTimeout)
conn, err := dialPipe(ctx, path)
cancel()
if err != nil {
continue
}
owned := ipcauth.PipeOwnedBySelf(conn)
if cerr := conn.Close(); cerr != nil {
log.Debugf("close daemon pipe %s after ownership check: %v", path, cerr)
}
return owned
}
return false
}

View File

@@ -1,103 +0,0 @@
package daemonaddr
import (
"context"
"net"
"runtime"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
)
const (
// WindowsPipeAddr is the default daemon address on Windows. A named pipe
// carries the connecting process's token, which loopback TCP does not, so
// it is the only Windows transport on which the daemon can tell who is
// calling it.
WindowsPipeAddr = "npipe://netbird"
// legacyWindowsAddr is the loopback-TCP address the Windows daemon used
// before named-pipe support.
legacyWindowsAddr = "tcp://127.0.0.1:41731"
pipeScheme = "npipe://"
// protectedPrefix is the NPFS namespace in which only LocalSystem and
// members of BUILTIN\Administrators may create a pipe. A daemon running as
// the service account creates its pipe there so that an unprivileged process
// cannot pre-create the name, which would keep the daemon from starting and
// leave callers talking to the squatter. Opening such a pipe needs no
// privilege, so unprivileged clients still reach the daemon.
protectedPrefix = `ProtectedPrefix\Administrators\`
)
// DialTarget returns the gRPC dial target and transport options for a daemon
// address. The npipe scheme needs a context dialer because gRPC has no
// named-pipe resolver; unix and tcp are handled by gRPC itself.
func DialTarget(addr string) (string, []grpc.DialOption) {
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
if name, ok := strings.CutPrefix(addr, pipeScheme); ok {
paths := PipePaths(name)
opts = append(opts, grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
return dialPipePaths(ctx, paths)
}))
return "passthrough:///netbird-daemon-pipe", opts
}
return strings.TrimPrefix(addr, "tcp://"), opts
}
// PipePath maps an npipe address name ("netbird", from "npipe://netbird") to a
// Windows named-pipe path (\\.\pipe\netbird). A fully qualified path is left as
// is.
func PipePath(name string) string {
if strings.HasPrefix(name, `\\`) {
return name
}
return `\\.\pipe\` + name
}
// PipePaths returns the paths a daemon control pipe may live at for an npipe
// address name, in the order both sides must try them: the protected name first,
// then the plain one.
//
// The daemon serves the first it can create, which is the protected name when it
// runs as the service account and the plain one when it runs as an ordinary user,
// as it does in netstack mode. Clients therefore have to try both, and because a
// client cannot tell from the name alone who created the pipe, the plain name is
// only usable once the server's identity has been checked: see
// verifyPipeServer.
//
// A fully qualified path is what the operator asked for and is used as is.
func PipePaths(name string) []string {
if strings.HasPrefix(name, `\\`) {
return []string{name}
}
return []string{PipePath(protectedPrefix + name), PipePath(name)}
}
// IsProtectedPipePath reports whether a pipe path is in the namespace only an
// administrator or LocalSystem can create in, which is what lets a client trust
// such a pipe from its name alone.
func IsProtectedPipePath(path string) bool {
return strings.HasPrefix(path, `\\.\pipe\`+protectedPrefix)
}
// MigrateLegacy upgrades the pre-named-pipe Windows daemon address to the named
// pipe, reporting whether it rewrote the address. Existing installs persist the
// daemon address, so without this an upgraded daemon would keep listening on
// loopback TCP, where callers carry no identity and privileged operations would
// have to be refused for everyone. Only the exact legacy default is rewritten:
// a deliberately chosen custom address is left alone.
func MigrateLegacy(addr string) (string, bool) {
return migrateLegacyForOS(runtime.GOOS, addr)
}
func migrateLegacyForOS(goos, addr string) (string, bool) {
if goos == "windows" && addr == legacyWindowsAddr {
return WindowsPipeAddr, true
}
return addr, false
}

View File

@@ -1,15 +0,0 @@
//go:build !windows
package daemonaddr
import (
"context"
"fmt"
"net"
)
// dialPipePaths is Windows-only: no other platform serves the daemon on a named
// pipe.
func dialPipePaths(context.Context, []string) (net.Conn, error) {
return nil, fmt.Errorf("named pipes are only supported on Windows")
}

View File

@@ -1,30 +0,0 @@
package daemonaddr
import (
"slices"
"testing"
)
// The protected name must be tried before the plain one on both sides: it is the
// one an unprivileged process cannot create, so preferring it is what keeps a
// squatter from owning the name the service daemon would otherwise use.
func TestPipePaths_PrefersTheProtectedName(t *testing.T) {
got := PipePaths("netbird")
want := []string{
`\\.\pipe\ProtectedPrefix\Administrators\netbird`,
`\\.\pipe\netbird`,
}
if !slices.Equal(got, want) {
t.Errorf("PipePaths = %q, want %q", got, want)
}
}
// An operator who passes a full path chose exactly one pipe, so neither side may
// look anywhere else.
func TestPipePaths_QualifiedPathIsUsedAsIs(t *testing.T) {
path := `\\.\pipe\custom-netbird`
got := PipePaths(path)
if !slices.Equal(got, []string{path}) {
t.Errorf("PipePaths = %q, want just %q", got, path)
}
}

View File

@@ -1,59 +0,0 @@
//go:build windows
package daemonaddr
import (
"context"
"errors"
"fmt"
"net"
"github.com/Microsoft/go-winio"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
// dialPipePaths connects to the first path that answers with a pipe server this
// client may trust, and returns the last error when none does.
func dialPipePaths(ctx context.Context, paths []string) (net.Conn, error) {
var lastErr error
for _, path := range paths {
conn, err := dialPipe(ctx, path)
if err != nil {
log.Debugf("dial daemon pipe %s: %v", path, err)
lastErr = err
continue
}
// A pipe in the protected namespace could only have been created by an
// administrator or LocalSystem, so its name is the guarantee. Any other
// name has to be checked, because any local user can create one.
if !IsProtectedPipePath(path) {
if err := ipcauth.PipeServerTrusted(conn); err != nil {
if closeErr := conn.Close(); closeErr != nil {
log.Debugf("close untrusted pipe %s: %v", path, closeErr)
}
lastErr = fmt.Errorf("%s: %w", path, err)
continue
}
}
return conn, nil
}
if lastErr == nil {
lastErr = errors.New("no daemon pipe to connect to")
}
return nil, lastErr
}
// dialPipe connects to the daemon control pipe at SECURITY_IDENTIFICATION.
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the daemon
// cannot read the caller's token at all. Identification lets the daemon read the
// caller's SID and groups without granting it the ability to act as the caller.
func dialPipe(ctx context.Context, path string) (net.Conn, error) {
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
}

View File

@@ -1,9 +0,0 @@
//go:build !windows
package daemonaddr
// ResolveDaemonAddr is a no-op off Windows, where there is no named-pipe
// default to fall back from.
func ResolveDaemonAddr(addr string) string {
return addr
}

View File

@@ -1,82 +0,0 @@
//go:build windows
package daemonaddr
import (
"net"
"strings"
"time"
"github.com/Microsoft/go-winio"
log "github.com/sirupsen/logrus"
)
// probeTimeout bounds each transport probe. Both are local, so a daemon that is
// listening answers immediately and one that is not fails immediately.
const probeTimeout = 300 * time.Millisecond
// ResolveDaemonAddr keeps a client on the named pipe and never silently moves it
// off. When the pipe does not answer it checks the legacy loopback TCP address, so
// a client meeting a daemon that has not restarted since the upgrade can say what
// is wrong, but it does not connect there.
//
// Using that address automatically would be a downgrade the user never asked for:
// any local process can bind 127.0.0.1 while the daemon is not listening, and the
// transport carries no caller identity, so a client that accepted whatever answered
// would hand a setup key, a pre-shared key or an SSO prompt to a local impostor. An
// operator who needs the legacy address during the upgrade window can still pass
// --daemon-addr explicitly, which is a deliberate choice and still refuses the
// privileged operations.
//
// Only the pipe address is resolved. A custom address is left alone, though passing
// --daemon-addr npipe://netbird explicitly is indistinguishable from the default
// here, so it is treated the same way.
func ResolveDaemonAddr(addr string) string {
if addr != WindowsPipeAddr {
return addr
}
for _, path := range PipePaths("netbird") {
if pipeAvailable(path) {
return addr
}
}
if tcpAvailable(legacyWindowsAddr) {
log.Warnf("the daemon is not serving %s, but something is listening on the legacy %s. "+
"Restart the NetBird service so it serves the pipe. That address is not used automatically: "+
"any local user can bind it and it carries no caller identity, so pass --daemon-addr %s "+
"explicitly if you accept that",
WindowsPipeAddr, legacyWindowsAddr, legacyWindowsAddr)
}
return addr
}
func pipeAvailable(path string) bool {
timeout := probeTimeout
conn, err := winio.DialPipe(path, &timeout)
if err != nil {
return false
}
if err := conn.Close(); err != nil {
log.Debugf("close daemon pipe probe: %v", err)
}
return true
}
func tcpAvailable(addr string) bool {
host := addr
if _, after, ok := strings.Cut(addr, "://"); ok {
host = after
}
conn, err := net.DialTimeout("tcp", host, probeTimeout)
if err != nil {
return false
}
if err := conn.Close(); err != nil {
log.Debugf("close daemon TCP probe: %v", err)
}
return true
}

View File

@@ -248,20 +248,6 @@ type MetricsExporter interface {
Export(w io.Writer) error
}
// LogOpener opens a log file for inclusion in the bundle. It exists so that log
// files whose path was supplied by an IPC caller can be opened under a check
// the daemon defines, instead of being opened with the daemon's privileges
// unconditionally.
type LogOpener func(path string) (*os.File, error)
func openLogFile(path string) (*os.File, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
return f, nil
}
type BundleGenerator struct {
anonymizer *anonymize.Anonymizer
@@ -271,7 +257,6 @@ type BundleGenerator struct {
syncResponse *mgmProto.SyncResponse
logPath string
uiLogPath string
uiLogOpener LogOpener
tempDir string
statePath string
cpuProfile []byte
@@ -300,20 +285,14 @@ type GeneratorDependencies struct {
SyncResponse *mgmProto.SyncResponse
LogPath string
UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one.
// UILogOpener opens the UI log and its rotated siblings. The path comes from
// a local IPC caller, so the daemon must not open it with plain os.Open: the
// opener is where the caller's right to that file is enforced. Defaults to
// os.Open, which is only correct where the path is not caller-supplied
// (mobile).
UILogOpener LogOpener
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
CPUProfile []byte
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
DaemonVersion string
CliVersion string
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
CPUProfile []byte
CapturePath string
RefreshStatus func()
ClientMetrics MetricsExporter
DaemonVersion string
CliVersion string
}
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
@@ -323,11 +302,6 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
logFileCount = 1
}
uiLogOpener := deps.UILogOpener
if uiLogOpener == nil {
uiLogOpener = openLogFile
}
return &BundleGenerator{
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
@@ -336,7 +310,6 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
syncResponse: deps.SyncResponse,
logPath: deps.LogPath,
uiLogPath: deps.UILogPath,
uiLogOpener: uiLogOpener,
tempDir: deps.TempDir,
statePath: deps.StatePath,
cpuProfile: deps.CPUProfile,
@@ -703,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))
@@ -1023,11 +995,11 @@ func (g *BundleGenerator) addLogfile() error {
logDir := filepath.Dir(g.logPath)
if err := g.addSingleLogfile(openLogFile, g.logPath, clientLogFile); err != nil {
if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil {
return fmt.Errorf("add client log file to zip: %w", err)
}
g.addRotatedLogFiles(openLogFile, logDir, clientLogPrefix)
g.addRotatedLogFiles(logDir, clientLogPrefix)
stdErrLogPath := filepath.Join(logDir, errorLogFile)
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
@@ -1036,11 +1008,11 @@ func (g *BundleGenerator) addLogfile() error {
stdoutLogPath = darwinStdoutLogPath
}
if err := g.addSingleLogfile(openLogFile, stdErrLogPath, errorLogFile); err != nil {
if err := g.addSingleLogfile(stdErrLogPath, errorLogFile); err != nil {
log.Warnf("Failed to add %s to zip: %v", errorLogFile, err)
}
if err := g.addSingleLogfile(openLogFile, stdoutLogPath, stdoutLogFile); err != nil {
if err := g.addSingleLogfile(stdoutLogPath, stdoutLogFile); err != nil {
log.Warnf("Failed to add %s to zip: %v", stdoutLogFile, err)
}
@@ -1057,18 +1029,18 @@ func (g *BundleGenerator) addUILog() error {
return nil
}
if err := g.addSingleLogfile(g.uiLogOpener, g.uiLogPath, uiLogFile); err != nil {
if err := g.addSingleLogfile(g.uiLogPath, uiLogFile); err != nil {
return fmt.Errorf("add UI log file to zip: %w", err)
}
g.addRotatedLogFiles(g.uiLogOpener, filepath.Dir(g.uiLogPath), uiLogPrefix)
g.addRotatedLogFiles(filepath.Dir(g.uiLogPath), uiLogPrefix)
return nil
}
// addSingleLogfile adds a single log file to the archive
func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName string) error {
logFile, err := open(logPath)
func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
logFile, err := os.Open(logPath)
if err != nil {
return fmt.Errorf("open log file %s: %w", targetName, err)
}
@@ -1093,8 +1065,8 @@ func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName s
}
// addSingleLogFileGz adds a single gzipped log file to the archive
func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName string) error {
f, err := open(logPath)
func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
f, err := os.Open(logPath)
if err != nil {
return fmt.Errorf("open gz log file %s: %w", targetName, err)
}
@@ -1141,7 +1113,7 @@ func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName
// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount.
// prefix is the base log name without extension (e.g. "client", "gui-client");
// the glob matches both files rotated by us and by logrotate on linux.
func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix string) {
func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) {
if g.logFileCount == 0 {
return
}
@@ -1181,9 +1153,9 @@ func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix stri
for i := 0; i < maxFiles; i++ {
name := filepath.Base(files[i])
if strings.HasSuffix(name, ".gz") {
err = g.addSingleLogFileGz(open, files[i], name)
err = g.addSingleLogFileGz(files[i], name)
} else {
err = g.addSingleLogfile(open, files[i], name)
err = g.addSingleLogfile(files[i], name)
}
if err != nil {
log.Warnf("failed to add rotated log %s: %v", name, err)

View File

@@ -27,7 +27,7 @@ func (g *BundleGenerator) addPlatformLog() error {
}
swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile)
if err := g.addSingleLogfile(openLogFile, swiftLogPath, swiftLogFile); err != nil {
if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil {
// The Swift log is best-effort: the app may not have written it yet.
log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err)
}

View File

@@ -97,7 +97,7 @@ func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount
archive: zip.NewWriter(&buf),
logFileCount: logFileCount,
}
g.addRotatedLogFiles(openLogFile, dir, prefix)
g.addRotatedLogFiles(dir, prefix)
require.NoError(t, g.archive.Close())
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))

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

@@ -1,62 +0,0 @@
package debug
import (
"archive/zip"
"bytes"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
// bundleEntries generates a bundle with the given generator and returns the
// set of entry names in the resulting archive.
func bundleEntries(t *testing.T, g *BundleGenerator) map[string]struct{} {
t.Helper()
path, err := g.Generate()
require.NoError(t, err)
t.Cleanup(func() { _ = os.Remove(path) })
data, err := os.ReadFile(path)
require.NoError(t, err)
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
require.NoError(t, err)
names := make(map[string]struct{}, len(zr.File))
for _, f := range zr.File {
names[f.Name] = struct{}{}
}
return names
}
func TestBundleIncludesUILogWhenOpenerAllows(t *testing.T) {
path := filepath.Join(t.TempDir(), uiLogFile)
require.NoError(t, os.WriteFile(path, []byte("gui log"), 0600))
g := NewBundleGenerator(GeneratorDependencies{
UILogPath: path,
UILogOpener: openLogFile,
}, BundleConfig{})
require.Contains(t, bundleEntries(t, g), uiLogFile)
}
// A UILogOpener that refuses (as the ownership check does for a foreign file)
// keeps the UI log out of the bundle without failing bundle generation.
func TestBundleExcludesUILogWhenOpenerRefuses(t *testing.T) {
path := filepath.Join(t.TempDir(), uiLogFile)
require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
g := NewBundleGenerator(GeneratorDependencies{
UILogPath: path,
UILogOpener: func(string) (*os.File, error) {
return nil, fmt.Errorf("not owned by the caller")
},
}, BundleConfig{})
require.NotContains(t, bundleEntries(t, g), uiLogFile)
}

View File

@@ -3,12 +3,10 @@ package debug
import (
"context"
"crypto/sha256"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"net/http"
neturl "net/url"
"os"
"github.com/netbirdio/netbird/upload-server/types"
@@ -16,64 +14,20 @@ import (
const maxBundleUploadSize = 50 * 1024 * 1024
// requireHTTPS refuses any URL the daemon would fetch or upload to that is not
// https. The daemon runs as root and the bundle carries its logs and state, so a
// plaintext hop is a place to intercept the bundle or the presigned redirect.
// The server-side gate already enforces this for the desktop path; this also
// covers the mobile and job-runner callers that reach this package directly.
// Skipped when the caller opted into an insecure upload (self-hosted server).
func requireHTTPS(what, rawURL string) error {
parsed, err := neturl.Parse(rawURL)
if err != nil {
return fmt.Errorf("parse %s: %w", what, err)
}
if parsed.Scheme != "https" {
return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme)
}
return nil
}
// uploadClient returns the HTTP client for the upload requests. The default
// client verifies TLS; the insecure variant accepts http and untrusted
// certificates, and is only reachable for a privileged caller that passed
// --upload-bundle-insecure (see requirePrivilegeForUploadURL).
func uploadClient(insecure bool) *http.Client {
if !insecure {
return http.DefaultClient
}
return &http.Client{
Transport: &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, //nolint:gosec // opt-in, privileged, self-hosted upload servers
},
}
}
func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) {
if !insecure {
if err := requireHTTPS("upload service URL", url); err != nil {
return "", err
}
}
response, err := getUploadURL(ctx, url, managementURL, insecure)
func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) {
response, err := getUploadURL(ctx, url, managementURL)
if err != nil {
return "", err
}
if !insecure {
if err := requireHTTPS("upload URL from service", response.URL); err != nil {
return "", err
}
}
err = upload(ctx, filePath, response, insecure)
err = upload(ctx, filePath, response)
if err != nil {
return "", err
}
return response.Key, nil
}
func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error {
func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error {
fileData, err := os.Open(filePath)
if err != nil {
return fmt.Errorf("open file: %w", err)
@@ -98,7 +52,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
req.ContentLength = stat.Size()
req.Header.Set("Content-Type", "application/octet-stream")
putResp, err := uploadClient(insecure).Do(req)
putResp, err := http.DefaultClient.Do(req)
if err != nil {
return fmt.Errorf("upload failed: %v", err)
}
@@ -111,7 +65,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
return nil
}
func getUploadURL(ctx context.Context, url string, managementURL string, insecure bool) (*types.GetURLResponse, error) {
func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) {
id := getURLHash(managementURL)
getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil)
if err != nil {
@@ -120,7 +74,7 @@ func getUploadURL(ctx context.Context, url string, managementURL string, insecur
getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue)
resp, err := uploadClient(insecure).Do(getReq)
resp, err := http.DefaultClient.Do(getReq)
if err != nil {
return nil, fmt.Errorf("get presigned URL: %w", err)
}

View File

@@ -43,7 +43,7 @@ func TestUpload(t *testing.T) {
fileContent := []byte("test file content")
err := os.WriteFile(file, fileContent, 0640)
require.NoError(t, err)
key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file, true)
key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file)
require.NoError(t, err)
id := getURLHash(testURL)
require.Contains(t, key, id+"/")

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

@@ -1,31 +0,0 @@
//go:build !linux && !darwin && !freebsd && !windows
package ipcauth
import (
"errors"
"net"
"google.golang.org/grpc/credentials"
)
// errUnsupported is returned on platforms with no local peer-identity
// primitive, so consumers fail closed instead of guessing an identity.
var errUnsupported = errors.New("peer identity is not available on this platform")
// NewTransportCredentials returns nil: without a peer-identity primitive the
// daemon cannot authenticate local callers, and the caller must treat that as
// "authorization cannot be enforced".
func NewTransportCredentials() credentials.TransportCredentials {
return nil
}
// PeerIdentity always fails on this platform.
func PeerIdentity(net.Conn) (Identity, error) {
return Identity{}, errUnsupported
}
// ConnIdentity always fails on this platform.
func ConnIdentity(net.Conn) (Identity, error) {
return Identity{}, errUnsupported
}

View File

@@ -1,56 +0,0 @@
//go:build linux || darwin || freebsd
package ipcauth
import (
"context"
"net"
"google.golang.org/grpc/credentials"
)
// NewTransportCredentials returns gRPC transport credentials that expose the
// caller's kernel-authenticated identity via IdentityFromContext. It returns
// nil on platforms that have no peer-identity primitive, which the caller must
// treat as "authorization cannot be enforced".
//
// The handshake exchanges no bytes on the wire, so a client dialing with
// insecure credentials interoperates with a server using these. That keeps
// older CLI and UI binaries working against an upgraded daemon.
func NewTransportCredentials() credentials.TransportCredentials {
return unixCreds{}
}
// ConnIdentity extracts the caller's identity from an accepted local IPC
// connection. It is shared by the gRPC transport credentials and by the JSON
// gateway, which reads the identity of its own HTTP clients.
func ConnIdentity(conn net.Conn) (Identity, error) {
return PeerIdentity(conn)
}
type unixCreds struct{}
func (unixCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return conn, AuthInfo{}, nil
}
// ServerHandshake extracts the peer identity and fails closed when it cannot
// be read, so a connection whose caller is unknown never reaches a handler.
func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
id, err := ConnIdentity(conn)
if err != nil {
return nil, nil, err
}
return conn, AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: id,
}, nil
}
func (unixCreds) Info() credentials.ProtocolInfo {
return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
}
func (unixCreds) Clone() credentials.TransportCredentials { return unixCreds{} }
func (unixCreds) OverrideServerName(string) error { return nil }

View File

@@ -1,194 +0,0 @@
//go:build windows
package ipcauth
import (
"context"
"fmt"
"net"
"runtime"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"google.golang.org/grpc/credentials"
)
var (
modadvapi32 = windows.NewLazySystemDLL("advapi32.dll")
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
)
// DefaultPipeSDDL is the security descriptor for the daemon control pipe.
//
// D:P protected DACL, no inheritance
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon's service account)
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone
//
// Any local caller may connect, as with a Unix socket at 0666; what a caller may
// actually do is decided from its token, not from the DACL. Remote callers are not
// a concern here: winio.ListenPipe creates the pipe with
// FILE_PIPE_REJECT_REMOTE_CLIENTS, so NPFS rejects connections from other machines
// before the descriptor is consulted.
//
// A deny ACE on the NETWORK SID would not add anything and would break callers:
// that SID is present in any network-logon token, which includes OpenSSH and WinRM
// sessions, so it denies administrators driving the CLI over SSH and denies the
// daemon itself when started from such a session.
func DefaultPipeSDDL() string {
return "D:P(A;;GA;;;SY)(A;;GA;;;WD)"
}
// NewTransportCredentials returns gRPC transport credentials that derive the
// caller's identity from the named-pipe client token.
//
// The client must connect at SECURITY_IDENTIFICATION for the daemon to be able
// to read its token, which is what DialNamedPipe does.
func NewTransportCredentials() credentials.TransportCredentials {
return winpipeCreds{}
}
// ConnIdentity extracts the caller's identity from an accepted named-pipe
// connection by impersonating the pipe client and reading its token. It is
// shared by the gRPC transport credentials and by the JSON gateway, which
// reads the identity of its own HTTP clients.
func ConnIdentity(conn net.Conn) (Identity, error) {
// go-winio's pipe connection embeds *win32File, which exposes Fd().
fdConn, ok := conn.(interface{ Fd() uintptr })
if !ok {
return Identity{}, fmt.Errorf("connection %T does not expose a pipe handle", conn)
}
return pipeClientIdentity(windows.Handle(fdConn.Fd()))
}
type winpipeCreds struct{}
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return conn, AuthInfo{}, nil
}
// ServerHandshake extracts the connecting client's identity and fails closed
// when the handle or token cannot be read, so a connection whose caller is
// unknown never reaches a handler.
func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
id, err := ConnIdentity(conn)
if err != nil {
return nil, nil, err
}
return conn, AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: id,
}, nil
}
func (winpipeCreds) Info() credentials.ProtocolInfo {
return credentials.ProtocolInfo{SecurityProtocol: AuthInfo{}.AuthType()}
}
func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCreds{} }
func (winpipeCreds) OverrideServerName(string) error { return nil }
// pipeClientIdentity reads the connecting client's user SID, usable group
// SIDs, and elevation state by impersonating the pipe client on this thread
// and reading the resulting impersonation token.
func pipeClientIdentity(handle windows.Handle) (id Identity, err error) {
// Impersonation is per-thread, so the goroutine must stay on this thread
// until RevertToSelf, otherwise an unrelated goroutine could inherit the
// impersonated context.
runtime.LockOSThread()
// The thread only goes back to the runtime's pool once it is provably no
// longer impersonating the client. If the revert fails, leaving it locked
// makes Go terminate it when this goroutine exits, which costs one thread
// and keeps a thread running as the client from ever being reused.
clean := false
defer func() {
if clean {
runtime.UnlockOSThread()
}
}()
if err = impersonateNamedPipeClient(handle); err != nil {
clean = true
return Identity{}, fmt.Errorf("impersonate named pipe client: %w", err)
}
defer func() {
// Surface the revert failure only when nothing else failed: leaving
// the thread impersonated is worse than the original error.
revErr := windows.RevertToSelf()
if revErr != nil {
if err == nil {
err = fmt.Errorf("revert impersonation: %w", revErr)
}
return
}
clean = true
}()
// openAsSelf=true opens the token with the daemon's own process context
// rather than the impersonated client's, so the open cannot fail because
// the client lacks access to its own token.
var token windows.Token
if err = windows.OpenThreadToken(windows.CurrentThread(), windows.TOKEN_QUERY, true, &token); err != nil {
return Identity{}, fmt.Errorf("open thread token: %w", err)
}
defer func() {
if cerr := token.Close(); cerr != nil {
log.Debugf("close client token: %v", cerr)
}
}()
return identityFromToken(token)
}
// identityFromToken reads the user SID, usable group SIDs and elevation state
// out of a Windows token.
func identityFromToken(token windows.Token) (Identity, error) {
user, err := token.GetTokenUser()
if err != nil {
return Identity{}, fmt.Errorf("read token user: %w", err)
}
groups, err := tokenGroupSIDs(token)
if err != nil {
return Identity{}, err
}
return Identity{
SID: user.User.Sid.String(),
Groups: groups,
Elevated: token.IsElevated(),
}, nil
}
// tokenGroupSIDs returns the SIDs of the groups the token can actually
// exercise. Groups that are disabled or marked deny-only are skipped: a
// UAC-filtered administrator carries BUILTIN\Administrators as deny-only, and
// treating that as membership would hand every admin account privilege it
// cannot currently use.
func tokenGroupSIDs(token windows.Token) ([]string, error) {
tg, err := token.GetTokenGroups()
if err != nil {
return nil, fmt.Errorf("read token groups: %w", err)
}
var sids []string
for _, g := range tg.AllGroups() {
if g.Attributes&windows.SE_GROUP_ENABLED == 0 {
continue
}
if g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
continue
}
sids = append(sids, g.Sid.String())
}
return sids, nil
}
func impersonateNamedPipeClient(h windows.Handle) error {
r, _, e := procImpersonateNamedPipeClient.Call(uintptr(h))
if r == 0 {
return e
}
return nil
}

View File

@@ -1,272 +0,0 @@
package ipcauth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"fmt"
"slices"
"strconv"
"strings"
"google.golang.org/grpc/metadata"
)
// Metadata keys the local JSON gateway uses to forward the identity of its own
// HTTP client to the daemon. The gateway runs inside the daemon process and
// re-dials the daemon over the control socket, so without forwarding every
// JSON request would appear to come from the daemon itself.
const (
// mdFwd marks a request as forwarded by the JSON gateway. It is always
// set, even when the gateway could not read its client's identity, so the
// daemon can tell "no identity available" apart from "not forwarded".
mdFwd = "x-netbird-fwd"
mdFwdUID = "x-netbird-fwd-uid" // Unix user ID
mdFwdGID = "x-netbird-fwd-gid" // Unix primary group ID
mdFwdSID = "x-netbird-fwd-sid" // Windows user SID
mdFwdGroup = "x-netbird-fwd-group" // Windows group SID, repeated
mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" when elevated
// mdFwdProof proves the forwarded identity was stamped by this process. The
// gateway runs inside the daemon, so a secret held in memory is available to
// the only legitimate producer and to nothing else.
mdFwdProof = "x-netbird-fwd-proof"
)
// forwardKeys is every metadata key the gateway sets. An HTTP client must never
// be able to supply one itself: see IsReservedForwardKey.
var forwardKeys = []string{mdFwd, mdFwdUID, mdFwdGID, mdFwdSID, mdFwdGroup, mdFwdElevated, mdFwdProof}
// forwardProof authenticates the gateway's forwarding metadata. It is generated
// once per daemon process and never leaves it: it is not written to disk, not
// logged, and not sent anywhere except over the daemon's own control socket to
// itself.
//
// Without it, trusting a forwarded identity rests on every layer in front of it
// stripping incoming forwarding keys, and on each key's value shape being
// distinguishable from an injected one. A single injected group SID or an
// injected "elevated" flag has the same shape as a legitimate one, so no
// cardinality rule can catch it. Requiring the proof means metadata that did not
// come from this process is refused whatever it contains.
var forwardProof = mustForwardProof()
func mustForwardProof() string {
var buf [32]byte
if _, err := rand.Read(buf[:]); err != nil {
// Continuing would leave the forwarded path authenticated by a
// predictable value, which is worse than not starting.
panic(fmt.Sprintf("generate identity forwarding proof: %v", err))
}
return hex.EncodeToString(buf[:])
}
// IsReservedForwardKey reports whether a gRPC metadata key belongs to the
// gateway's identity forwarding, and therefore must be dropped when it arrives
// from outside.
//
// grpc-gateway maps "Grpc-Metadata-<key>" request headers into gRPC metadata and
// joins them ahead of the values its own annotators add. Without dropping these,
// an HTTP client could hand the daemon "x-netbird-fwd-uid: 0" and be believed,
// because the daemon trusts forwarded metadata when the transport peer is the
// (privileged) gateway.
func IsReservedForwardKey(key string) bool {
key = strings.ToLower(key)
return slices.Contains(forwardKeys, key)
}
// ForwardIdentityMetadata encodes an HTTP client's identity for the JSON
// gateway to forward to the daemon. When known is false only the marker is
// set, which makes the daemon treat the caller as unidentified rather than as
// the daemon itself.
func ForwardIdentityMetadata(id Identity, known bool) metadata.MD {
md := metadata.MD{}
md.Set(mdFwd, "1")
md.Set(mdFwdProof, forwardProof)
if !known {
return md
}
if id.IsWindows() {
md.Set(mdFwdSID, id.SID)
if len(id.Groups) > 0 {
md.Set(mdFwdGroup, id.Groups...)
}
if id.Elevated {
md.Set(mdFwdElevated, "1")
}
return md
}
md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10))
md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10))
return md
}
// CallerIdentity returns the identity to authorize a request against. For a
// direct connection that is the transport peer's kernel identity. For a
// request relayed by the local JSON gateway it is the identity the gateway
// forwarded, since the transport peer is then the daemon itself.
//
// A forwarded identity is only honoured when the transport peer is the daemon's
// own identity and the metadata carries this process's forwarding proof, so
// forged forwarding metadata gains a caller nothing. A forwarded request that
// carries no identity is reported as unidentified, never as the daemon.
//
// The second return value is false when no identity could be established, and
// callers MUST fail closed in that case.
func CallerIdentity(ctx context.Context) (Identity, bool) {
id, ok := IdentityFromContext(ctx)
if !ok {
return Identity{}, false
}
// A forwarding key that arrives more than once did not come from the gateway
// alone, so nothing about the request can be trusted to describe its caller.
// Refusing outright matters because the alternative reading, "not forwarded",
// would authorize the request as the transport peer, which on the gateway's
// connection is the daemon itself.
if duplicatedForwardKey(ctx) {
return Identity{}, false
}
forwarded := isForwarded(ctx)
// Our own process on the other end of the socket is the JSON gateway, the only
// thing that dials the daemon from inside it. Such a call must carry a
// forwarded identity; without one there is no caller to authorize, and
// treating it as the daemon would authorize whatever reached the JSON socket.
// Only Linux reports the peer PID, so this is a belt on top of the gateway's
// interceptor rather than the sole guarantee.
if id.PID != 0 && int(id.PID) == selfPID && !forwarded {
return Identity{}, false
}
// Only the gateway's own connection may speak for someone else. Being
// privileged is not enough and not the point: the gateway runs inside the
// daemon, so it dials as the daemon's identity whatever user that is, which
// also covers a rootless container.
if !forwarded || !IsDaemonSelf(id) {
return id, true
}
// Speaking for someone else additionally requires the proof only this process
// holds. Refusing is the only safe reading: the transport peer here is the
// daemon itself, so falling back to it would authorize the request as the
// daemon. This is also what makes the forwarded values trustworthy once
// accepted, so they need no shape checks of their own.
if !authenticForward(ctx) {
return Identity{}, false
}
return forwardedIdentity(ctx)
}
// duplicatedForwardKey reports whether any forwarding key carries more than one
// value. The gateway's interceptor sets each key exactly once and replaces what
// was already there, so a repeat means a second source supplied it.
func duplicatedForwardKey(ctx context.Context) bool {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return false
}
for _, key := range forwardKeys {
// Group SIDs are legitimately repeated; the rest identify the caller.
if key == mdFwdGroup {
continue
}
if len(md.Get(key)) > 1 {
return true
}
}
return false
}
// authenticForward reports whether the request carries this process's forwarding
// proof, which only the in-process JSON gateway can supply.
func authenticForward(ctx context.Context) bool {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return false
}
got := mdSingle(md, mdFwdProof)
return subtle.ConstantTimeCompare([]byte(got), []byte(forwardProof)) == 1
}
// isForwarded reports whether the request carries the JSON gateway marker.
func isForwarded(ctx context.Context) bool {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return false
}
return mdSingle(md, mdFwd) != ""
}
// forwardedIdentity decodes the identity the JSON gateway attached.
func forwardedIdentity(ctx context.Context) (Identity, bool) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return Identity{}, false
}
if sid := mdSingle(md, mdFwdSID); sid != "" {
return Identity{
SID: sid,
// Repeated by design, one value per group, and only reachable once
// the forwarding proof has been verified.
Groups: md.Get(mdFwdGroup),
Elevated: mdSingle(md, mdFwdElevated) == "1",
}, true
}
uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32)
if err != nil {
return Identity{}, false
}
id := Identity{UID: uint32(uid)}
if gid, err := strconv.ParseUint(mdSingle(md, mdFwdGID), 10, 32); err == nil {
id.GID = uint32(gid)
}
return id, true
}
// mdSingle returns the value of a forwarded key only when exactly one was
// supplied. The gateway's interceptor sets each key exactly once, so more than one
// value means something else also supplied it, and the whole identity is treated as
// unknown rather than picking a winner. Defence in depth behind the gateway's
// header filter.
func mdSingle(md metadata.MD, key string) string {
if v := md.Get(key); len(v) == 1 {
return v[0]
}
return ""
}
// WithForwardedIdentity stamps id onto a context's outgoing metadata for the JSON
// gateway's call to the daemon, replacing any forwarding keys already present so
// values supplied from outside cannot survive alongside it.
//
// This is deliberately not done with runtime.WithMetadata: grpc-gateway skips its
// annotators entirely when no request header maps to metadata ("if len(pairs) == 0
// { return ctx, nil, nil }", runtime/context.go), which an HTTP/1.0 request with no
// Host header over a unix socket achieves. The daemon would then see an unmarked
// call whose transport peer is the daemon's own identity, and authorize it as the
// daemon. A client interceptor runs for every RPC regardless of headers.
func WithForwardedIdentity(ctx context.Context, id Identity, known bool) context.Context {
md, ok := metadata.FromOutgoingContext(ctx)
if !ok {
md = metadata.MD{}
} else {
md = md.Copy()
}
for _, key := range forwardKeys {
delete(md, key)
}
for key, values := range ForwardIdentityMetadata(id, known) {
md[key] = values
}
return metadata.NewOutgoingContext(ctx, md)
}

View File

@@ -1,214 +0,0 @@
package ipcauth
import (
"context"
"testing"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
)
// transportCtx builds a request context as the daemon's transport credentials
// would: the identity of whoever opened the socket, plus whatever metadata the
// request carried.
func transportCtx(id Identity, md metadata.MD) context.Context {
ctx := peer.NewContext(context.Background(), &peer.Peer{
AuthInfo: AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: id,
},
})
if md != nil {
ctx = metadata.NewIncomingContext(ctx, md)
}
return ctx
}
var (
root = Identity{UID: 0}
unprivUser = Identity{UID: 1000, GID: 1000}
)
// asDaemon pins which identity counts as this process for the duration of a test.
// Without it the test binary's own uid decides, which silently changes what
// "the gateway" means.
func asDaemon(t *testing.T, id Identity) {
t.Helper()
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
selfIdentity, selfKnown = id, true
selfMayDelegate = !id.IsPrivileged()
}
func TestCallerIdentity_DirectConnections(t *testing.T) {
t.Run("no transport credentials is not an identity", func(t *testing.T) {
if _, ok := CallerIdentity(context.Background()); ok {
t.Fatal("a caller with no credentials must not be identified")
}
})
t.Run("a direct caller is its transport identity", func(t *testing.T) {
id, ok := CallerIdentity(transportCtx(unprivUser, nil))
if !ok || id.UID != 1000 {
t.Fatalf("got %v ok=%t, want uid 1000", id, ok)
}
})
// The whole point of honouring forwarded metadata only from a privileged
// transport peer: an unprivileged caller can set any metadata it likes on its
// own connection to the daemon socket.
t.Run("an unprivileged caller cannot forge an identity", func(t *testing.T) {
asDaemon(t, root)
forged := metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdGID, "0")
id, ok := CallerIdentity(transportCtx(unprivUser, forged))
if !ok {
t.Fatal("caller should still be identified, as itself")
}
if id.IsPrivileged() || id.UID != 1000 {
t.Fatalf("forged metadata was believed: got %v", id)
}
})
}
func TestCallerIdentity_GatewayForwarding(t *testing.T) {
t.Run("the gateway's client identity is used, not the gateway's own", func(t *testing.T) {
asDaemon(t, root)
md := ForwardIdentityMetadata(unprivUser, true)
id, ok := CallerIdentity(transportCtx(root, md))
if !ok {
t.Fatal("forwarded identity should be usable")
}
if id.IsPrivileged() || id.UID != 1000 {
t.Fatalf("got %v, want the forwarded uid 1000 and not privileged", id)
}
})
t.Run("a privileged gateway client stays privileged", func(t *testing.T) {
asDaemon(t, root)
md := ForwardIdentityMetadata(root, true)
id, ok := CallerIdentity(transportCtx(root, md))
if !ok || !id.IsPrivileged() {
t.Fatalf("got %v ok=%t, want a privileged identity", id, ok)
}
})
// A JSON socket the gateway cannot read peer credentials from (a TCP socket,
// say) must not make every request look like the daemon itself.
t.Run("an unreadable client identity is unknown, not the daemon", func(t *testing.T) {
asDaemon(t, root)
md := ForwardIdentityMetadata(Identity{}, false)
if _, ok := CallerIdentity(transportCtx(root, md)); ok {
t.Fatal("a forwarded request with no identity must not be identified")
}
})
// grpc-gateway turns Grpc-Metadata-<key> headers into gRPC metadata and joins
// them ahead of its annotators' values. If an HTTP client's header survived
// that, this is the shape the daemon would see: the attacker's uid 0 first,
// the real uid second. The gateway filters those headers out, and reading a
// duplicated key as unknown makes the daemon safe even if it did not.
t.Run("a duplicated key from an injected header is not believed", func(t *testing.T) {
asDaemon(t, root)
md := metadata.MD{}
md.Append(mdFwd, "1")
md.Append(mdFwdUID, "0") // injected by the HTTP client
md.Append(mdFwdUID, "1000") // appended by the gateway's annotator
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
t.Fatalf("injected uid was accepted: got %v", id)
}
})
t.Run("a duplicated marker is not believed either", func(t *testing.T) {
asDaemon(t, root)
md := metadata.MD{}
md.Append(mdFwd, "1")
md.Append(mdFwd, "1")
md.Append(mdFwdUID, "1000")
// A repeated marker must not be read as "not forwarded": that would
// authorize the request as the transport peer, which on the gateway's
// connection is the daemon itself.
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
t.Fatalf("a duplicated marker was believed: got %v", id)
}
})
// The layers in front of this (the gateway's header matcher, and its
// interceptor replacing every forwarding key) are what keep outside metadata
// from arriving at all. The proof is what the daemon can check for itself, and
// it is the only defence that works for a value whose legitimate shape is
// indistinguishable from an injected one: a lone group SID, or "elevated".
t.Run("forwarding metadata without this process's proof is refused", func(t *testing.T) {
asDaemon(t, root)
for name, md := range map[string]metadata.MD{
"no proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0"),
"wrong proof": metadata.Pairs(mdFwd, "1", mdFwdUID, "0", mdFwdProof, "deadbeef"),
"windows identity without a proof": metadata.Pairs(mdFwd, "1",
mdFwdSID, "S-1-5-21-1-2-3-1001", mdFwdGroup, sidAdministrators, mdFwdElevated, "1"),
} {
t.Run(name, func(t *testing.T) {
if id, ok := CallerIdentity(transportCtx(root, md)); ok {
t.Fatalf("unstamped forwarding metadata was believed: got %v", id)
}
})
}
})
// A caller that reaches the gateway cannot see the proof, so it cannot append
// a group of its own to a genuine forwarded identity: doing so would have to
// go through the interceptor, which replaces the whole set.
t.Run("a group appended to a stamped identity does not survive the interceptor", func(t *testing.T) {
asDaemon(t, root)
injected := metadata.MD{}
injected.Append(mdFwdGroup, sidAdministrators)
ctx := WithForwardedIdentity(metadata.NewOutgoingContext(context.Background(), injected),
Identity{SID: "S-1-5-21-1-2-3-1001"}, true)
out, ok := metadata.FromOutgoingContext(ctx)
if !ok {
t.Fatal("no outgoing metadata")
}
if groups := out.Get(mdFwdGroup); len(groups) != 0 {
t.Fatalf("injected group survived: %v", groups)
}
})
}
func TestIsReservedForwardKey(t *testing.T) {
for _, key := range forwardKeys {
if !IsReservedForwardKey(key) {
t.Errorf("%q must be reserved", key)
}
}
// grpc-gateway canonicalises header names, so the check has to be
// case-insensitive.
if !IsReservedForwardKey("X-Netbird-Fwd-Uid") {
t.Error("the check must be case-insensitive")
}
for _, key := range []string{"authorization", "x-netbird", "x-netbird-fwd-uid-extra", ""} {
if IsReservedForwardKey(key) {
t.Errorf("%q must not be reserved", key)
}
}
}
func TestForwardIdentityMetadata_AlwaysMarksForwarded(t *testing.T) {
for _, tc := range []struct {
name string
id Identity
known bool
}{
{"known unix identity", unprivUser, true},
{"unknown identity", Identity{}, false},
{"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true},
} {
t.Run(tc.name, func(t *testing.T) {
md := ForwardIdentityMetadata(tc.id, tc.known)
if got := md.Get(mdFwd); len(got) != 1 || got[0] != "1" {
t.Fatalf("marker = %v, want exactly one \"1\"", got)
}
})
}
}

View File

@@ -1,127 +0,0 @@
// Package ipcauth provides the kernel-authenticated identity of a local IPC
// (gRPC) caller and the transport credentials that surface it into the gRPC
// context, so the daemon can authorize individual RPCs by caller identity.
//
// On Unix the identity is read from the kernel via SO_PEERCRED (Linux) or
// LOCAL_PEERCRED (Darwin/FreeBSD). On Windows it is derived from the
// named-pipe client token. Platforms without a peer-identity primitive get no
// credentials, and every consumer must fail closed when no identity is
// available.
package ipcauth
import (
"context"
"fmt"
"slices"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
)
// Well-known Windows SIDs that identify a fully privileged principal.
const (
sidLocalSystem = "S-1-5-18" // NT AUTHORITY\SYSTEM
sidLocalService = "S-1-5-19" // NT AUTHORITY\LOCAL SERVICE
sidNetworkService = "S-1-5-20" // NT AUTHORITY\NETWORK SERVICE
sidAdministrators = "S-1-5-32-544" // BUILTIN\Administrators
)
// Identity is the kernel-authenticated identity of a local IPC caller. The
// zero value is not a valid identity: consumers must only use one obtained
// with a true ok/nil error return.
type Identity struct {
// UID and GID are the caller's Unix user ID and primary group ID. Both are
// zero on Windows, where SID is authoritative instead.
UID uint32
GID uint32
// SID is the caller's Windows security identifier, empty on Unix.
SID string
// Groups holds the caller's Windows group SIDs, captured from the client
// token at handshake time. Only groups that are enabled and not
// deny-only are captured, so a group listed here is one the caller can
// actually exercise. Empty on Unix.
Groups []string
// Elevated reports whether the Windows client token is elevated (running
// as administrator, or an administrator with UAC turned off). Always false
// on Unix, where privilege is uid 0.
Elevated bool
// PID is the caller's process ID where the platform reports it (Linux's
// SO_PEERCRED), and 0 where it does not. It identifies the daemon's own
// process dialling itself, which is what the JSON gateway does, and is never
// used to grant anything.
PID int32
}
// IsWindows reports whether this identity is a Windows principal (SID-based)
// rather than a Unix uid/gid principal.
func (i Identity) IsWindows() bool {
return i.SID != ""
}
// IsPrivileged reports whether the caller is the platform's administrative
// principal, which is what the daemon requires for changes that cross the
// user-to-root boundary.
//
// On Windows the decision comes from the caller's token rather than from
// account names or group RIDs: an elevated token, one of the service accounts
// the daemon itself may run as, or a token with BUILTIN\Administrators
// enabled. A UAC-filtered administrator has that group marked deny-only, and
// deny-only groups are dropped when the identity is captured, so such a
// caller is correctly reported as unprivileged. Domain group memberships
// (Domain Admins and friends) are deliberately not consulted: they say
// nothing about what this token may do on this machine.
func (i Identity) IsPrivileged() bool {
if !i.IsWindows() {
return i.UID == 0
}
if i.Elevated {
return true
}
switch i.SID {
case sidLocalSystem, sidLocalService, sidNetworkService:
return true
}
return slices.Contains(i.Groups, sidAdministrators)
}
// String renders the identity for audit logs and denial messages.
func (i Identity) String() string {
if i.IsWindows() {
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
}
return fmt.Sprintf("uid=%d gid=%d", i.UID, i.GID)
}
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so
// handlers can retrieve it from the request context via IdentityFromContext.
type AuthInfo struct {
credentials.CommonAuthInfo
Identity Identity
}
// AuthType identifies the authentication scheme.
func (AuthInfo) AuthType() string { return "netbird-ipc-peercred" }
// IdentityFromContext extracts the caller's kernel-authenticated identity from
// the gRPC peer context. The second return value is false when no IPC
// transport credentials were negotiated, which happens on a TCP daemon socket
// and on platforms without a peer-identity primitive. Callers MUST fail closed
// in that case.
func IdentityFromContext(ctx context.Context) (Identity, bool) {
p, ok := peer.FromContext(ctx)
if !ok {
return Identity{}, false
}
info, ok := p.AuthInfo.(AuthInfo)
if !ok {
return Identity{}, false
}
return info.Identity, true
}

View File

@@ -1,63 +0,0 @@
package ipcauth
import (
"fmt"
"os"
)
// OpenOwnedFile opens path for reading on behalf of the IPC caller identified by
// id, and fails unless the opened file is a regular file that id owns.
//
// It exists for the paths a local caller hands to the daemon over the IPC. The
// daemon runs as root, so opening such a path unchecked lets any local user read
// any file through it. Ownership is the invariant that keeps the daemon from
// reading, with its own privileges, a file the caller could not read itself: a
// symlink or hard link planted at the path resolves to a file someone else owns
// and is refused.
//
// The check is made against the open descriptor rather than the path, so
// swapping the path between the check and the read cannot change the answer.
//
// A privileged caller is exempt: it can read the file directly, so refusing it
// here would protect nothing. The regular-file requirement still applies to
// everyone, since a fifo or device planted at the path is never a log file.
func OpenOwnedFile(id Identity, path string) (*os.File, error) {
f, err := openForRead(path)
if err != nil {
return nil, err
}
if err := checkOwnership(id, f); err != nil {
if cerr := f.Close(); cerr != nil {
return nil, fmt.Errorf("%w (close: %v)", err, cerr)
}
return nil, err
}
return f, nil
}
func checkOwnership(id Identity, f *os.File) error {
info, err := f.Stat()
if err != nil {
return fmt.Errorf("stat %s: %w", f.Name(), err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("%s is not a regular file", f.Name())
}
if IsPrivilegedCaller(id) {
return nil
}
owned, err := fileOwnedBy(id, f)
if err != nil {
return fmt.Errorf("read owner of %s: %w", f.Name(), err)
}
if !owned {
return fmt.Errorf("%s is not owned by the caller (%s)", f.Name(), id)
}
return nil
}

View File

@@ -1,64 +0,0 @@
package ipcauth
import (
"io"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/stretchr/testify/require"
)
// otherIdentity is an unprivileged caller that owns nothing the test creates.
func otherIdentity(t *testing.T) Identity {
t.Helper()
if runtime.GOOS == "windows" {
return Identity{SID: "S-1-5-21-1-2-3-1001"}
}
return Identity{UID: uint32(os.Geteuid() + 1), GID: uint32(os.Getegid() + 1)}
}
func TestOpenOwnedFileReadsFileOwnedByCaller(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("hello"), 0600))
id, err := CurrentProcessIdentity()
require.NoError(t, err)
f, err := OpenOwnedFile(id, path)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
content, err := io.ReadAll(f)
require.NoError(t, err)
require.Equal(t, "hello", string(content))
}
func TestOpenOwnedFileRefusesFileOwnedByAnother(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
_, err := OpenOwnedFile(otherIdentity(t), path)
require.ErrorContains(t, err, "not owned by the caller")
}
func TestOpenOwnedFileRefusesNonRegularFile(t *testing.T) {
dir := t.TempDir()
// The caller owns the directory, so this is the regular-file requirement
// talking, not the ownership check.
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, dir)
require.ErrorContains(t, err, "not a regular file")
}
func TestOpenOwnedFileRefusesMissingFile(t *testing.T) {
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, filepath.Join(t.TempDir(), "absent.log"))
require.Error(t, err)
}

View File

@@ -1,35 +0,0 @@
//go:build !windows
package ipcauth
import (
"fmt"
"os"
"syscall"
)
// openForRead opens a caller-supplied path without following a symlink at its
// final component and without blocking: a fifo planted at the path would
// otherwise stall the open until a writer appears, and the daemon holds a lock
// while it collects the file.
func openForRead(path string) (*os.File, error) {
f, err := os.OpenFile(path, os.O_RDONLY|syscall.O_NOFOLLOW|syscall.O_NONBLOCK, 0)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
return f, nil
}
func fileOwnedBy(id Identity, f *os.File) (bool, error) {
info, err := f.Stat()
if err != nil {
return false, err
}
stat, ok := info.Sys().(*syscall.Stat_t)
if !ok {
return false, fmt.Errorf("no owner information in %T", info.Sys())
}
return stat.Uid == id.UID, nil
}

View File

@@ -1,53 +0,0 @@
//go:build !windows
package ipcauth
import (
"os"
"path/filepath"
"syscall"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// A symlink is the shape the arbitrary-read attempt takes: the caller owns the
// link, the file it points at belongs to someone else.
func TestOpenOwnedFileRefusesSymlink(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "target.log")
require.NoError(t, os.WriteFile(target, []byte("secret"), 0600))
link := filepath.Join(dir, "gui-client.log")
require.NoError(t, os.Symlink(target, link))
id, err := CurrentProcessIdentity()
require.NoError(t, err)
_, err = OpenOwnedFile(id, link)
require.ErrorIs(t, err, syscall.ELOOP)
}
// A fifo would block the open until a writer showed up, stalling the daemon
// while it holds its lock.
func TestOpenOwnedFileRefusesFifoWithoutBlocking(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, syscall.Mkfifo(path, 0600))
id, err := CurrentProcessIdentity()
require.NoError(t, err)
done := make(chan error, 1)
go func() {
_, err := OpenOwnedFile(id, path)
done <- err
}()
select {
case err := <-done:
require.ErrorContains(t, err, "not a regular file")
case <-time.After(5 * time.Second):
t.Fatal("opening a fifo blocked")
}
}

View File

@@ -1,35 +0,0 @@
//go:build windows
package ipcauth
import (
"fmt"
"os"
"golang.org/x/sys/windows"
)
func openForRead(path string) (*os.File, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("open %s: %w", path, err)
}
return f, nil
}
// fileOwnedBy compares the file's owner SID with the caller's. Files an elevated
// process creates are owned by BUILTIN\Administrators rather than by the user,
// but such a caller is privileged and never reaches this check.
func fileOwnedBy(id Identity, f *os.File) (bool, error) {
sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
if err != nil {
return false, fmt.Errorf("read security info: %w", err)
}
owner, _, err := sd.Owner()
if err != nil {
return false, fmt.Errorf("read owner: %w", err)
}
return id.SID != "" && owner.String() == id.SID, nil
}

View File

@@ -1,57 +0,0 @@
//go:build windows
package ipcauth
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"golang.org/x/sys/windows"
)
// fileOwnerSID reads the owner SID of path the same way OpenOwnedFile does, so
// the test can construct an Identity that matches (or deliberately does not).
func fileOwnerSID(t *testing.T, path string) string {
t.Helper()
f, err := os.Open(path)
require.NoError(t, err)
t.Cleanup(func() { _ = f.Close() })
sd, err := windows.GetSecurityInfo(windows.Handle(f.Fd()), windows.SE_FILE_OBJECT, windows.OWNER_SECURITY_INFORMATION)
require.NoError(t, err)
owner, _, err := sd.Owner()
require.NoError(t, err)
return owner.String()
}
// The allow branch of fileOwnedBy is the SID-equality path the legitimate GUI
// flow depends on. Running elevated, a created file is owned by
// BUILTIN\Administrators; an Identity carrying that SID with Elevated=false and
// no groups is unprivileged by IsPrivileged (which reads the token, not the
// SID's RID), so this exercises the real GetSecurityInfo equality rather than
// the privileged-caller shortcut.
func TestOpenOwnedFileWindowsOwnerMatchAllows(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("hello"), 0600))
ownerSID := fileOwnerSID(t, path)
id := Identity{SID: ownerSID}
require.False(t, id.IsPrivileged(), "identity built from the owner SID must be unprivileged for this to test the match path")
f, err := OpenOwnedFile(id, path)
require.NoError(t, err)
_ = f.Close()
}
func TestOpenOwnedFileWindowsOwnerMismatchRefuses(t *testing.T) {
path := filepath.Join(t.TempDir(), "gui-client.log")
require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
other := Identity{SID: "S-1-5-21-9-9-9-9999"}
require.False(t, other.IsPrivileged())
_, err := OpenOwnedFile(other, path)
require.ErrorContains(t, err, "not owned by the caller")
}

View File

@@ -1,43 +0,0 @@
//go:build darwin || freebsd
package ipcauth
import (
"fmt"
"net"
"golang.org/x/sys/unix"
)
// PeerIdentity reads the kernel-authenticated identity of the process on the
// other end of a Unix socket via LOCAL_PEERCRED. The xucred is recorded by the
// kernel at connect() time and carries the peer's uid and its group list, of
// which the first entry is the primary group.
func PeerIdentity(conn net.Conn) (Identity, error) {
uc, ok := conn.(*net.UnixConn)
if !ok {
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
}
raw, err := uc.SyscallConn()
if err != nil {
return Identity{}, fmt.Errorf("raw conn: %w", err)
}
var cred *unix.Xucred
var credErr error
if err := raw.Control(func(fd uintptr) {
cred, credErr = unix.GetsockoptXucred(int(fd), unix.SOL_LOCAL, unix.LOCAL_PEERCRED)
}); err != nil {
return Identity{}, fmt.Errorf("control raw conn: %w", err)
}
if credErr != nil {
return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr)
}
id := Identity{UID: cred.Uid}
if cred.Ngroups > 0 {
id.GID = cred.Groups[0]
}
return id, nil
}

View File

@@ -1,39 +0,0 @@
//go:build linux
package ipcauth
import (
"fmt"
"net"
"golang.org/x/sys/unix"
)
// PeerIdentity reads the kernel-authenticated identity of the process on the
// other end of a Unix socket via SO_PEERCRED. The credentials are recorded by
// the kernel at connect() time and cannot be changed for the life of the
// connection, so they are not spoofable by the caller.
func PeerIdentity(conn net.Conn) (Identity, error) {
uc, ok := conn.(*net.UnixConn)
if !ok {
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
}
raw, err := uc.SyscallConn()
if err != nil {
return Identity{}, fmt.Errorf("raw conn: %w", err)
}
var cred *unix.Ucred
var credErr error
if err := raw.Control(func(fd uintptr) {
cred, credErr = unix.GetsockoptUcred(int(fd), unix.SOL_SOCKET, unix.SO_PEERCRED)
}); err != nil {
return Identity{}, fmt.Errorf("control raw conn: %w", err)
}
if credErr != nil {
return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr)
}
return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil
}

View File

@@ -1,87 +0,0 @@
//go:build windows
package ipcauth
import (
"fmt"
"net"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
)
// PipeServerTrusted reports an error unless the pipe behind conn was created by a
// principal this client may hand secrets to. Clients call it for a pipe whose name
// carries no guarantee of its own, which is any name outside the
// ProtectedPrefix\Administrators namespace: that namespace already restricts
// creation to administrators and LocalSystem, while a plain name can be created by
// any local user before the daemon gets there.
//
// The decision is made from the pipe object's owner, not from the serving process,
// because a client cannot open a process running as another user at all, and the
// legitimate case is precisely an unprivileged client talking to a privileged
// daemon. Trusted owners are the service accounts, BUILTIN\Administrators, and
// this client's own user, the last of which is the daemon a user runs themselves
// as in netstack mode. A pipe owned by anyone else gets no setup key, pre-shared
// key or SSO prompt out of this client.
func PipeServerTrusted(conn net.Conn) error {
// go-winio's pipe connection embeds *win32File, which exposes Fd().
fdConn, ok := conn.(interface{ Fd() uintptr })
if !ok {
return fmt.Errorf("connection %T does not expose a pipe handle", conn)
}
owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
if err != nil {
return err
}
if !trustedPipeOwner(owner) {
return fmt.Errorf("pipe owned by %s, which is neither an administrator nor this user", owner)
}
return nil
}
// PipeOwnedBySelf reports whether the pipe behind conn was created by this very
// user, which is how a client recognises a daemon running as itself. Ownership it
// cannot read is reported as false.
func PipeOwnedBySelf(conn net.Conn) bool {
fdConn, ok := conn.(interface{ Fd() uintptr })
if !ok {
return false
}
owner, err := pipeOwnerSID(windows.Handle(fdConn.Fd()))
if err != nil {
log.Debugf("read daemon pipe owner: %v", err)
return false
}
return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
}
// pipeOwnerSID reads the owner of the pipe object a client is connected to. The
// handle was opened with GENERIC_READ, which includes READ_CONTROL, so no extra
// access is needed.
func pipeOwnerSID(handle windows.Handle) (string, error) {
sd, err := windows.GetSecurityInfo(handle, windows.SE_KERNEL_OBJECT, windows.OWNER_SECURITY_INFORMATION)
if err != nil {
return "", fmt.Errorf("read pipe security info: %w", err)
}
owner, _, err := sd.Owner()
if err != nil {
return "", fmt.Errorf("read pipe owner: %w", err)
}
return owner.String(), nil
}
// trustedPipeOwner reports whether a pipe's owner is a principal a client may
// speak to. An elevated process's objects are owned by BUILTIN\Administrators by
// default, an unelevated one's by the user, which is why both forms appear here.
func trustedPipeOwner(owner string) bool {
switch owner {
case sidLocalSystem, sidLocalService, sidNetworkService, sidAdministrators:
return true
}
return selfKnown && selfIdentity.SID != "" && owner == selfIdentity.SID
}

View File

@@ -1,125 +0,0 @@
package ipcauth
import (
"os"
"runtime"
)
// Fields of the ErrorInfo detail the daemon attaches to a PermissionDenied it
// raises for an operation that requires root/administrator. Clients match on
// Reason and Domain rather than on the message text, and render the summary and
// command themselves so the user gets guidance instead of a gRPC error dump.
const (
// ErrorReasonPrivilegeRequired identifies the detail.
ErrorReasonPrivilegeRequired = "PRIVILEGE_REQUIRED"
// ErrorDomain scopes the reason to the NetBird daemon.
ErrorDomain = "daemon.netbird.io"
// ErrorMetaSummary is the one-sentence explanation of what was refused.
ErrorMetaSummary = "summary"
// ErrorMetaCommand is the command that performs the same operation with the
// privileges it needs, ready to copy and run.
ErrorMetaCommand = "command"
)
// The identity of the process evaluating callers, captured once because it cannot
// change. selfKnown is false when it could not be read, in which case nothing is
// ever treated as this process. selfMayDelegate additionally requires this
// process to be unprivileged: see IsPrivilegedCaller.
var (
selfIdentity Identity
selfKnown bool
selfMayDelegate bool
// selfPID is this process's PID, used to recognise the daemon dialling itself.
selfPID = os.Getpid()
)
func init() {
id, err := CurrentProcessIdentity()
if err != nil {
return
}
selfIdentity, selfKnown = id, true
// Only an unprivileged daemon delegates its authority to its own identity.
// When it is root or LocalSystem, sharing its identity does not mean sharing
// its power: on Windows a filtered and a full token carry the same SID, so
// matching there would let a non-elevated shell of an administrator account
// act as an administrator, which is the boundary the token check exists to
// keep.
selfMayDelegate = !id.IsPrivileged()
}
// IsDaemonSelf reports whether an identity is this very process. The JSON gateway
// runs inside the daemon and re-dials it locally, so this is what distinguishes
// the gateway from any other caller, whatever user the daemon runs as.
func IsDaemonSelf(id Identity) bool {
if !selfKnown || id.IsWindows() != selfIdentity.IsWindows() {
return false
}
if id.IsWindows() {
return id.SID != "" && id.SID == selfIdentity.SID
}
return id.UID == selfIdentity.UID
}
// IsPrivilegedCaller reports whether an identity may make the changes the daemon
// restricts to the platform administrator. This is the daemon's own rule and
// cannot be evaluated by a client, which does not know what the daemon runs as.
//
// Beyond root/administrator it accepts a caller running as the daemon's own
// identity when the daemon is itself unprivileged. That keeps a rootless container
// working, where there is no uid 0 at all, and a Windows daemon in netstack mode,
// which needs no administrator rights. In those setups a caller sharing the
// daemon's identity can already rewrite the config files it reads and replace the
// binary it runs, so refusing it a config change would protect nothing; and an
// unprivileged daemon cannot hand out a root shell in the first place.
func IsPrivilegedCaller(id Identity) bool {
if id.IsPrivileged() {
return true
}
return selfMayDelegate && IsDaemonSelf(id)
}
// SelfDelegatesTo returns the identity this process delegates its authority to,
// and whether it delegates at all. Only an unprivileged daemon does: see
// IsPrivilegedCaller. It exists so a refusal can name who may actually perform the
// operation, because on such a host root is neither required nor necessarily
// available.
func SelfDelegatesTo() (Identity, bool) {
if !selfKnown || !selfMayDelegate {
return Identity{}, false
}
return selfIdentity, true
}
// PrivilegedActor names the principal a privileged operation requires, for use
// in messages shown to the user.
func PrivilegedActor() string {
if runtime.GOOS == "windows" {
return "administrator privileges"
}
return "root"
}
// ElevatedCommand renders a command so that running it grants the privileges the
// operation needs. Windows has no in-line equivalent of sudo, so the command is
// returned unchanged and the user is expected to run it from an elevated
// terminal.
func ElevatedCommand(command string) string {
if runtime.GOOS == "windows" {
return command
}
return "sudo " + command
}
// UpCommand renders an elevated `netbird up` with the given flags, preceded by a
// `down`. The down is what makes the command work on a connected client: `netbird
// up` prints "Already connected" and returns without applying any config flag, so
// on its own the command would appear to do nothing. It is a no-op, exit 0, when
// the client is not connected.
//
// ";" rather than "&&" so the line can be pasted into any of the shells a user
// might have: PowerShell 5.1, still the default on Windows Server, rejects "&&"
// as a syntax error.
func UpCommand(flags string) string {
return ElevatedCommand("netbird down") + "; " + ElevatedCommand("netbird up "+flags)
}

View File

@@ -1,134 +0,0 @@
package ipcauth
import "testing"
// The self rule is the one place privilege is granted to something other than the
// platform administrator, so its two guards matter: it must apply only when the
// daemon is itself unprivileged, and only to a caller with the daemon's identity.
func TestIsPrivilegedCaller_SelfRule(t *testing.T) {
tests := []struct {
name string
// self stands in for the process the daemon runs as.
self Identity
selfKnown bool
caller Identity
want bool
}{
{
name: "root is privileged whatever the daemon runs as",
self: Identity{UID: 1000},
selfKnown: true,
caller: Identity{UID: 0},
want: true,
},
{
name: "an unprivileged daemon delegates to its own user (rootless container)",
self: Identity{UID: 1000},
selfKnown: true,
caller: Identity{UID: 1000},
want: true,
},
{
name: "an unprivileged daemon delegates to nobody else",
self: Identity{UID: 1000},
selfKnown: true,
caller: Identity{UID: 1001},
want: false,
},
{
// The daemon is root on a normal install, so sharing its identity is
// already covered by being root; nothing else may match.
name: "a root daemon delegates to nobody",
self: Identity{UID: 0},
selfKnown: true,
caller: Identity{UID: 1000},
want: false,
},
{
// Windows netstack mode: the daemon needs no administrator rights.
name: "an unprivileged windows daemon delegates to its own SID",
self: Identity{SID: "S-1-5-21-1-2-3-1001"},
selfKnown: true,
caller: Identity{SID: "S-1-5-21-1-2-3-1001"},
want: true,
},
{
name: "an unprivileged windows daemon delegates to no other SID",
self: Identity{SID: "S-1-5-21-1-2-3-1001"},
selfKnown: true,
caller: Identity{SID: "S-1-5-21-1-2-3-1002"},
want: false,
},
{
// The UAC boundary: a filtered and a full token of the same account
// carry the same SID but not the same power, so an elevated daemon must
// never delegate to its own SID.
name: "an elevated windows daemon does not delegate to its own SID",
self: Identity{SID: "S-1-5-21-1-2-3-500", Elevated: true},
selfKnown: true,
caller: Identity{SID: "S-1-5-21-1-2-3-500"},
want: false,
},
{
name: "LocalSystem is privileged on its own merits, not by delegation",
self: Identity{SID: sidLocalSystem},
selfKnown: true,
caller: Identity{SID: sidLocalSystem},
want: true, // LocalSystem is privileged on its own merits
},
{
name: "identities of different kinds never match",
self: Identity{UID: 1000},
selfKnown: true,
caller: Identity{SID: "S-1-5-21-1-2-3-1001"},
want: false,
},
{
name: "an unknown self identity delegates to nobody",
self: Identity{},
selfKnown: false,
caller: Identity{UID: 1000},
want: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
prevID, prevKnown, prevDelegate := selfIdentity, selfKnown, selfMayDelegate
t.Cleanup(func() { selfIdentity, selfKnown, selfMayDelegate = prevID, prevKnown, prevDelegate })
selfIdentity, selfKnown = tt.self, tt.selfKnown
selfMayDelegate = tt.selfKnown && !tt.self.IsPrivileged()
if got := IsPrivilegedCaller(tt.caller); got != tt.want {
t.Fatalf("IsPrivilegedCaller(%v) with daemon %v = %t, want %t",
tt.caller, tt.self, got, tt.want)
}
})
}
}
// The real process must never accidentally delegate: a test binary running as a
// normal user is unprivileged, so it may match itself, but nothing else.
func TestIsPrivilegedCaller_ThisProcess(t *testing.T) {
id, err := CurrentProcessIdentity()
if err != nil {
t.Skipf("cannot read this process's identity: %v", err)
}
// This process is always allowed to act as itself: either it is privileged, or
// it is unprivileged and therefore delegates to its own identity.
if !IsPrivilegedCaller(id) {
t.Errorf("this process %v was refused its own identity", id)
}
// A caller that is neither root nor this process must be refused, whatever
// this process happens to be.
other := Identity{UID: id.UID + 1}
if id.IsWindows() {
other = Identity{SID: id.SID + "9"}
}
if IsPrivilegedCaller(other) {
t.Errorf("an unrelated identity %v was treated as privileged", other)
}
}

View File

@@ -1,17 +0,0 @@
//go:build !windows
package ipcauth
import "os"
// CurrentProcessIdentity returns this process's identity as the daemon would
// see it if this process connected to the local IPC. It lets a client (the UI)
// decide up front whether a privileged operation can succeed, without a
// round-trip and without duplicating the rules: the answer comes from the same
// Identity.IsPrivileged the daemon applies.
func CurrentProcessIdentity() (Identity, error) {
return Identity{
UID: uint32(os.Geteuid()),
GID: uint32(os.Getegid()),
}, nil
}

View File

@@ -1,35 +0,0 @@
//go:build windows
package ipcauth
import (
"fmt"
"golang.org/x/sys/windows"
)
// CurrentProcessIdentity returns this process's identity as the daemon would see
// it if this process connected to the local IPC. It lets a client (the UI)
// decide up front whether a privileged operation can succeed, without a
// round-trip and without duplicating the rules: the answer comes from the same
// Identity.IsPrivileged the daemon applies to the token it reads off the pipe.
func CurrentProcessIdentity() (Identity, error) {
// A pseudo-token, so it must not be closed.
token := windows.GetCurrentProcessToken()
user, err := token.GetTokenUser()
if err != nil {
return Identity{}, fmt.Errorf("read token user: %w", err)
}
groups, err := tokenGroupSIDs(token)
if err != nil {
return Identity{}, err
}
return Identity{
SID: user.User.Sid.String(),
Groups: groups,
Elevated: token.IsElevated(),
}, nil
}

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")
@@ -746,13 +738,6 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
// appended for https or ":80" for http. The serviceName parameter is
// used to contextualise error messages. On success returns the parsed
// *url.URL; on failure returns a non-nil error.
// ParseServiceURL normalises a service URL exactly as the config layer does when
// it stores one, so callers comparing a requested URL against a stored one do not
// have to reimplement the scheme validation and default-port handling.
func ParseServiceURL(serviceName, serviceURL string) (*url.URL, error) {
return parseURL(serviceName, serviceURL)
}
func parseURL(serviceName, serviceURL string) (*url.URL, error) {
parsedMgmtURL, err := url.ParseRequestURI(serviceURL)
if err != nil {

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]

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