Compare commits

..

14 Commits

Author SHA1 Message Date
Theodor S. Midtlien
bbf326e22c Fix up comments and improve readability 2026-07-25 20:51:29 +02:00
Theodor S. Midtlien
11642aeb44 Make active profile name available for other owners in status and list 2026-07-25 20:04:01 +02:00
Theodor S. Midtlien
14917dbc22 Refactor owner to be daemon-wide 2026-07-25 18:31:39 +02:00
Theodor S. Midtlien
ae9ee15501 Relax profile command ACLs 2026-07-25 16:09:22 +02:00
Theodor S. Midtlien
cd98648a67 Add elevation for dangerous ssh flags 2026-07-24 21:30:10 +02:00
Theodor S. Midtlien
0bb73b3730 Add warning log for tcp json socket 2026-07-24 19:39:58 +02:00
Theodor S. Midtlien
5e07a0c27b Add migration for legacy profile ownership 2026-07-24 15:31:57 +02:00
Theodor S. Midtlien
0137876618 Clean up comments 2026-07-24 14:57:21 +02:00
Theodor S. Midtlien
57f9cbe5ff Add migration from legacy tcp to named pipes 2026-07-24 10:58:29 +02:00
Theodor S. Midtlien
f60ac9e746 Wire up json socket as a named pipe with metdata exchange to daemon 2026-07-23 20:27:06 +02:00
Theodor S. Midtlien
2f84aa3d20 Remove PID from Identity 2026-07-23 19:43:58 +02:00
Theodor S. Midtlien
4de39a80f8 Clean up windows impersonation/SDDL and some comments 2026-07-23 19:35:22 +02:00
Theodor S. Midtlien
5d9ef0123f Add impersonateNamedPipeClient 2026-07-23 16:05:27 +02:00
Theodor S. Midtlien
27afbd7952 WIP: acl interceptor and named pipe ui 2026-07-23 14:14:42 +02:00
365 changed files with 9102 additions and 24276 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=npipe://netbird
```
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

@@ -145,7 +145,7 @@ func (pm *ProfileManager) SwitchProfile(id string) error {
// AddProfile creates a new profile
func (pm *ProfileManager) AddProfile(profileName string) error {
// Use ServiceManager (creates profile in profiles/ directory)
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername)
profile, err := pm.serviceMgr.AddProfile(profileName, androidUsername, nil)
if err != nil {
return fmt.Errorf("failed to add profile: %w", err)
}
@@ -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")
}

58
client/cmd/elevate.go Normal file
View File

@@ -0,0 +1,58 @@
package cmd
import (
"fmt"
"os"
"github.com/spf13/cobra"
)
// SetSSHConfigCmdName is the hidden subcommand an elevated process runs to apply
// the privileged SSH settings.
const SetSSHConfigCmdName = "set-ssh-config"
// sshConfigElevated is set by runInDaemonMode once the dangerous SSH settings
// have been applied by an elevated helper, so the (unprivileged) up flow does
// not re-send them and re-trip the daemon gate.
var sshConfigElevated bool
// wantsDangerousSSH reports whether this invocation is trying to ENABLE SSH root
// login or DISABLE SSH authentication.
func wantsDangerousSSH(cmd *cobra.Command) bool {
return (cmd.Flags().Changed(enableSSHRootFlag) && enableSSHRoot) ||
(cmd.Flags().Changed(disableSSHAuthFlag) && disableSSHAuth)
}
// buildSetSSHConfigArgs builds the argument list for an elevated
// `netbird set-ssh-config` invocation.
func buildSetSSHConfigArgs(profileName, username string, enableSSHRoot, disableSSHAuth *bool, daemonAddr string) []string {
args := []string{SetSSHConfigCmdName}
if profileName != "" {
args = append(args, "--profile", profileName)
}
if username != "" {
args = append(args, "--username", username)
}
if enableSSHRoot != nil && *enableSSHRoot {
args = append(args, "--"+enableSSHRootFlag)
}
if disableSSHAuth != nil && *disableSSHAuth {
args = append(args, "--"+disableSSHAuthFlag)
}
if daemonAddr != "" {
args = append(args, "--daemon-addr", daemonAddr)
}
return args
}
// ElevateSSHConfig re-runs the current executable's `set-ssh-config` command with
// root/administrator privileges via the platform's prompt (pkexec/UAC/osascript),
// so the elevated process connects to the daemon with a privileged identity and
// the daemon's requirePrivilegedForDangerousSSH gate passes.
func ElevateSSHConfig(profileName, username string, enableSSHRoot, disableSSHAuth *bool) error {
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("locate executable for elevation: %w", err)
}
return runElevated(exe, buildSetSSHConfigArgs(profileName, username, enableSSHRoot, disableSSHAuth, daemonAddr))
}

View File

@@ -0,0 +1,16 @@
//go:build darwin
package cmd
import (
"fmt"
"os"
)
// isProcessPrivileged reports whether the current process runs as root.
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
// TODO(ssh-elevation): implement the osascript admin prompt.
func runElevated(_ string, _ []string) error {
return fmt.Errorf("automatic privilege elevation is not yet implemented on macOS, re-run with sudo")
}

View File

@@ -0,0 +1,38 @@
//go:build linux
package cmd
import (
"fmt"
"os"
"os/exec"
"strings"
)
// isProcessPrivileged reports whether the current process runs as root.
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
// runElevated re-runs exe with args as root. On a graphical session it uses
// pkexec, which drives the desktop's polkit authentication agent (GUI prompt).
func runElevated(exe string, args []string) error {
if !hasGraphicalSession() {
return fmt.Errorf("cannot request privilege elevation without a graphical session. re-run as root: sudo %s %s", exe, strings.Join(args, " "))
}
pkexec, err := exec.LookPath("pkexec")
if err != nil {
return fmt.Errorf("pkexec not found for privilege elevation, re-run as root: sudo %s %s", exe, strings.Join(args, " "))
}
c := exec.Command(pkexec, append([]string{exe}, args...)...)
c.Stdin, c.Stdout, c.Stderr = os.Stdin, os.Stdout, os.Stderr
if err := c.Run(); err != nil {
return fmt.Errorf("elevated %s failed (elevation cancelled or denied?): %w", SetSSHConfigCmdName, err)
}
return nil
}
// hasGraphicalSession heuristically reports whether a desktop session is present
// that polkit can prompt in.
func hasGraphicalSession() bool {
return os.Getenv("DISPLAY") != "" || os.Getenv("WAYLAND_DISPLAY") != ""
}

View File

@@ -0,0 +1,17 @@
//go:build !linux && !darwin && !windows
package cmd
import (
"fmt"
"os"
"strings"
)
// isProcessPrivileged reports whether the current process runs as root.
func isProcessPrivileged() bool { return os.Geteuid() == 0 }
// runElevated has no automatic elevation mechanism on these platforms
func runElevated(exe string, args []string) error {
return fmt.Errorf("automatic privilege elevation is not supported on this platform, re-run as root: sudo %s %s", exe, strings.Join(args, " "))
}

View File

@@ -0,0 +1,99 @@
//go:build !ios && !android
package cmd
import (
"testing"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildSetSSHConfigArgs(t *testing.T) {
tr, fa := true, false
t.Run("enable root only, with daemon-addr", func(t *testing.T) {
got := buildSetSSHConfigArgs("prof", "alice", &tr, nil, "unix:///x.sock")
assert.Equal(t, []string{
SetSSHConfigCmdName, "--profile", "prof", "--username", "alice",
"--" + enableSSHRootFlag, "--daemon-addr", "unix:///x.sock",
}, got)
})
t.Run("disable auth only, no daemon-addr", func(t *testing.T) {
got := buildSetSSHConfigArgs("prof", "alice", nil, &tr, "")
assert.Equal(t, []string{
SetSSHConfigCmdName, "--profile", "prof", "--username", "alice",
"--" + disableSSHAuthFlag,
}, got)
})
t.Run("false pointers omit the flags", func(t *testing.T) {
got := buildSetSSHConfigArgs("", "", &fa, &fa, "")
assert.Equal(t, []string{SetSSHConfigCmdName}, got)
})
t.Run("both enabled", func(t *testing.T) {
got := buildSetSSHConfigArgs("p", "u", &tr, &tr, "")
assert.Equal(t, []string{
SetSSHConfigCmdName, "--profile", "p", "--username", "u",
"--" + enableSSHRootFlag, "--" + disableSSHAuthFlag,
}, got)
})
}
func TestBuildSetSSHConfigRequest(t *testing.T) {
tr := true
req := buildSetSSHConfigRequest("p", "u", &tr, nil)
assert.Equal(t, "p", req.ProfileName)
assert.Equal(t, "u", req.Username)
if assert.NotNil(t, req.EnableSSHRoot) {
assert.True(t, *req.EnableSSHRoot)
}
assert.Nil(t, req.DisableSSHAuth, "an unset flag must leave the daemon value untouched")
}
func TestWantsDangerousSSH(t *testing.T) {
origRoot, origAuth := enableSSHRoot, disableSSHAuth
t.Cleanup(func() { enableSSHRoot, disableSSHAuth = origRoot, origAuth })
newCmd := func() *cobra.Command {
enableSSHRoot, disableSSHAuth = false, false
c := &cobra.Command{Use: "x"}
c.Flags().BoolVar(&enableSSHRoot, enableSSHRootFlag, false, "")
c.Flags().BoolVar(&disableSSHAuth, disableSSHAuthFlag, false, "")
return c
}
// wantsDangerousSSH fires only in the privileged direction.
t.Run("enable root true is dangerous", func(t *testing.T) {
c := newCmd()
require.NoError(t, c.Flags().Set(enableSSHRootFlag, "true"))
assert.True(t, wantsDangerousSSH(c))
})
t.Run("enable root false is not dangerous", func(t *testing.T) {
c := newCmd()
require.NoError(t, c.Flags().Set(enableSSHRootFlag, "false"))
assert.False(t, wantsDangerousSSH(c))
})
t.Run("disable auth true is dangerous", func(t *testing.T) {
c := newCmd()
require.NoError(t, c.Flags().Set(disableSSHAuthFlag, "true"))
assert.True(t, wantsDangerousSSH(c))
})
t.Run("disable auth false is not dangerous", func(t *testing.T) {
c := newCmd()
require.NoError(t, c.Flags().Set(disableSSHAuthFlag, "false"))
assert.False(t, wantsDangerousSSH(c))
})
t.Run("nothing changed is not dangerous", func(t *testing.T) {
c := newCmd()
assert.False(t, wantsDangerousSSH(c))
})
}

View File

@@ -0,0 +1,23 @@
//go:build windows
package cmd
import (
"fmt"
"golang.org/x/sys/windows"
)
// isProcessPrivileged reports whether the current process token is elevated
// (running as administrator / LocalSystem).
func isProcessPrivileged() bool {
return windows.GetCurrentProcessToken().IsElevated()
}
// runElevated should re-run exe with args elevated via a UAC prompt
// (ShellExecuteEx with the "runas" verb).
//
// TODO(ssh-elevation): implement ShellExecuteEx("runas") + wait for the child.
func runElevated(_ string, _ []string) error {
return fmt.Errorf("automatic privilege elevation is not yet implemented on Windows, re-run netbird as administrator")
}

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")

116
client/cmd/owner.go Normal file
View File

@@ -0,0 +1,116 @@
package cmd
import (
"context"
"time"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
var ownerCmd = &cobra.Command{
Use: "owner",
Short: "Manage who may control the NetBird daemon",
Long: `Manage the daemon-wide owners.
Owners are enforced on the daemon and stored in the service parameters. All owners
may control the daemon and use the shared default profile (plus root/administrator),
every other profile stays isolated to the user that created it. An unowned daemon
is claimed by the first caller (trust-on-first-use).`,
}
var ownerAddCmd = &cobra.Command{
Use: "add <principal>",
Short: "Add a daemon owner principal",
Long: `Add a daemon-wide owner principal. Principals are typed:
uid:1000 a Unix user ID
gid:1000 a Unix group ID
group:netbird-admins a Unix group name (resolved via NSS/getent)
sid:S-1-5-21-... a Windows user or group SID
Requires root/administrator or an existing owner.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.AddOwner(ctx, &proto.AddOwnerRequest{Principal: args[0]}); err != nil {
return err
}
cmd.Printf("Added daemon owner %q\n", args[0])
return nil
})
},
}
var ownerResetCmd = &cobra.Command{
Use: "reset",
Short: "Clear the daemon owner list (root/administrator only)",
Long: `Clear the daemon-wide owner list, returning the daemon to the unowned
state. The next caller then claims ownership (trust-on-first-use). Requires
root/administrator.`,
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.ResetOwner(ctx, &proto.ResetOwnerRequest{}); err != nil {
return err
}
cmd.Println("Daemon owner list cleared, the next caller will claim ownership")
return nil
})
},
}
var ownerShareCmd = &cobra.Command{
Use: "share",
Short: "Mark the daemon shared (any local user may control it)",
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: true}); err != nil {
return err
}
cmd.Println("Daemon is now shared with all local users")
return nil
})
},
}
var ownerUnshareCmd = &cobra.Command{
Use: "unshare",
Short: "Stop sharing the daemon (restrict to its owners)",
RunE: func(cmd *cobra.Command, args []string) error {
return withDaemon(cmd, func(ctx context.Context, c proto.DaemonServiceClient) error {
if _, err := c.ShareProfile(ctx, &proto.ShareProfileRequest{Shared: false}); err != nil {
return err
}
cmd.Println("Daemon is no longer shared")
return nil
})
},
}
// withDaemon runs fn with a connected daemon client, handling setup and teardown.
func withDaemon(cmd *cobra.Command, fn func(context.Context, proto.DaemonServiceClient) error) error {
SetFlagsFromEnvVars(rootCmd)
cmd.SetOut(cmd.OutOrStdout())
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
log.Errorf("failed initializing log %v", err)
return err
}
ctx, cancel := context.WithTimeout(cmd.Context(), 20*time.Second)
defer cancel()
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
log.Errorf("failed to connect to service CLI interface %v", err)
return err
}
defer func() {
if cerr := conn.Close(); cerr != nil {
log.Debugf("close daemon connection: %v", cerr)
}
}()
return fn(ctx, proto.NewDaemonServiceClient(conn))
}

View File

@@ -117,10 +117,12 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
} else {
fmt.Fprintln(tw, "NAME\tACTIVE")
}
anyActive := false
for _, profile := range resp.Profiles {
marker := ""
if profile.IsActive {
marker = "✓"
anyActive = true
}
name := profilemanager.StripCtrlChars(profile.Name)
id := profilemanager.ID(profile.Id)
@@ -130,7 +132,19 @@ func listProfilesFunc(cmd *cobra.Command, _ []string) error {
fmt.Fprintf(tw, "%s\t%s\n", name, marker)
}
}
return tw.Flush()
if err := tw.Flush(); err != nil {
return err
}
// None of the caller's profiles is active: another user may hold the daemon.
// Surface it so the empty ACTIVE column is not mistaken for "nothing active".
if !anyActive {
if active, aerr := daemonClient.GetActiveProfile(cmd.Context(), &proto.GetActiveProfileRequest{}); aerr == nil && active.GetUsername() != "" && !usernamesMatch(active.GetUsername(), currUser.Username) {
cmd.Printf("\nActive profile belongs to another user: %s (user %s)\n",
profilemanager.StripCtrlChars(active.GetProfileName()), active.GetUsername())
}
}
return nil
}
func addProfileFunc(cmd *cobra.Command, args []string) error {

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"io/fs"
"net"
"os"
"os/signal"
"path"
@@ -20,6 +21,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 +92,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,7 +144,7 @@ func init() {
defaultDaemonAddr := "unix:///var/run/netbird.sock"
if runtime.GOOS == "windows" {
defaultDaemonAddr = daddr.WindowsPipeAddr
defaultDaemonAddr = windowsPipeDaemonAddr
}
rootCmd.PersistentFlags().StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp|npipe]://[path|host:port|name]")
@@ -172,6 +173,11 @@ func init() {
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)
rootCmd.AddCommand(ownerCmd)
ownerCmd.AddCommand(ownerAddCmd, ownerResetCmd, ownerShareCmd, ownerUnshareCmd)
rootCmd.AddCommand(setSSHConfigCmd)
networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
@@ -264,15 +270,32 @@ func FlagNameToEnvVar(cmdFlag string, prefix string) string {
return prefix + upper
}
// daemonDialTarget returns the gRPC dial target and base options for the daemon
// address, handling the npipe scheme (Windows named pipe, via a context dialer)
// and unix/tcp.
func daemonDialTarget(addr string) (string, []grpc.DialOption) {
opts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
target := strings.TrimPrefix(addr, "tcp://")
if strings.HasPrefix(addr, "npipe://") {
path := pipePath(strings.TrimPrefix(addr, "npipe://"))
opts = append(opts, grpc.WithContextDialer(func(dialCtx context.Context, _ string) (net.Conn, error) {
return dialNamedPipe(dialCtx, path)
}))
target = "passthrough:///netbird-daemon-pipe"
}
return target, opts
}
// DialClientGRPCServer returns client connection to the daemon server.
func DialClientGRPCServer(ctx context.Context, addr string) (*grpc.ClientConn, error) {
func DialClientGRPCServer(ctx context.Context, addr string, opts ...grpc.DialOption) (*grpc.ClientConn, error) {
ctx, cancel := context.WithTimeout(ctx, time.Second*10)
defer cancel()
target, opts := daddr.DialTarget(addr)
opts = append(opts, grpc.WithBlock())
target, dialOpts := daemonDialTarget(addr)
dialOpts = append(dialOpts, grpc.WithBlock())
dialOpts = append(dialOpts, opts...)
return grpc.DialContext(ctx, target, opts...)
return grpc.DialContext(ctx, target, dialOpts...)
}
// WithBackOff execute function in backoff cycle.

View File

@@ -30,18 +30,19 @@ var (
serviceEnvVars []string
jsonSocket string
enableJSONSocket bool
// owners seeds the daemon-wide owner set at install time (--owner). At runtime
// the daemon reads and writes owners in service.json directly.
owners []string
// daemonShared carries the persisted daemon shared flag across
// install/reconfigure round-trips (set at runtime via `netbird owner share`).
daemonShared bool
)
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
@@ -59,7 +60,8 @@ func init() {
serviceCmd.PersistentFlags().BoolVar(&captureEnabled, "enable-capture", false, "Enables packet capture via 'netbird debug capture'. To persist, use: netbird service install --enable-capture")
serviceCmd.PersistentFlags().BoolVar(&networksDisabled, "disable-networks", false, "Disables network selection. If enabled, the client will not allow listing, selecting, or deselecting networks. To persist, use: netbird service install --disable-networks")
serviceCmd.PersistentFlags().BoolVar(&enableJSONSocket, "enable-json-socket", false, "Enables the HTTP/JSON API socket served by grpc-gateway. To persist, use: netbird service install --enable-json-socket")
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp]://[path|host:port]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
serviceCmd.PersistentFlags().StringVar(&jsonSocket, "json-socket", defaultJSONSocket, "HTTP/JSON API socket address [unix|tcp|npipe]://[path|host:port|name]. Requires --enable-json-socket to serve. To persist, use: netbird service install --enable-json-socket --json-socket")
serviceCmd.PersistentFlags().StringSliceVar(&owners, "owner", nil, "Principal(s) allowed to control the daemon and its default profile: uid:1000, gid:1000, group:netbird-admins (NSS), or sid:S-1-5-... (Windows). Repeatable. Other profiles stay isolated per user. To persist: netbird service install --owner uid:1000")
rootCmd.PersistentFlags().StringVarP(&serviceName, "service", "s", defaultServiceName, "Netbird system service name")
serviceEnvDesc := `Sets extra environment variables for the service. ` +

View File

@@ -14,7 +14,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"
@@ -22,6 +21,25 @@ import (
"github.com/netbirdio/netbird/util"
)
// daemonServerOptions installs peer-identity transport credentials and the
// authorization interceptor on the daemon ipc if supported.
func daemonServerOptions(network string, interceptor *ipcauth.Interceptor) []grpc.ServerOption {
creds := ipcauth.NewTransportCredentials()
if creds == nil {
log.Warnf("daemon ipc has no peer-identity primitive on %s, per-caller authorization is disabled", runtime.GOOS)
return nil
}
if network == "tcp" {
log.Warnf("daemon is listening on TCP (%s), peer identity cannot be authenticated over TCP, per-caller authorization is disabled", daemonAddr)
return nil
}
return []grpc.ServerOption{
grpc.Creds(creds),
grpc.ChainUnaryInterceptor(interceptor.UnaryServerInterceptor()),
grpc.ChainStreamInterceptor(interceptor.StreamServerInterceptor()),
}
}
func validateJSONSocketFlags() error {
if serviceCmd.PersistentFlags().Changed("json-socket") && !enableJSONSocket {
return fmt.Errorf("--json-socket requires --enable-json-socket to configure the daemon JSON gateway")
@@ -29,31 +47,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,11 +58,12 @@ 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)
// A daemon installed before named-pipe support uses the old loopback-TCP
// address as the daemon address. We migrate to a named pipe so an
// upgraded daemon enforces per-caller authorization instead of silently
// running on identity-less TCP.
if migrated, ok := migrateLegacyDaemonAddr(daemonAddr); ok {
log.Infof("legacy daemon address %q predates named-pipe support. listening on %q so per-caller authorization is enforced", daemonAddr, migrated)
daemonAddr = migrated
}
@@ -78,93 +72,79 @@ func (p *program) Start(svc service.Service) error {
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)...)
// Owner-authorization interceptor. The ConfigAdapter is a lazy bridge: the
// gRPC server is built before the daemon server instance exists, so we set
// the real policy backend below once serverInstance is created.
ownerAdapter := &ipcauth.ConfigAdapter{}
authInterceptor := ipcauth.NewInterceptor(ownerAdapter, ipcauth.NewDefaultGroupResolver())
daemonListener, jsonListener, err := listenDaemonSockets()
// in any case, even if configuration does not exist we run daemon to serve the CLI gRPC API.
p.serv = grpc.NewServer(daemonServerOptions(network, authInterceptor)...)
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)
// Daemon-wide owners live in service.json (governs the default profile and
// daemon access), wire persistence before serving so owner add / TOFU work.
serverInstance.SetDaemonOwnerStore(daemonOwnerStore{})
if err := serverInstance.Start(); err != nil {
log.Fatalf("failed to start daemon: %v", err)
}
ownerAdapter.SetBackend(serverInstance)
proto.RegisterDaemonServiceServer(p.serv, serverInstance)
p.serverInstanceMu.Lock()
p.serverInstance = serverInstance
p.serverInstanceMu.Unlock()
if jsonListener != nil {
log.Warnf("JSON gateway (--enable-json-socket) re-dials the daemon locally. The HTTP client's identity is forwarded so per-caller authorization still applies, but restrict access to %s appropriately", jsonSocket)
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 +159,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

@@ -8,120 +8,59 @@ import (
"fmt"
"net"
"net/http"
"sync"
"time"
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
"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{}
// jsonPeerCtxKey keys the HTTP client's kernel identity in the request context.
type jsonPeerCtxKey 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.
// jsonConnContext reads the connecting HTTP client's identity from the JSON
// socket and stashes it so it can be forwarded to the daemon. The gateway
// re-dials the daemon as the daemon's own identity, so without this the
// daemon would see every JSON request as privileged.
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
log.Warnf("json gateway: cannot read HTTP client identity, requests won't carry it: %v", err)
return ctx
}
return context.WithValue(ctx, jsonPeerIdentity{}, value)
return context.WithValue(ctx, jsonPeerCtxKey{}, id)
}
// 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)
// jsonForwardIdentity injects the stashed HTTP client identity as gRPC metadata
// on the gateway's re-dial to the daemon. The daemon trusts it only because the
// dial arrives as the daemon's own (self/privileged) identity.
func jsonForwardIdentity(ctx context.Context, _ *http.Request) metadata.MD {
id, ok := ctx.Value(jsonPeerCtxKey{}).(ipcauth.Identity)
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 nil
}
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
return ipcauth.ForwardIdentityMetadata(id)
}
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)
log.Warnf("json daemon is listening on TCP (%s), peer identity cannot be authenticated over TCP, per-caller authorization is disabled", daemonAddr)
}
mux := runtime.NewServeMux(runtime.WithIncomingHeaderMatcher(jsonIncomingHeaderMatcher))
mux := runtime.NewServeMux(runtime.WithMetadata(jsonForwardIdentity))
// 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),
)
// Lazy client to the daemon, npipe-aware (grpc.NewClient does not connect
// until the first request, so this does not block startup before Serve).
target, opts := daemonDialTarget(daemonEndpoint)
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)
}
return err
}
@@ -136,7 +75,6 @@ func (p *program) startJSONGateway(jsonListener *socketListener, daemonEndpoint
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"
)
@@ -34,6 +33,15 @@ type serviceParams struct {
DisableNetworks bool `json:"disable_networks,omitempty"`
EnableJSONSocket bool `json:"enable_json_socket,omitempty"`
ServiceEnvVars map[string]string `json:"service_env_vars,omitempty"`
// Owners lists the principals allowed to control this profile over the local
// IPC, as typed strings: "uid:1000", "gid:1000", "group:netbird-admins"
// (Unix, NSS-resolved) or "sid:S-1-5-..." (Windows user or group SID). Empty
// with Shared=false means the profile is owned by nobody yet, until claimed
Owners []string `json:"owners,omitempty"`
// Shared, when true, lets any authenticated local caller control this profile
// (opt-in). Takes precedence over Owners.
Shared bool `json:"shared,omitempty"`
}
// serviceParamsPath returns the path to the service params file.
@@ -41,6 +49,38 @@ func serviceParamsPath() string {
return filepath.Join(configs.StateDir, serviceParamsFile)
}
// daemonOwnerStore persists the daemon-wide owner set in service.json. It
// implements server.DaemonOwnerStore so the daemon can read owners at startup and
// mutate them at runtime (owner add, reset, share, TOFU claim) without server
// importing cmd. Load-modify-write preserves the other service.json fields.
type daemonOwnerStore struct{}
func (daemonOwnerStore) Load() ([]string, bool, error) {
params, err := loadServiceParams()
if err != nil {
return nil, false, err
}
if params == nil {
return nil, false, nil
}
return params.Owners, params.Shared, nil
}
func (daemonOwnerStore) Save(owners []string, shared bool) error {
params, err := loadServiceParams()
if err != nil {
return err
}
if params == nil {
// No service.json yet (daemon started without `service install`). Seed it
// from the running daemon's current parameters so the file stays complete.
params = currentServiceParams()
}
params.Owners = owners
params.Shared = shared
return saveServiceParams(params)
}
// loadServiceParams reads saved service parameters from disk.
// Returns nil with no error if the file does not exist.
func loadServiceParams() (*serviceParams, error) {
@@ -87,6 +127,8 @@ func currentServiceParams() *serviceParams {
EnableCapture: captureEnabled,
DisableNetworks: networksDisabled,
EnableJSONSocket: enableJSONSocket,
Owners: owners,
Shared: daemonShared,
}
if len(serviceEnvVars) > 0 {
@@ -126,11 +168,8 @@ 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)
if migrated, ok := migrateLegacyDaemonAddr(daemonAddr); ok {
cmd.Printf("Migrating saved daemon address %q to %q so per-caller authorization can be enforced\n", daemonAddr, migrated)
daemonAddr = migrated
}
}
@@ -173,6 +212,14 @@ func applyServiceParams(cmd *cobra.Command, params *serviceParams) {
networksDisabled = params.DisableNetworks
}
// Carry the daemon-wide owner set forward across install/reconfigure so a
// runtime owner add or TOFU claim in service.json is not clobbered. --owner
// overrides.
if !serviceCmd.PersistentFlags().Changed("owner") && len(params.Owners) > 0 {
owners = params.Owners
}
daemonShared = params.Shared
applyServiceEnvParams(cmd, params)
}

View File

@@ -431,9 +431,15 @@ func TestServiceParams_BuildArgsCoversAllFlags(t *testing.T) {
installerFile, err := parser.ParseFile(fset, "service_installer.go", nil, 0)
require.NoError(t, err)
// Fields that are handled outside of buildServiceArguments (env vars go through newSVCConfig).
// Fields that are handled outside of buildServiceArguments.
// - ServiceEnvVars flows through newSVCConfig() EnvVars, not CLI args.
// - Owners/Shared are daemon-wide ownership persisted in service.json and
// read+mutated by the daemon at runtime (owner add / TOFU claim); they are
// deliberately NOT baked into the run args so runtime changes are not lost.
fieldsNotInArgs := map[string]bool{
"ServiceEnvVars": true,
"Owners": true,
"Shared": true,
}
buildFields := extractFuncGlobalRefs(t, installerFile, "buildServiceArguments")

View File

@@ -3,12 +3,18 @@
package cmd
import (
"context"
"fmt"
"net"
"runtime"
)
// 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")
// listenNamedPipe is unsupported off Windows; named pipes are a Windows-only transport.
func listenNamedPipe(string) (net.Listener, error) {
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
}
// dialNamedPipe is unsupported off Windows.
func dialNamedPipe(context.Context, string) (net.Conn, error) {
return nil, fmt.Errorf("named pipe daemon socket is only supported on Windows, not %s", runtime.GOOS)
}

View File

@@ -3,39 +3,28 @@
package cmd
import (
"errors"
"fmt"
"context"
"net"
"github.com/Microsoft/go-winio"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"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...)
// listenNamedPipe creates the daemon control named pipe with a permissive,
// local-only SDDL. Any local caller may connect, like Unix socket with 0666.
func listenNamedPipe(path string) (net.Listener, error) {
return winio.ListenPipe(path, &winio.PipeConfig{
SecurityDescriptor: ipcauth.DefaultPipeSDDL(),
})
}
// dialNamedPipe connects to the daemon ipc named pipe at SECURITY_IDENTIFICATION.
func dialNamedPipe(ctx context.Context, path string) (net.Conn, error) {
access := uint32(windows.GENERIC_READ | windows.GENERIC_WRITE)
// winio's plain DialPipe connects at SECURITY_ANONYMOUS, under which the
// daemon cannot read the caller's token. Identification lets the daemon
// read its SID/groups without granting it the ability to act as the caller.
return winio.DialPipeAccessImpLevel(ctx, path, access, winio.PipeImpLevelIdentification)
}

View File

@@ -7,6 +7,7 @@ import (
"fmt"
"net"
"os"
"runtime"
"strings"
"syscall"
"time"
@@ -14,6 +15,30 @@ import (
log "github.com/sirupsen/logrus"
)
const (
windowsPipeDaemonAddr = "npipe://netbird"
// legacyWindowsDaemonAddr is the loopback-TCP address the Windows daemon used
// before named-pipe support.
legacyWindowsDaemonAddr = "tcp://127.0.0.1:41731"
)
// migrateLegacyDaemonAddr upgrades the pre-named-pipe Windows daemon address to
// the pipe. Existing installs persist daemon addr, so on upgrade the daemon
// would otherwise keep listening on TCP and silently run without IPC
// authorization. Only the exact legacy default is rewritten, while a
// deliberately-chosen custom TCP address is left alone.
func migrateLegacyDaemonAddr(addr string) (string, bool) {
return migrateLegacyDaemonAddrForOS(runtime.GOOS, addr)
}
func migrateLegacyDaemonAddrForOS(goos, addr string) (string, bool) {
if goos == "windows" && addr == legacyWindowsDaemonAddr {
return windowsPipeDaemonAddr, true
}
return addr, false
}
type socketListener struct {
net.Listener
network string
@@ -27,7 +52,8 @@ func listenOnAddress(addr string) (*socketListener, error) {
}
if network == "npipe" {
listener, path, err := listenNamedPipe(address)
path := pipePath(address)
listener, err := listenNamedPipe(path)
if err != nil {
return nil, err
}
@@ -60,6 +86,15 @@ func parseListenAddress(addr string) (string, string, error) {
}
}
// pipePath maps a daemon-addr npipe name ("npipe://netbird") to a Windows
// named-pipe path (\\.\pipe\netbird).
func pipePath(name string) string {
if strings.HasPrefix(name, `\\`) {
return name
}
return `\\.\pipe\` + name
}
func removeStaleUnixSocket(path string) {
stat, err := os.Lstat(path)
if err != nil {

View File

@@ -0,0 +1,63 @@
//go:build !ios && !android
package cmd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestMigrateLegacyDaemonAddrForOS(t *testing.T) {
cases := []struct {
name string
goos string
addr string
want string
migrate bool
}{
{
name: "windows legacy tcp migrates to pipe",
goos: "windows",
addr: legacyWindowsDaemonAddr,
want: windowsPipeDaemonAddr,
migrate: true,
},
{
name: "windows pipe already migrated stays",
goos: "windows",
addr: windowsPipeDaemonAddr,
want: windowsPipeDaemonAddr,
migrate: false,
},
{
name: "windows custom tcp left alone",
goos: "windows",
addr: "tcp://127.0.0.1:9999",
want: "tcp://127.0.0.1:9999",
migrate: false,
},
{
name: "linux legacy-looking tcp not migrated",
goos: "linux",
addr: legacyWindowsDaemonAddr,
want: legacyWindowsDaemonAddr,
migrate: false,
},
{
name: "linux unix socket untouched",
goos: "linux",
addr: "unix:///var/run/netbird.sock",
want: "unix:///var/run/netbird.sock",
migrate: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got, ok := migrateLegacyDaemonAddrForOS(tc.goos, tc.addr)
assert.Equal(t, tc.want, got)
assert.Equal(t, tc.migrate, ok)
})
}
}

View File

@@ -0,0 +1,73 @@
package cmd
import (
"fmt"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
var (
setSSHConfigProfile string
setSSHConfigUsername string
setSSHConfigEnableRoot bool
setSSHConfigDisableAuth bool
)
// setSSHConfigCmd applies the privileged SSH settings (enable root login /
// disable auth) to a profile over the daemon IPC. Hidden because users
// interact via `netbird up` / the UI, not directly.
var setSSHConfigCmd = &cobra.Command{
Use: SetSSHConfigCmdName,
Short: "Apply privileged SSH settings to a profile (internal elevation target)",
Hidden: true,
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := internal.CtxInitState(cmd.Context())
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
return fmt.Errorf("connect to daemon: %w", err)
}
defer func() {
if cerr := conn.Close(); cerr != nil {
log.Warnf("failed closing daemon gRPC client connection: %v", cerr)
}
}()
var enableRoot, disableAuth *bool
if cmd.Flags().Changed(enableSSHRootFlag) {
enableRoot = &setSSHConfigEnableRoot
}
if cmd.Flags().Changed(disableSSHAuthFlag) {
disableAuth = &setSSHConfigDisableAuth
}
req := buildSetSSHConfigRequest(setSSHConfigProfile, setSSHConfigUsername, enableRoot, disableAuth)
if _, err := proto.NewDaemonServiceClient(conn).SetConfig(ctx, req); err != nil {
return fmt.Errorf("apply SSH config: %w", err)
}
return nil
},
}
// buildSetSSHConfigRequest builds a minimal SetConfigRequest that touches only
// the SSH fields that were provided (nil pointers leave the daemon's stored
// value untouched, matching setupSetConfigReq's pointer semantics).
func buildSetSSHConfigRequest(profileName, username string, enableSSHRoot, disableSSHAuth *bool) *proto.SetConfigRequest {
return &proto.SetConfigRequest{
ProfileName: profileName,
Username: username,
EnableSSHRoot: enableSSHRoot,
DisableSSHAuth: disableSSHAuth,
}
}
func init() {
setSSHConfigCmd.Flags().StringVar(&setSSHConfigProfile, "profile", "", "profile name to apply the SSH settings to")
setSSHConfigCmd.Flags().StringVar(&setSSHConfigUsername, "username", "", "owning username of the profile")
setSSHConfigCmd.Flags().BoolVar(&setSSHConfigEnableRoot, enableSSHRootFlag, false, "enable root login for the SSH server")
setSSHConfigCmd.Flags().BoolVar(&setSSHConfigDisableAuth, disableSSHAuthFlag, false, "disable SSH authentication")
}

View File

@@ -5,6 +5,8 @@ import (
"fmt"
"net"
"net/netip"
"os/user"
"runtime"
"strings"
"time"
@@ -172,10 +174,9 @@ func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (
return resp, nil
}
// getActiveProfileName asks the daemon for the active profile's display
// name. The daemon runs as root and can read the per-user profile files to
// resolve the ID to its human-readable name. Returns an empty string on any
// error so status output degrades gracefully.
// getActiveProfileName asks the daemon for the active profile's display name,
// annotated with the owning user when the active profile belongs to someone else.
// Returns an empty string on any error so status output degrades gracefully.
func getActiveProfileName(ctx context.Context) string {
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
@@ -188,7 +189,22 @@ func getActiveProfileName(ctx context.Context) string {
return ""
}
return resp.GetProfileName()
name := resp.GetProfileName()
if owner := resp.GetUsername(); owner != "" {
if curr, uerr := user.Current(); uerr != nil || !usernamesMatch(owner, curr.Username) {
name = fmt.Sprintf("%s (user %s)", name, owner)
}
}
return name
}
// usernamesMatch compares usernames case-insensitively on Windows (domain
// accounts) and exactly elsewhere.
func usernamesMatch(a, b string) bool {
if runtime.GOOS == "windows" {
return strings.EqualFold(a, b)
}
return a == b
}
func parseFilters() error {

View File

@@ -21,8 +21,8 @@ import (
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
nbnet "github.com/netbirdio/netbird/client/net"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/client/server"
"github.com/netbirdio/netbird/client/system"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -56,6 +56,7 @@ var (
showQR bool
profileName string
configPath string
claimOwner bool
upCmd = &cobra.Command{
Use: "up",
@@ -67,6 +68,7 @@ var (
func init() {
upCmd.PersistentFlags().BoolVarP(&foregroundMode, "foreground-mode", "F", false, "start service in foreground")
upCmd.PersistentFlags().BoolVar(&claimOwner, "owner", false, "claim ownership of this profile for the current user, restricting daemon control of it to you and root/administrator")
upCmd.PersistentFlags().StringVar(&interfaceName, interfaceNameFlag, iface.WgInterfaceDefault, "WireGuard interface name")
upCmd.PersistentFlags().Uint16Var(&wireguardPort, wireguardPortFlag, iface.DefaultWgPort, "WireGuard interface listening port")
upCmd.PersistentFlags().Uint16Var(&mtu, mtuFlag, iface.DefaultMTU, "Set MTU (Maximum Transmission Unit) for the WireGuard interface")
@@ -319,13 +321,31 @@ func runInDaemonMode(ctx context.Context, cmd *cobra.Command, pm *profilemanager
return fmt.Errorf("get current user: %v", err)
}
// Enabling SSH root login / disabling SSH auth is a privileged change the
// daemon rejects from a non-privileged caller. Offer to elevate (polkit/UAC/
// osascript) so an elevated helper applies just those settings.
if wantsDangerousSSH(cmd) && !isProcessPrivileged() {
cmd.Println("Enabling SSH root login / disabling SSH authentication requires administrator privileges, requesting elevation...")
var enableRoot, disableAuth *bool
if cmd.Flag(enableSSHRootFlag).Changed {
enableRoot = &enableSSHRoot
}
if cmd.Flag(disableSSHAuthFlag).Changed {
disableAuth = &disableSSHAuth
}
if err := ElevateSSHConfig(activeProf.ID.String(), username.Username, enableRoot, disableAuth); err != nil {
return fmt.Errorf("apply privileged SSH settings: %w", err)
}
sshConfigElevated = true
}
// set the new config
req := setupSetConfigReq(customDNSAddressConverted, cmd, activeProf.ID.String(), username.Username)
if _, err := client.SetConfig(ctx, req); err != nil {
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 +399,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 {
@@ -391,8 +411,9 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
if _, err := client.Up(ctx, &proto.UpRequest{
ProfileName: &profileID,
Username: &username,
ClaimOwner: claimOwner,
}); err != nil {
return daemonCallError("call service up method", err)
return fmt.Errorf("call service up method: %v", err)
}
return nil
@@ -421,7 +442,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
if cmd.Flag(serverSSHAllowedFlag).Changed {
req.ServerSSHAllowed = &serverSSHAllowed
}
if cmd.Flag(enableSSHRootFlag).Changed {
if cmd.Flag(enableSSHRootFlag).Changed && !sshConfigElevated {
req.EnableSSHRoot = &enableSSHRoot
}
if cmd.Flag(enableSSHSFTPFlag).Changed {
@@ -433,7 +454,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
if cmd.Flag(enableSSHRemotePortForwardFlag).Changed {
req.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
}
if cmd.Flag(disableSSHAuthFlag).Changed {
if cmd.Flag(disableSSHAuthFlag).Changed && !sshConfigElevated {
req.DisableSSHAuth = &disableSSHAuth
}
if cmd.Flag(sshJWTCacheTTLFlag).Changed {
@@ -649,7 +670,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
loginRequest.ServerSSHAllowed = &serverSSHAllowed
}
if cmd.Flag(enableSSHRootFlag).Changed {
if cmd.Flag(enableSSHRootFlag).Changed && !sshConfigElevated {
loginRequest.EnableSSHRoot = &enableSSHRoot
}
@@ -665,7 +686,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
loginRequest.EnableSSHRemotePortForwarding = &enableSSHRemotePortForward
}
if cmd.Flag(disableSSHAuthFlag).Changed {
if cmd.Flag(disableSSHAuthFlag).Changed && !sshConfigElevated {
loginRequest.DisableSSHAuth = &disableSSHAuth
}

View File

@@ -29,7 +29,7 @@ func TestUpDaemon(t *testing.T) {
}
sm := profilemanager.ServiceManager{}
created, err := sm.AddProfile("test1", currUser.Username)
created, err := sm.AddProfile("test1", currUser.Username, nil)
if err != nil {
t.Fatalf("failed to add profile: %v", err)
return

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

@@ -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,
}
@@ -625,7 +621,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 +696,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

@@ -0,0 +1,84 @@
package ipcauth
import (
"slices"
"strconv"
)
// Ownership is a profile's access policy: the typed owner principals plus the
// opt-in shared flag.
type Ownership struct {
Owners []string
Shared bool
}
// GroupResolver resolves a Unix caller's effective group IDs and owner group
// names to GIDs. A nil resolver disables group matching.
type GroupResolver interface {
// CallerGIDs returns the set of group IDs the caller belongs to.
CallerGIDs(id Identity) map[uint32]struct{}
// GroupNameGID resolves a group name to its GID.
GroupNameGID(name string) (uint32, bool)
}
// Authorize reports whether the identity may control a profile with the given
// ownership. Privileged callers and shared profiles are always allowed.
func Authorize(o Ownership, id Identity, r GroupResolver) bool {
if id.IsPrivileged() {
return true
}
if o.Shared {
return true
}
for _, raw := range o.Owners {
p, ok := ParsePrincipal(raw)
if !ok {
continue
}
if principalMatches(p, id, r) {
return true
}
}
return false
}
func principalMatches(p Principal, id Identity, r GroupResolver) bool {
switch p.Kind {
case KindUID:
if id.IsWindows() {
return false
}
uid, err := strconv.ParseUint(p.Value, 10, 32)
return err == nil && uint32(uid) == id.UID
case KindGID:
if id.IsWindows() {
return false
}
gid, err := strconv.ParseUint(p.Value, 10, 32)
return err == nil && callerHasGID(uint32(gid), id, r)
case KindGroup:
if id.IsWindows() || r == nil {
return false
}
gid, ok := r.GroupNameGID(p.Value)
return ok && callerHasGID(gid, id, r)
case KindSID:
if !id.IsWindows() {
return false
}
return id.SID == p.Value || slices.Contains(id.Groups, p.Value)
default:
return false
}
}
func callerHasGID(gid uint32, id Identity, r GroupResolver) bool {
if id.GID == gid {
return true
}
if r == nil {
return false
}
_, ok := r.CallerGIDs(id)[gid]
return ok
}

View File

@@ -3,29 +3,20 @@
package ipcauth
import (
"errors"
"fmt"
"net"
"runtime"
"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".
// NewTransportCredentials returns nil on platforms without a peer-identity
// primitive.
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.
// ConnIdentity is unsupported on platforms without a peer-identity primitive.
func ConnIdentity(net.Conn) (Identity, error) {
return Identity{}, errUnsupported
return Identity{}, fmt.Errorf("peer identity not supported on %s", runtime.GOOS)
}

View File

@@ -9,33 +9,28 @@ import (
"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.
// NewTransportCredentials returns gRPC transport credentials that extract the
// caller's kernel-authenticated identity from a Unix-socket connection and
// expose it via IdentityFromContext. Non-nil on platforms with a
// peer-credential primitive.
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.
// ConnIdentity extracts the caller's identity from an accepted local IPC
// connection. On Unix it reads peer credentials from the socket. It is shared
// by the gRPC transport credentials and the JSON gateway (which forwards it).
func ConnIdentity(conn net.Conn) (Identity, error) {
return PeerIdentity(conn)
}
// ServerHandshake extracts the peer identity and fails closed if it cannot be read.
func (unixCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
id, err := ConnIdentity(conn)
if err != nil {

View File

@@ -8,7 +8,6 @@ import (
"net"
"runtime"
log "github.com/sirupsen/logrus"
"golang.org/x/sys/windows"
"google.golang.org/grpc/credentials"
)
@@ -18,39 +17,35 @@ var (
procImpersonateNamedPipeClient = modadvapi32.NewProc("ImpersonateNamedPipeClient")
)
// DefaultPipeSDDL is the security descriptor for the daemon control pipe.
// DefaultPipeSDDL keeps the daemon control pipe open to any LOCAL caller,
// like Unix socket with 0666 permissions.
//
// 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.
// D:P protected DACL, no inheritance
// (D;;GA;;;NU) deny GENERIC_ALL to NETWORK (remote/SMB)
// (A;;GA;;;SY) allow GENERIC_ALL to LocalSystem (the daemon itself)
// (A;;GA;;;WD) allow GENERIC_ALL to Everyone (local, per-RPC ACL gates)
func DefaultPipeSDDL() string {
return "D:P(A;;GA;;;SY)(A;;GA;;;WD)"
return "D:P(D;;GA;;;NU)(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.
// This requires the client to dial at SECURITY_IDENTIFICATION (see dialNamedPipe).
func NewTransportCredentials() credentials.TransportCredentials {
return winpipeCreds{}
}
type winpipeCreds struct{}
func (winpipeCreds) ClientHandshake(_ context.Context, _ string, conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
return conn, AuthInfo{}, nil
}
// 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.
// shared by the gRPC transport credentials and the JSON gateway (which forwards
// it). Requires the client to have connected at SECURITY_IDENTIFICATION.
func ConnIdentity(conn net.Conn) (Identity, error) {
// go-winio's pipe connection embeds *win32File, which exposes Fd().
fdConn, ok := conn.(interface{ Fd() uintptr })
@@ -60,15 +55,8 @@ func ConnIdentity(conn net.Conn) (Identity, error) {
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.
// ServerHandshake extracts the connecting client's identity from the pipe. Fails
// closed if the handle or token cannot be read.
func (winpipeCreds) ServerHandshake(conn net.Conn) (net.Conn, credentials.AuthInfo, error) {
id, err := ConnIdentity(conn)
if err != nil {
@@ -88,103 +76,56 @@ func (winpipeCreds) Clone() credentials.TransportCredentials { return winpipeCre
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.
// pipeClientIdentity reads the connecting client's user SID, enabled group SIDs,
// and elevation by impersonating the pipe client on this thread and reading the
// 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()
}
}()
defer 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.
// Surface revert error if there are no other errors.
revErr := windows.RevertToSelf()
if revErr != nil {
if err == nil {
err = fmt.Errorf("revert impersonation: %w", revErr)
}
return
if err == nil {
err = revErr
}
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.
// openAsSelf=true: the token is opened using the daemon's process context
// (LocalSystem), not the impersonated client's, so the open always succeeds.
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)
}
}()
defer token.Close()
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()
tu, err := token.GetTokenUser()
if err != nil {
return Identity{}, fmt.Errorf("read token user: %w", err)
return Identity{}, fmt.Errorf("get token user: %w", err)
}
groups, err := tokenGroupSIDs(token)
tg, err := token.GetTokenGroups()
if err != nil {
return Identity{}, err
return Identity{}, fmt.Errorf("get token groups: %w", err)
}
var groups []string
for _, g := range tg.AllGroups() {
if g.Attributes&windows.SE_GROUP_ENABLED == 0 || g.Attributes&windows.SE_GROUP_USE_FOR_DENY_ONLY != 0 {
continue
}
groups = append(groups, g.Sid.String())
}
return Identity{
SID: user.User.Sid.String(),
SID: tu.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 {

View File

@@ -2,92 +2,26 @@ 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.
// Metadata keys used by the local JSON gateway to forward the HTTP client's
// identity to the daemon.
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
mdFwdUID = "x-netbird-fwd-uid" // Unix
mdFwdGID = "x-netbird-fwd-gid" // Unix
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"
mdFwdGroup = "x-netbird-fwd-group" // Windows group SID (repeated)
mdFwdElevated = "x-netbird-fwd-elevated" // Windows, "1" if elevated
)
// 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
}
// ForwardIdentityMetadata encodes an identity for the gateway to forward to the
// daemon.
func ForwardIdentityMetadata(id Identity) metadata.MD {
if id.IsWindows() {
md := metadata.MD{}
md.Set(mdFwdSID, id.SID)
if len(id.Groups) > 0 {
md.Set(mdFwdGroup, id.Groups...)
@@ -97,176 +31,47 @@ func ForwardIdentityMetadata(id Identity, known bool) metadata.MD {
}
return md
}
md.Set(mdFwdUID, strconv.FormatUint(uint64(id.UID), 10))
md.Set(mdFwdGID, strconv.FormatUint(uint64(id.GID), 10))
return md
return metadata.Pairs(
mdFwdUID, strconv.FormatUint(uint64(id.UID), 10),
mdFwdGID, strconv.FormatUint(uint64(id.GID), 10),
)
}
// 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.
// forwardedIdentity extracts a forwarded identity from incoming gRPC metadata
func forwardedIdentity(ctx context.Context) (Identity, bool) {
md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return Identity{}, false
}
if sid := mdSingle(md, mdFwdSID); sid != "" {
if sid := mdFirst(md, mdFwdSID); sid != "" {
return Identity{
SID: sid,
// Repeated by design, one value per group, and only reachable once
// the forwarding proof has been verified.
SID: sid,
Groups: md.Get(mdFwdGroup),
Elevated: mdSingle(md, mdFwdElevated) == "1",
Elevated: mdFirst(md, mdFwdElevated) == "1",
}, true
}
uid, err := strconv.ParseUint(mdSingle(md, mdFwdUID), 10, 32)
uidStr := mdFirst(md, mdFwdUID)
if uidStr == "" {
return Identity{}, false
}
uid, err := strconv.ParseUint(uidStr, 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)
if g := mdFirst(md, mdFwdGID); g != "" {
if v, err := strconv.ParseUint(g, 10, 32); err == nil {
id.GID = uint32(v)
}
}
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 {
func mdFirst(md metadata.MD, key string) string {
if v := md.Get(key); len(v) > 0 {
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

@@ -4,211 +4,38 @@ import (
"context"
"testing"
"google.golang.org/grpc/credentials"
"github.com/stretchr/testify/assert"
"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
func TestForwardIdentityRoundTrip(t *testing.T) {
cases := []struct {
name string
id Identity
}{
{"known unix identity", unprivUser, true},
{"unknown identity", Identity{}, false},
{"windows identity", Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}, true},
} {
{"unix uid/gid", Identity{UID: 1000, GID: 1000}},
{"windows sid+groups+elevated", Identity{
SID: "S-1-5-21-1-2-3-1001",
Groups: []string{"S-1-5-32-544", "S-1-1-0"},
Elevated: true,
}},
{"windows sid only", Identity{SID: "S-1-5-21-9"}},
}
for _, tc := range cases {
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)
}
ctx := metadata.NewIncomingContext(context.Background(), ForwardIdentityMetadata(tc.id))
got, ok := forwardedIdentity(ctx)
assert.True(t, ok)
assert.Equal(t, tc.id, got)
})
}
}
func TestForwardedIdentity_None(t *testing.T) {
_, ok := forwardedIdentity(context.Background())
assert.False(t, ok, "no metadata, no forwarded identity")
ctx := metadata.NewIncomingContext(context.Background(), metadata.Pairs("other", "x"))
_, ok = forwardedIdentity(ctx)
assert.False(t, ok)
}

View File

@@ -1,59 +1,43 @@
// 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.
// context, so the daemon can authorize each RPC 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.
// 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 therefore no enforcement.
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
)
// sidLocalSystem is the well-known Windows SID for the LocalSystem account.
const sidLocalSystem = "S-1-5-18"
// 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.
// Identity is the kernel-authenticated identity of a local IPC caller. The zero
// value is not a valid identity.
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 and GID are the caller's Unix user ID and primary group ID.
// Zero on Windows, where SID is authoritative instead.
UID uint32
GID uint32
// SID is the caller's Windows security identifier, empty on Unix.
// 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.
// token at handshake (empty on Unix, where supplementary group membership is
// resolved on demand via NSS/getent by the authorizer).
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 reports whether the Windows client token is elevated (run as
// administrator). 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)
@@ -63,35 +47,15 @@ func (i Identity) IsWindows() bool {
}
// 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.
// principal.
func (i Identity) IsPrivileged() bool {
if !i.IsWindows() {
return i.UID == 0
if i.IsWindows() {
return i.Elevated || i.SID == sidLocalSystem
}
if i.Elevated {
return true
}
switch i.SID {
case sidLocalSystem, sidLocalService, sidNetworkService:
return true
}
return slices.Contains(i.Groups, sidAdministrators)
return i.UID == 0
}
// String renders the identity for audit logs and denial messages.
// String renders the identity for audit logs.
func (i Identity) String() string {
if i.IsWindows() {
return fmt.Sprintf("sid=%s elevated=%t", i.SID, i.Elevated)
@@ -99,8 +63,8 @@ func (i Identity) String() string {
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.
// AuthInfo carries the peer Identity as a gRPC credentials.AuthInfo so the
// interceptor can retrieve it from the request context via IdentityFromContext.
type AuthInfo struct {
credentials.CommonAuthInfo
Identity Identity
@@ -110,10 +74,8 @@ type AuthInfo struct {
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.
// the gRPC peer context. The second return value is false when no IPC transport
// credentials were negotiated, callers MUST fail closed in that case.
func IdentityFromContext(ctx context.Context) (Identity, bool) {
p, ok := peer.FromContext(ctx)
if !ok {

View File

@@ -0,0 +1,50 @@
package ipcauth
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
)
func TestIdentityFromContext_NoPeer(t *testing.T) {
_, ok := IdentityFromContext(context.Background())
assert.False(t, ok, "bare context must report no identity (fail closed)")
}
func TestIdentityFromContext_WrongAuthInfo(t *testing.T) {
ctx := peer.NewContext(context.Background(), &peer.Peer{})
_, ok := IdentityFromContext(ctx)
assert.False(t, ok, "peer without our AuthInfo must report no identity")
}
func TestIdentityFromContext_Present(t *testing.T) {
want := Identity{UID: 1000, GID: 1000}
ctx := peer.NewContext(context.Background(), &peer.Peer{
AuthInfo: AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: want,
},
})
got, ok := IdentityFromContext(ctx)
assert.True(t, ok)
assert.Equal(t, want, got)
}
func TestIdentity_IsPrivileged(t *testing.T) {
// Unix
assert.True(t, Identity{UID: 0}.IsPrivileged(), "root is privileged")
assert.False(t, Identity{UID: 1000}.IsPrivileged(), "non-root is not privileged")
// Windows
assert.True(t, Identity{SID: "S-1-5-21-1-2-3-1001", Elevated: true}.IsPrivileged(), "elevated admin is privileged")
assert.True(t, Identity{SID: "S-1-5-18"}.IsPrivileged(), "LocalSystem is privileged")
assert.False(t, Identity{SID: "S-1-5-21-1-2-3-1001"}.IsPrivileged(), "non-elevated admin is NOT privileged")
}
func TestIdentity_IsWindows(t *testing.T) {
assert.True(t, Identity{SID: "S-1-5-18"}.IsWindows())
assert.False(t, Identity{UID: 0}.IsWindows())
}

View File

@@ -0,0 +1,152 @@
package ipcauth
import (
"context"
"os"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// Interceptor enforces per-RPC authorization on the daemon IPC, keyed to
// the caller's kernel-authenticated identity. It is safe-by-default:
// any RPC without a matching bypass is gated by the active profile's ownership,
// and a caller without a readable identity is denied.
type Interceptor struct {
policy ProfilePolicy
resolver GroupResolver
// selfUID is the daemon's own effective UID. -1 on Windows.
selfUID int
}
// NewInterceptor builds an interceptor over the given policy and group resolver.
func NewInterceptor(policy ProfilePolicy, resolver GroupResolver) *Interceptor {
return &Interceptor{policy: policy, resolver: resolver, selfUID: os.Geteuid()}
}
// UnaryServerInterceptor authorizes each unary RPC before the handler runs.
func (i *Interceptor) UnaryServerInterceptor() grpc.UnaryServerInterceptor {
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
if err := i.authorize(ctx, info.FullMethod); err != nil {
return nil, err
}
return handler(ctx, req)
}
}
// StreamServerInterceptor authorizes each streaming RPC before the handler runs.
func (i *Interceptor) StreamServerInterceptor() grpc.StreamServerInterceptor {
return func(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
if err := i.authorize(ss.Context(), info.FullMethod); err != nil {
return err
}
return handler(srv, ss)
}
}
// authorize decides each RPC from the caller's identity, first match wins:
//
// 0. no identity DENY
// 1. self / privileged / forwarded ALLOW (root, elevated, daemon-self, JSON gateway)
// 2. in ownersAuthorizedMethods owner tier (gate on DaemonOwnership)
// 3. in handlerAuthorizedMethods profile tier (bypass, handler decides)
// 4. everything else default tier (gate on ActiveProfileOwnership)
func (i *Interceptor) authorize(ctx context.Context, fullMethod string) error {
id, ok := IdentityFromContext(ctx)
if !ok {
log.Warnf("ipc authz: DENY %s. caller identity unavailable", fullMethod)
return status.Error(codes.PermissionDenied, "caller identity could not be verified on the daemon control channel")
}
if i.isSelfOrPrivileged(id) {
// The local JSON gateway connects as the daemon itself (self/privileged)
// and forwards the real HTTP client's identity. Trust it here, where
// the transport peer is already the daemon, then authorize as the
// forwarded client. A direct non-privileged caller never reaches this
// branch, so it cannot forge the forwarding metadata.
fwd, hasFwd := forwardedIdentity(ctx)
if !hasFwd {
i.auditAllow(id, fullMethod)
return nil
}
log.Infof("ipc authz: gateway-forwarded identity %s", fwd)
id = fwd
if i.isSelfOrPrivileged(id) {
i.auditAllow(id, fullMethod)
return nil
}
}
// Owner tier: daemon-level RPCs (Add, Down, Status) require a daemon-wide owner.
if ownersAuthorizedMethods[fullMethod] {
allowed, err := i.authorizeOwnership(id, i.policy.DaemonOwnership, i.policy.ClaimDaemonOwnerIfUnowned)
if err != nil {
log.Errorf("ipc authz: claim daemon owner for %s: %v", id, err)
return status.Error(codes.Internal, "failed to claim daemon ownership")
}
if allowed {
i.auditAllow(id, fullMethod)
return nil
}
log.Warnf("ipc authz: DENY %s for %s. not a daemon owner", fullMethod, id)
return status.Errorf(codes.PermissionDenied,
"not authorized (caller %s is not a daemon owner). ask an owner or run as root/administrator", id)
}
// Profile tier: the handler self-authorizes against the target profile.
if handlerAuthorizedMethods[fullMethod] {
i.auditAllow(id, fullMethod)
return nil
}
// Default: gated on the active profile's ownership.
allowed, err := i.authorizeOwnership(id, i.policy.ActiveProfileOwnership, i.policy.ClaimActiveProfileOwnerIfUnowned)
if err != nil {
log.Errorf("ipc authz: claim active profile for %s: %v", id, err)
return status.Error(codes.Internal, "failed to claim profile ownership")
}
if allowed {
i.auditAllow(id, fullMethod)
return nil
}
log.Warnf("ipc authz: DENY %s for %s. active profile owned by another principal", fullMethod, id)
return status.Errorf(codes.PermissionDenied,
"not authorized to control the active profile (caller %s). ask an owner or run as root/administrator", id)
}
// authorizeOwnership authorizes id against an ownership set, claiming it via
// trust-on-first-use when it is unowned and unshared. The claim is atomic, so on
// a lost race it re-reads and authorizes normally.
func (i *Interceptor) authorizeOwnership(id Identity, get func() Ownership, claim func(Identity) (bool, error)) (bool, error) {
o := get()
if len(o.Owners) == 0 && !o.Shared {
claimed, err := claim(id)
if err != nil {
return false, err
}
if claimed {
return true, nil
}
o = get()
}
return Authorize(o, id, i.resolver), nil
}
// isSelfOrPrivileged reports whether the caller is the platform administrator
// (root / elevated-admin / LocalSystem) or the daemon's own user.
func (i *Interceptor) isSelfOrPrivileged(id Identity) bool {
if id.IsPrivileged() {
return true
}
// Daemon-self: only meaningful on Unix (Windows privilege is covered above).
return !id.IsWindows() && i.selfUID >= 0 && int(id.UID) == i.selfUID
}
func (i *Interceptor) auditAllow(id Identity, fullMethod string) {
if auditMethods[fullMethod] {
log.Infof("ipc authz: allow %s for %s", fullMethod, id)
}
}

View File

@@ -0,0 +1,176 @@
package ipcauth
import (
"context"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/peer"
"google.golang.org/grpc/status"
)
type mockPolicy struct {
o Ownership // active profile ownership
daemon Ownership // daemon-wide ownership
claimed bool
daemonClaimed bool
}
func (m *mockPolicy) ActiveProfileOwnership() Ownership { return m.o }
// ClaimActiveProfileOwnerIfUnowned records a claim and marks the profile owned.
func (m *mockPolicy) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
if len(m.o.Owners) == 0 && !m.o.Shared {
m.o.Owners = []string{OwnerPrincipalForIdentity(id)}
m.claimed = true
return true, nil
}
return false, nil
}
func (m *mockPolicy) DaemonOwnership() Ownership { return m.daemon }
// ClaimDaemonOwnerIfUnowned records a daemon claim and marks the daemon owned.
func (m *mockPolicy) ClaimDaemonOwnerIfUnowned(id Identity) (bool, error) {
if len(m.daemon.Owners) == 0 && !m.daemon.Shared {
m.daemon.Owners = []string{OwnerPrincipalForIdentity(id)}
m.daemonClaimed = true
return true, nil
}
return false, nil
}
type mockResolver struct {
gids map[uint32]struct{}
names map[string]uint32
}
func (m mockResolver) CallerGIDs(Identity) map[uint32]struct{} { return m.gids }
func (m mockResolver) GroupNameGID(n string) (uint32, bool) { g, ok := m.names[n]; return g, ok }
func ctxWith(id Identity) context.Context {
return peer.NewContext(context.Background(), &peer.Peer{AuthInfo: AuthInfo{Identity: id}})
}
const (
up = servicePath + "Up"
list = servicePath + "ListProfiles"
unkwn = servicePath + "SomeFutureMethod"
down = servicePath + "Down"
statusm = servicePath + "Status"
addp = servicePath + "AddProfile"
switchp = servicePath + "SwitchProfile"
addowner = servicePath + "AddOwner"
sharep = servicePath + "ShareProfile"
)
func TestInterceptorAuthorize(t *testing.T) {
const selfUID = 4000
tests := []struct {
name string
own Ownership // active profile ownership
daemon Ownership // daemon-wide ownership
resolver GroupResolver
ctx context.Context
method string
wantErr bool
}{
// Default gate (active profile ownership).
{"no identity denies", Ownership{}, Ownership{}, nil, context.Background(), up, true},
{"root allowed", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: 0}), up, false},
{"daemon-self allowed", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: selfUID}), up, false},
{"shared allows any", Ownership{Shared: true}, Ownership{}, nil, ctxWith(Identity{UID: 1234}), up, false},
{"uid owner allowed", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 1000}), up, false},
{"non-owner denied", Ownership{Owners: []string{"uid:1000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), up, true},
{"unknown method gated", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), unkwn, true},
{"primary gid owner", Ownership{Owners: []string{"gid:5000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000, GID: 5000}), up, false},
{"group-name owner via resolver", Ownership{Owners: []string{"group:admins"}}, Ownership{},
mockResolver{names: map[string]uint32{"admins": 5000}, gids: map[uint32]struct{}{5000: {}}},
ctxWith(Identity{UID: 2000, GID: 42}), up, false},
{"windows sid owner", Ownership{Owners: []string{"sid:S-1-5-21-9"}}, Ownership{}, nil,
ctxWith(Identity{SID: "S-1-5-21-9"}), up, false},
{"windows group-sid owner", Ownership{Owners: []string{"sid:S-1-5-32-544"}}, Ownership{}, nil,
ctxWith(Identity{SID: "S-1-5-21-1", Groups: []string{"S-1-5-32-544"}}), up, false},
{"windows elevated privileged", Ownership{}, Ownership{}, nil,
ctxWith(Identity{SID: "S-1-5-21-1", Elevated: true}), up, false},
// Profile tier (handler self-authorizes, bypass).
{"list bypasses gate", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), list, false},
{"switch-profile bypasses gate", Ownership{Owners: []string{"uid:1000"}}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), switchp, false},
// Owner tier (daemon-wide ownership), independent of the active profile.
{"down by daemon owner allowed", Ownership{Owners: []string{"uid:9"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), down, false},
{"down by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), down, true},
{"status by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), statusm, true},
{"add by daemon owner allowed", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), addp, false},
{"add by non-owner denied", Ownership{}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), addp, true},
{"owner-tier TOFU claims unowned daemon", Ownership{}, Ownership{}, nil, ctxWith(Identity{UID: 2000}), down, false},
// Owner-set mutations gate on daemon ownership, not the active profile.
{"add-owner by daemon owner allowed", Ownership{Owners: []string{"uid:9"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 1000}), addowner, false},
{"add-owner by active-profile owner (non daemon owner) denied", Ownership{Owners: []string{"uid:2000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), addowner, true},
{"share by active-profile owner (non daemon owner) denied", Ownership{Owners: []string{"uid:2000"}}, Ownership{Owners: []string{"uid:1000"}}, nil, ctxWith(Identity{UID: 2000}), sharep, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
i := &Interceptor{policy: &mockPolicy{o: tt.own, daemon: tt.daemon}, resolver: tt.resolver, selfUID: selfUID}
err := i.authorize(tt.ctx, tt.method)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, codes.PermissionDenied, status.Code(err))
} else {
assert.NoError(t, err)
}
})
}
}
// TestInterceptorForwardedIdentity verifies the JSON-gateway trust model: a
// self/privileged transport peer (the loopback gateway) may forward a real
// client identity, but a non-privileged caller cannot forge it.
func TestInterceptorForwardedIdentity(t *testing.T) {
const selfUID = 4000
owners := Ownership{Owners: []string{"uid:1000"}}
withFwd := func(peerUID, fwdUID uint32) context.Context {
ctx := ctxWith(Identity{UID: peerUID})
return metadata.NewIncomingContext(ctx, metadata.Pairs(mdFwdUID, itoa(fwdUID)))
}
// Gateway forwards a non-owner client: denied as that client.
i := &Interceptor{policy: &mockPolicy{o: owners}, selfUID: selfUID}
assert.Error(t, i.authorize(withFwd(selfUID, 2000), up))
// Gateway forwards the owner: allowed.
assert.NoError(t, i.authorize(withFwd(selfUID, 1000), up))
// A non-privileged direct caller's forwarded metadata: denied
assert.Error(t, i.authorize(withFwd(2000, 1000), up))
}
func itoa(u uint32) string {
return strconv.FormatUint(uint64(u), 10)
}
// TestInterceptorTOFU verifies an unowned, non-shared profile is claimed by the
// first non-privileged caller, and a different caller is then denied.
func TestInterceptorTOFU(t *testing.T) {
policy := &mockPolicy{o: Ownership{}} // unowned
i := &Interceptor{policy: policy, resolver: nil, selfUID: 4000}
// First caller (uid 1000) claims via TOFU.
err := i.authorize(ctxWith(Identity{UID: 1000}), up)
assert.NoError(t, err)
assert.True(t, policy.claimed, "first caller should claim ownership")
assert.Equal(t, []string{"uid:1000"}, policy.o.Owners)
// A different caller is now denied (profile owned by uid 1000).
err = i.authorize(ctxWith(Identity{UID: 2000}), up)
assert.Error(t, err)
assert.Equal(t, codes.PermissionDenied, status.Code(err))
}

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

@@ -10,15 +10,13 @@ import (
)
// 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)
// other end of a Unix socket connection via LOCAL_PEERCRED (xucred). xucred
// carries the uid and primary group.
func PeerIdentity(c net.Conn) (Identity, error) {
uc, ok := c.(*net.UnixConn)
if !ok {
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
}
raw, err := uc.SyscallConn()
if err != nil {
return Identity{}, fmt.Errorf("raw conn: %w", err)
@@ -29,13 +27,14 @@ func PeerIdentity(conn net.Conn) (Identity, 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)
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
}
if credErr != nil {
return Identity{}, fmt.Errorf("read LOCAL_PEERCRED: %w", credErr)
return Identity{}, fmt.Errorf("LOCAL_PEERCRED: %w", credErr)
}
id := Identity{UID: cred.Uid}
// Groups[0] is the effective (primary) GID; guard against an empty list.
if cred.Ngroups > 0 {
id.GID = cred.Groups[0]
}

View File

@@ -10,15 +10,14 @@ import (
)
// 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)
// other end of a Unix socket connection via SO_PEERCRED. The credentials are
// captured by the kernel at connect() time and cannot be spoofed or changed for
// the life of the connection.
func PeerIdentity(c net.Conn) (Identity, error) {
uc, ok := c.(*net.UnixConn)
if !ok {
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", conn)
return Identity{}, fmt.Errorf("connection is not a unix socket: %T", c)
}
raw, err := uc.SyscallConn()
if err != nil {
return Identity{}, fmt.Errorf("raw conn: %w", err)
@@ -29,11 +28,14 @@ func PeerIdentity(conn net.Conn) (Identity, 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)
return Identity{}, fmt.Errorf("getsockopt control: %w", err)
}
if credErr != nil {
return Identity{}, fmt.Errorf("read SO_PEERCRED: %w", credErr)
return Identity{}, fmt.Errorf("SO_PEERCRED: %w", credErr)
}
return Identity{UID: cred.Uid, GID: cred.Gid, PID: cred.Pid}, nil
return Identity{
UID: cred.Uid,
GID: cred.Gid,
}, nil
}

View File

@@ -0,0 +1,16 @@
//go:build !linux && !darwin && !freebsd
package ipcauth
import (
"fmt"
"net"
"runtime"
)
// PeerIdentity is unimplemented on platforms without a Unix-socket peer-credential
// primitive. Windows derives identity from the named-pipe client token instead
// (see the Windows transport credentials), so it never calls this.
func PeerIdentity(net.Conn) (Identity, error) {
return Identity{}, fmt.Errorf("peer credential check not supported on %s", runtime.GOOS)
}

View File

@@ -0,0 +1,114 @@
//go:build linux || darwin || freebsd
package ipcauth
import (
"context"
"net"
"os"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/health"
healthpb "google.golang.org/grpc/health/grpc_health_v1"
)
// TestPeerIdentity_MatchesCurrentProcess connects to a real Unix socket and
// verifies the extracted UID/GID match the running process (both ends are us).
func TestPeerIdentity_MatchesCurrentProcess(t *testing.T) {
sock := filepath.Join(t.TempDir(), "peer.sock")
ln, err := net.Listen("unix", sock)
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
type result struct {
id Identity
err error
}
done := make(chan result, 1)
go func() {
c, aerr := ln.Accept()
if aerr != nil {
done <- result{err: aerr}
return
}
defer func() { _ = c.Close() }()
id, ierr := PeerIdentity(c)
done <- result{id: id, err: ierr}
}()
client, err := net.Dial("unix", sock)
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
res := <-done
require.NoError(t, res.err)
assert.Equal(t, uint32(os.Getuid()), res.id.UID, "UID should match current process")
assert.Equal(t, uint32(os.Getgid()), res.id.GID, "primary GID should match current process")
}
// TestPeerIdentity_NonUnixConn rejects non-Unix connections (fail closed).
func TestPeerIdentity_NonUnixConn(t *testing.T) {
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
done := make(chan error, 1)
go func() {
c, aerr := ln.Accept()
if aerr != nil {
done <- aerr
return
}
defer func() { _ = c.Close() }()
_, ierr := PeerIdentity(c)
done <- ierr
}()
client, err := net.Dial("tcp", ln.Addr().String())
require.NoError(t, err)
t.Cleanup(func() { _ = client.Close() })
assert.Error(t, <-done, "PeerIdentity must reject a non-Unix connection")
}
// TestGRPCRoundTrip_ServerCredsClientInsecure proves the transport contract end
// to end: a gRPC server using the peercred transport credentials still serves a
// plain insecure client (the CLI never changed), and the caller's kernel
// identity reaches the handler via IdentityFromContext.
func TestGRPCRoundTrip_ServerCredsClientInsecure(t *testing.T) {
sock := filepath.Join(t.TempDir(), "rt.sock")
ln, err := net.Listen("unix", sock)
require.NoError(t, err)
var gotUID uint32
var gotOK bool
srv := grpc.NewServer(
grpc.Creds(NewTransportCredentials()),
grpc.UnaryInterceptor(func(ctx context.Context, req any, _ *grpc.UnaryServerInfo, h grpc.UnaryHandler) (any, error) {
id, ok := IdentityFromContext(ctx)
gotUID, gotOK = id.UID, ok
return h(ctx, req)
}),
)
healthpb.RegisterHealthServer(srv, health.NewServer())
go func() { _ = srv.Serve(ln) }()
t.Cleanup(srv.Stop)
conn, err := grpc.NewClient("unix://"+sock, grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
t.Cleanup(func() { _ = conn.Close() })
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_, err = healthpb.NewHealthClient(conn).Check(ctx, &healthpb.HealthCheckRequest{})
require.NoError(t, err, "insecure client must reach the peercred server")
assert.True(t, gotOK, "handler must see a peer identity")
assert.Equal(t, uint32(os.Getuid()), gotUID, "handler must see the caller's UID")
}

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

@@ -0,0 +1,135 @@
package ipcauth
import "sync"
const servicePath = "/daemon.DaemonService/"
// ProfilePolicy exposes ownership to the interceptor. The daemon server
// implements it. ConfigAdapter bridges the gap because the gRPC server (and its
// interceptor) is constructed before the server instance exists.
type ProfilePolicy interface {
// ActiveProfileOwnership returns the active profile's ownership policy.
ActiveProfileOwnership() Ownership
// ClaimActiveProfileOwnerIfUnowned atomically claims the active profile for
// id when it has no owners and is not shared (trust-on-first-use), and
// reports whether id is now an owner. A false return means the profile was
// already owned or shared or another caller won the claim.
ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error)
// DaemonOwnership returns the daemon-wide ownership policy that governs the
// owner-tier RPCs and the default profile.
DaemonOwnership() Ownership
// ClaimDaemonOwnerIfUnowned atomically claims daemon-wide ownership for id
// when the daemon is unowned and not shared (trust-on-first-use).
ClaimDaemonOwnerIfUnowned(id Identity) (bool, error)
}
// ownersAuthorizedMethods gate on the daemon-wide owner set (or root): daemon-level
// ops independent of any profile. The owner-set mutations (AddOwner, ShareProfile,
// ResetOwner) must gate here, not on the active profile, else a per-profile owner
// could escalate via `owner add`. ResetOwner also requires root in its handler.
var ownersAuthorizedMethods = map[string]bool{
servicePath + "AddProfile": true,
servicePath + "Down": true,
servicePath + "Status": true,
servicePath + "AddOwner": true,
servicePath + "ShareProfile": true,
servicePath + "ResetOwner": true,
}
// handlerAuthorizedMethods bypass the ownership gate (identity still required)
// and let the handler authorize. GetActiveProfile bypasses only to return
// public metadata any local user may read.
var handlerAuthorizedMethods = map[string]bool{
servicePath + "ListProfiles": true,
servicePath + "RemoveProfile": true,
servicePath + "RenameProfile": true,
servicePath + "SwitchProfile": true,
servicePath + "GetActiveProfile": true,
}
// auditMethods are worth an audit log line. Denials are always logged.
var auditMethods = map[string]bool{
servicePath + "GetConfig": true,
servicePath + "SetConfig": true,
servicePath + "Login": true,
servicePath + "WaitSSOLogin": true,
servicePath + "RequestJWTAuth": true,
servicePath + "WaitJWTToken": true,
servicePath + "StartCapture": true,
servicePath + "StartBundleCapture": true,
servicePath + "DebugBundle": true,
servicePath + "ExposeService": true,
servicePath + "Up": true,
servicePath + "Down": true,
servicePath + "SelectNetworks": true,
servicePath + "DeselectNetworks": true,
servicePath + "SwitchProfile": true,
servicePath + "TriggerUpdate": true,
servicePath + "Logout": true,
servicePath + "CleanState": true,
servicePath + "DeleteState": true,
servicePath + "AddOwner": true,
servicePath + "ResetOwner": true,
servicePath + "ShareProfile": true,
}
// ConfigAdapter is a ProfilePolicy whose backend is set lazily, once the daemon
// server instance is created. Until then it reports an unowned profile
// (Ownership zero value), so non-privileged callers are denied.
type ConfigAdapter struct {
mu sync.RWMutex
backend ProfilePolicy
}
// SetBackend installs the real policy. Must be called before serving RPCs.
func (a *ConfigAdapter) SetBackend(backend ProfilePolicy) {
a.mu.Lock()
defer a.mu.Unlock()
a.backend = backend
}
// ActiveProfileOwnership delegates to the backend, or reports an unowned profile
// when no backend is set yet.
func (a *ConfigAdapter) ActiveProfileOwnership() Ownership {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return Ownership{}
}
return a.backend.ActiveProfileOwnership()
}
// ClaimActiveProfileOwnerIfUnowned delegates to the backend. Before the backend
// is set it cannot claim, so it reports not-owned (fail closed).
func (a *ConfigAdapter) ClaimActiveProfileOwnerIfUnowned(id Identity) (bool, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return false, nil
}
return a.backend.ClaimActiveProfileOwnerIfUnowned(id)
}
// DaemonOwnership delegates to the backend, reporting unowned when none is set.
func (a *ConfigAdapter) DaemonOwnership() Ownership {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return Ownership{}
}
return a.backend.DaemonOwnership()
}
// ClaimDaemonOwnerIfUnowned delegates to the backend. Before the backend is set
// it cannot claim, so it reports not-owned (fail closed).
func (a *ConfigAdapter) ClaimDaemonOwnerIfUnowned(id Identity) (bool, error) {
a.mu.RLock()
defer a.mu.RUnlock()
if a.backend == nil {
return false, nil
}
return a.backend.ClaimDaemonOwnerIfUnowned(id)
}

View File

@@ -0,0 +1,54 @@
package ipcauth
import (
"strconv"
"strings"
)
// PrincipalKind is the type of an owner principal.
type PrincipalKind string
const (
KindUID PrincipalKind = "uid" // Unix user ID
KindGID PrincipalKind = "gid" // Unix group ID
KindGroup PrincipalKind = "group" // Unix group name (NSS-resolved)
KindSID PrincipalKind = "sid" // Windows user or group SID
)
// Principal is a parsed owner entry from a profile's Owners list.
type Principal struct {
Kind PrincipalKind
Value string
}
// ParsePrincipal parses a "kind:value" owner string. Returns false for empty
// values or unknown kinds so malformed entries are ignored rather than trusted.
func ParsePrincipal(s string) (Principal, bool) {
kind, value, ok := strings.Cut(s, ":")
if !ok || value == "" {
return Principal{}, false
}
switch PrincipalKind(kind) {
case KindUID, KindGID, KindGroup, KindSID:
return Principal{Kind: PrincipalKind(kind), Value: value}, true
default:
return Principal{}, false
}
}
// UIDPrincipal builds the owner string for a Unix user ID.
func UIDPrincipal(uid uint32) string {
return string(KindUID) + ":" + strconv.FormatUint(uint64(uid), 10)
}
// SIDPrincipal builds the owner string for a Windows SID.
func SIDPrincipal(sid string) string { return string(KindSID) + ":" + sid }
// OwnerPrincipalForIdentity returns the self-ownership principal for an identity:
// the user's UID on Unix, or the user's SID on Windows.
func OwnerPrincipalForIdentity(id Identity) string {
if id.IsWindows() {
return SIDPrincipal(id.SID)
}
return UIDPrincipal(id.UID)
}

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

@@ -0,0 +1,71 @@
//go:build !windows
package ipcauth
import (
"strconv"
"sync"
"time"
"github.com/netbirdio/netbird/client/internal/shell"
)
const groupCacheTTL = 30 * time.Second
// NewDefaultGroupResolver returns an NSS-aware group resolver, owners resolve
// correctly for LDAP/AD users under CGO_ENABLED=0. Results are cached briefly.
func NewDefaultGroupResolver() GroupResolver {
return &nssResolver{byUID: make(map[uint32]gidCacheEntry)}
}
type gidCacheEntry struct {
gids map[uint32]struct{}
at time.Time
}
type nssResolver struct {
mu sync.Mutex
byUID map[uint32]gidCacheEntry
}
func (r *nssResolver) CallerGIDs(id Identity) map[uint32]struct{} {
r.mu.Lock()
defer r.mu.Unlock()
if e, ok := r.byUID[id.UID]; ok && time.Since(e.at) < groupCacheTTL {
return e.gids
}
gids := resolveGIDs(id.UID)
r.byUID[id.UID] = gidCacheEntry{gids: gids, at: time.Now()}
return gids
}
func resolveGIDs(uid uint32) map[uint32]struct{} {
out := make(map[uint32]struct{})
u, err := shell.GetUserFromGetent(strconv.FormatUint(uint64(uid), 10))
if err != nil {
return out
}
ids, err := shell.GroupIdsWithFallback(u)
if err != nil {
return out
}
for _, s := range ids {
if g, err := strconv.ParseUint(s, 10, 32); err == nil {
out[uint32(g)] = struct{}{}
}
}
return out
}
func (r *nssResolver) GroupNameGID(name string) (uint32, bool) {
g, err := shell.LookupGroupWithGetent(name)
if err != nil {
return 0, false
}
gid, err := strconv.ParseUint(g.Gid, 10, 32)
if err != nil {
return 0, false
}
return uint32(gid), true
}

View File

@@ -0,0 +1,10 @@
//go:build windows
package ipcauth
// NewDefaultGroupResolver returns nil on Windows: group authorization uses the
// group SIDs carried in the client token (see the Windows transport
// credentials).
func NewDefaultGroupResolver() GroupResolver {
return nil
}

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