Merge remote-tracking branch 'origin/main' into refactor/permissions-manager

This commit is contained in:
pascal
2026-09-11 13:55:02 +02:00
2126 changed files with 284567 additions and 42026 deletions
+365
View File
@@ -0,0 +1,365 @@
package server
import (
"context"
"io"
"os"
"sync"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util/capture"
)
const maxBundleCaptureDuration = 10 * time.Minute
// bundleCapture holds the state of an in-progress capture destined for the
// debug bundle. The lifecycle is:
//
// StartBundleCapture → capture running, writing to temp file
// StopBundleCapture → capture stopped, temp file available
// DebugBundle → temp file included in zip, then cleaned up
type bundleCapture struct {
mu sync.Mutex
sess *capture.Session
file *os.File
engine *internal.Engine
cancel context.CancelFunc
stopped bool
}
// stop halts the capture session and closes the pcap writer. Idempotent.
func (bc *bundleCapture) stop() {
bc.mu.Lock()
defer bc.mu.Unlock()
if bc.stopped {
return
}
bc.stopped = true
if bc.cancel != nil {
bc.cancel()
}
if bc.sess != nil {
bc.sess.Stop()
}
}
// path returns the temp file path, or "" if no file exists.
func (bc *bundleCapture) path() string {
if bc.file == nil {
return ""
}
return bc.file.Name()
}
// cleanup removes the temp file.
func (bc *bundleCapture) cleanup() {
if bc.file == nil {
return
}
name := bc.file.Name()
if err := bc.file.Close(); err != nil {
log.Debugf("close bundle capture file: %v", err)
}
if err := os.Remove(name); err != nil && !os.IsNotExist(err) {
log.Debugf("remove bundle capture file: %v", err)
}
bc.file = nil
}
// StartCapture streams a pcap or text packet capture over gRPC.
// Gated by the --enable-capture service flag.
func (s *Server) StartCapture(req *proto.StartCaptureRequest, stream proto.DaemonService_StartCaptureServer) error {
if !s.captureEnabled {
return status.Error(codes.PermissionDenied,
"packet capture is disabled; reinstall or reconfigure the service with --enable-capture")
}
if d := req.GetDuration(); d != nil && d.AsDuration() < 0 {
return status.Error(codes.InvalidArgument, "duration must not be negative")
}
matcher, err := parseCaptureFilter(req)
if err != nil {
return status.Errorf(codes.InvalidArgument, "invalid filter: %v", err)
}
pr, pw := io.Pipe()
opts := capture.Options{
Matcher: matcher,
SnapLen: req.GetSnapLen(),
Verbose: req.GetVerbose(),
ASCII: req.GetAscii(),
}
if req.GetTextOutput() {
opts.TextOutput = pw
} else {
opts.Output = pw
}
sess, err := capture.NewSession(opts)
if err != nil {
pw.Close()
return status.Errorf(codes.Internal, "create capture session: %v", err)
}
engine, err := s.claimCapture(sess)
if err != nil {
sess.Stop()
pw.Close()
return err
}
if err := engine.SetCapture(sess); err != nil {
s.releaseCapture(sess)
sess.Stop()
pw.Close()
return status.Errorf(codes.Internal, "set capture: %v", err)
}
// Send an empty initial message to signal that the capture was accepted.
// The client waits for this before printing the banner, so it must arrive
// before any packet data.
if err := stream.Send(&proto.CapturePacket{}); err != nil {
s.clearCaptureIfOwner(sess, engine)
sess.Stop()
pw.Close()
return status.Errorf(codes.Internal, "send initial message: %v", err)
}
ctx := stream.Context()
if d := req.GetDuration(); d != nil {
if dur := d.AsDuration(); dur > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, dur)
defer cancel()
}
}
go func() {
<-ctx.Done()
s.clearCaptureIfOwner(sess, engine)
sess.Stop()
pw.Close()
}()
defer pr.Close()
log.Infof("packet capture started (text=%v, expr=%q)", req.GetTextOutput(), req.GetFilterExpr())
defer func() {
stats := sess.Stats()
log.Infof("packet capture stopped: %d packets, %d bytes, %d dropped",
stats.Packets, stats.Bytes, stats.Dropped)
}()
return streamToGRPC(pr, stream)
}
func streamToGRPC(r io.Reader, stream proto.DaemonService_StartCaptureServer) error {
buf := make([]byte, 32*1024)
for {
n, readErr := r.Read(buf)
if n > 0 {
if err := stream.Send(&proto.CapturePacket{Data: buf[:n]}); err != nil {
log.Debugf("capture stream send: %v", err)
return nil //nolint:nilerr // client disconnected
}
}
if readErr != nil {
return nil //nolint:nilerr // pipe closed, capture stopped normally
}
}
}
// StartBundleCapture begins capturing packets to a server-side temp file for
// inclusion in the next debug bundle. Not gated by --enable-capture since the
// output stays on the server (same trust level as CPU profiling).
//
// A timeout auto-stops the capture as a safety net if StopBundleCapture is
// never called (e.g. CLI crash).
func (s *Server) StartBundleCapture(_ context.Context, req *proto.StartBundleCaptureRequest) (*proto.StartBundleCaptureResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.stopBundleCaptureLocked()
s.cleanupBundleCapture()
if s.activeCapture != nil {
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
}
engine, err := s.getCaptureEngineLocked()
if err != nil {
// Not fatal: kernel mode or not connected. Log and return success
// so the debug bundle still generates without capture data.
log.Warnf("packet capture unavailable, skipping: %v", err)
return &proto.StartBundleCaptureResponse{}, nil
}
timeout := req.GetTimeout().AsDuration()
if timeout <= 0 || timeout > maxBundleCaptureDuration {
timeout = maxBundleCaptureDuration
}
f, err := os.CreateTemp("", "netbird.capture.*.pcap")
if err != nil {
return nil, status.Errorf(codes.Internal, "create temp file: %v", err)
}
sess, err := capture.NewSession(capture.Options{Output: f})
if err != nil {
f.Close()
os.Remove(f.Name())
return nil, status.Errorf(codes.Internal, "create capture session: %v", err)
}
if err := engine.SetCapture(sess); err != nil {
sess.Stop()
f.Close()
os.Remove(f.Name())
log.Warnf("packet capture unavailable (no filtered device), skipping: %v", err)
return &proto.StartBundleCaptureResponse{}, nil
}
s.activeCapture = sess
ctx, cancel := context.WithTimeout(context.Background(), timeout)
bc := &bundleCapture{
sess: sess,
file: f,
engine: engine,
cancel: cancel,
}
s.bundleCapture = bc
go func() {
<-ctx.Done()
s.mutex.Lock()
if s.bundleCapture == bc {
s.stopBundleCaptureLocked()
} else {
bc.stop()
}
s.mutex.Unlock()
log.Infof("bundle capture auto-stopped after timeout")
}()
log.Infof("bundle capture started (timeout=%s, file=%s)", timeout, f.Name())
return &proto.StartBundleCaptureResponse{}, nil
}
// StopBundleCapture stops the running bundle capture. Idempotent.
func (s *Server) StopBundleCapture(_ context.Context, _ *proto.StopBundleCaptureRequest) (*proto.StopBundleCaptureResponse, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
s.stopBundleCaptureLocked()
return &proto.StopBundleCaptureResponse{}, nil
}
// stopBundleCaptureLocked stops the bundle capture if running. Must hold s.mutex.
func (s *Server) stopBundleCaptureLocked() {
if s.bundleCapture == nil {
return
}
bc := s.bundleCapture
if bc.engine != nil && s.activeCapture == bc.sess {
if err := bc.engine.SetCapture(nil); err != nil {
log.Debugf("clear bundle capture: %v", err)
}
s.activeCapture = nil
}
bc.stop()
stats := bc.sess.Stats()
log.Infof("bundle capture stopped: %d packets, %d bytes, %d dropped",
stats.Packets, stats.Bytes, stats.Dropped)
}
// bundleCapturePath returns the temp file path if a capture has been taken,
// stops any running capture, and returns "". Called from DebugBundle.
// Must hold s.mutex.
func (s *Server) bundleCapturePath() string {
if s.bundleCapture == nil {
return ""
}
s.bundleCapture.stop()
return s.bundleCapture.path()
}
// cleanupBundleCapture removes the temp file and clears state. Must hold s.mutex.
func (s *Server) cleanupBundleCapture() {
if s.bundleCapture == nil {
return
}
s.bundleCapture.cleanup()
s.bundleCapture = nil
}
// claimCapture reserves the engine's capture slot for sess. Returns
// FailedPrecondition if another capture is already active.
func (s *Server) claimCapture(sess *capture.Session) (*internal.Engine, error) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.activeCapture != nil {
return nil, status.Error(codes.FailedPrecondition, "another capture is already running")
}
engine, err := s.getCaptureEngineLocked()
if err != nil {
return nil, err
}
s.activeCapture = sess
return engine, nil
}
// releaseCapture clears the active-capture owner if it still matches sess.
func (s *Server) releaseCapture(sess *capture.Session) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.activeCapture == sess {
s.activeCapture = nil
}
}
// clearCaptureIfOwner clears engine's capture slot only if sess still owns it.
func (s *Server) clearCaptureIfOwner(sess *capture.Session, engine *internal.Engine) {
s.mutex.Lock()
defer s.mutex.Unlock()
if s.activeCapture != sess {
return
}
if err := engine.SetCapture(nil); err != nil {
log.Debugf("clear capture: %v", err)
}
s.activeCapture = nil
}
func (s *Server) getCaptureEngineLocked() (*internal.Engine, error) {
if s.connectClient == nil {
return nil, status.Error(codes.FailedPrecondition, "client not connected")
}
engine := s.connectClient.Engine()
if engine == nil {
return nil, status.Error(codes.FailedPrecondition, "engine not initialized")
}
return engine, nil
}
// parseCaptureFilter returns a Matcher from the request.
// Returns nil (match all) when no filter expression is set.
func parseCaptureFilter(req *proto.StartCaptureRequest) (capture.Matcher, error) {
expr := req.GetFilterExpr()
if expr == "" {
return nil, nil //nolint:nilnil // nil Matcher means "match all"
}
return capture.ParseFilter(expr)
}
+104 -15
View File
@@ -7,17 +7,63 @@ import (
"context"
"errors"
"fmt"
"path/filepath"
"runtime/pprof"
"strings"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/version"
)
// DebugBundle creates a debug bundle and returns the location.
func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
func (s *Server) DebugBundle(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure()); err != nil {
return nil, err
}
// The UI log is opened as whoever asked for this bundle, so a caller only
// collects a log it owns (privileged callers excepted). ok is false on a
// socket that carries no identity, which skips the UI log.
callerID, callerIdentified := ipcauth.CallerIdentity(callerCtx)
path, managementURL, err := s.generateDebugBundle(req, uiLogOpener(callerID, callerIdentified))
if err != nil {
return nil, err
}
if req.GetUploadURL() == "" {
return &proto.DebugBundleResponse{Path: path}, nil
}
// The upload runs without s.mutex held: it does network I/O to a possibly
// slow destination and must not block the other RPCs that take the lock. The
// bounded context is a backstop against a hung connection.
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
key, err := debug.UploadDebugBundle(uploadCtx, req.GetUploadURL(), managementURL, path, req.GetUploadInsecure())
if err != nil {
log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
}
log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key)
return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
}
// generateDebugBundle builds the bundle under s.mutex and returns its path plus
// the management URL captured under the lock, so the caller can run the upload
// without holding the lock.
func (s *Server) generateDebugBundle(req *proto.DebugBundleRequest, uiOpener debug.LogOpener) (path string, managementURL string, err error) {
s.mutex.Lock()
defer s.mutex.Unlock()
@@ -43,14 +89,19 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
}()
}
// Prepare refresh callback for health probes
capturePath := s.bundleCapturePath()
defer s.cleanupBundleCapture()
var refreshStatus func()
if s.connectClient != nil {
engine := s.connectClient.Engine()
if engine != nil {
refreshStatus = func() {
log.Debug("refreshing system health status for debug bundle")
engine.RunHealthProbes(true)
// Background ctx: the bundle wants a full, fresh probe regardless
// of the DebugBundle RPC client's lifetime. The engine's own ctx
// still aborts it on shutdown.
engine.RunHealthProbes(context.Background(), true)
}
}
}
@@ -61,34 +112,33 @@ func (s *Server) DebugBundle(_ context.Context, req *proto.DebugBundleRequest) (
StatusRecorder: s.statusRecorder,
SyncResponse: syncResponse,
LogPath: s.logFile,
UILogPath: s.uiLogPath,
UILogOpener: uiOpener,
CPUProfile: cpuProfileData,
CapturePath: capturePath,
RefreshStatus: refreshStatus,
ClientMetrics: clientMetrics,
DaemonVersion: version.NetbirdVersion(),
CliVersion: req.CliVersion,
},
debug.BundleConfig{
Anonymize: req.GetAnonymize(),
AnonymizeLevel: anonymize.ParseLevel(req.GetAnonymizeLevel()),
IncludeSystemInfo: req.GetSystemInfo(),
LogFileCount: req.GetLogFileCount(),
},
)
path, err := bundleGenerator.Generate()
path, err = bundleGenerator.Generate()
if err != nil {
return nil, fmt.Errorf("generate debug bundle: %w", err)
return "", "", fmt.Errorf("generate debug bundle: %w", err)
}
if req.GetUploadURL() == "" {
return &proto.DebugBundleResponse{Path: path}, nil
}
key, err := debug.UploadDebugBundle(context.Background(), req.GetUploadURL(), s.config.ManagementURL.String(), path)
if err != nil {
log.Errorf("failed to upload debug bundle to %s: %v", req.GetUploadURL(), err)
return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
if s.config != nil && s.config.ManagementURL != nil {
managementURL = s.config.ManagementURL.String()
}
log.Infof("debug bundle uploaded to %s with key %s", req.GetUploadURL(), key)
return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
return path, managementURL, nil
}
// GetLogLevel gets the current logging level for the server.
@@ -118,9 +168,48 @@ func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (
log.Infof("Log level set to %s", level.String())
// Signal the desktop UI so it can attach/detach its gui-client.log. Rides
// the SubscribeEvents stream as a marked event (see publishLogLevelChanged).
s.publishLogLevelChanged(level.String())
return &proto.SetLogLevelResponse{}, nil
}
// RegisterUILog records the desktop UI's absolute log path so DebugBundle can
// collect the GUI log. The daemon runs as root and can't resolve the user's
// config dir, so the UI reports it. Last-writer-wins (one UI per socket).
//
// The path arrives over an IPC any local user can reach and is later opened by
// a root daemon, so it is constrained to the file name the UI writes and to a
// local absolute path. Authorization happens when DebugBundle opens it: the
// bundle refuses a file its requester does not own. A caller the daemon cannot
// identify cannot register a path at all.
func (s *Server) RegisterUILog(callerCtx context.Context, req *proto.RegisterUILogRequest) (*proto.RegisterUILogResponse, error) {
if _, ok := ipcauth.CallerIdentity(callerCtx); !ok {
return nil, gstatus.Error(codes.PermissionDenied,
"registering a UI log path requires a control channel that carries the caller's identity")
}
path := filepath.Clean(req.GetPath())
if !filepath.IsAbs(path) || filepath.Base(path) != uiLogFileName {
return nil, gstatus.Errorf(codes.InvalidArgument, "UI log path must be an absolute path ending in %s", uiLogFileName)
}
// filepath.IsAbs accepts a Windows UNC path (\\host\share\...) and a device
// path (\\.\, \\?\); opening one would make the root daemon reach a remote
// or device namespace. Require a plain local path.
if strings.HasPrefix(path, `\\`) {
return nil, gstatus.Error(codes.InvalidArgument, "UI log path must be a local path, not a UNC or device path")
}
s.mutex.Lock()
defer s.mutex.Unlock()
s.uiLogPath = path
log.Infof("registered UI log path %s", s.uiLogPath)
return &proto.RegisterUILogResponse{}, nil
}
// SetSyncResponsePersistence sets the sync response persistence for the server.
func (s *Server) SetSyncResponsePersistence(_ context.Context, req *proto.SetSyncResponsePersistenceRequest) (*proto.SetSyncResponsePersistenceResponse, error) {
s.mutex.Lock()
+99
View File
@@ -0,0 +1,99 @@
//go:build !android && !ios
package server
import (
"context"
"fmt"
"net/url"
"os"
"strings"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/configs"
"github.com/netbirdio/netbird/client/internal/debug"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/upload-server/types"
)
// uiLogFileName is the only file name the daemon accepts as a UI log path. The
// UI (writer), this validation, and the bundle collector all read it from
// configs so they cannot drift.
const uiLogFileName = configs.UILogFile
// uiLogOpener opens the registered UI log, and its rotated siblings, on behalf
// of the caller requesting the bundle: OpenOwnedFile then collects the log only
// when that caller owns it (or is privileged). identified is false on a socket
// that carries no caller identity, in which case nothing is opened.
func uiLogOpener(id ipcauth.Identity, identified bool) debug.LogOpener {
return func(path string) (*os.File, error) {
if !identified {
return nil, fmt.Errorf("bundle requester has no verified identity")
}
return ipcauth.OpenOwnedFile(id, path)
}
}
// requirePrivilegeForUploadURL restricts where the daemon may send a debug
// bundle. The bundle holds the daemon's own logs and state, and the daemon
// fetches the upload URL itself, so an unrestricted endpoint turns the daemon
// into both an exfiltration channel and a request forwarder that reaches
// services only it can talk to.
//
// The upload service NetBird publishes is open to any caller, since that is what
// the CLI and the desktop UI use. Any other endpoint, self-hosted upload servers
// included, requires a privileged caller. Plaintext is refused for everyone: the
// daemon fetches the URL and then PUTs the bundle to whatever that fetch returns,
// so an http hop is a place to intercept the bundle or the redirect.
//
// insecure relaxes transport security (http, or an untrusted TLS certificate)
// for a self-hosted server. It weakens a root-privileged upload, so it is
// refused for an unprivileged caller regardless of the host.
func requirePrivilegeForUploadURL(ctx context.Context, rawURL string, insecure bool) error {
if rawURL == "" {
return nil
}
parsed, err := url.Parse(rawURL)
if err != nil {
return gstatus.Errorf(codes.InvalidArgument, "parse upload URL: %v", err)
}
// --insecure relaxes https to http or an untrusted certificate; it does not
// widen the URL to arbitrary schemes, so a host and http/https are required
// before the insecure branch takes over.
if parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http") {
return gstatus.Errorf(codes.InvalidArgument, "upload URL must be http or https with a host")
}
if insecure {
return denyPrivileged(ctx,
"uploading a debug bundle without transport security (--upload-bundle-insecure)",
ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-insecure --upload-bundle-url <url>"))
}
if parsed.Scheme != "https" {
return gstatus.Errorf(codes.InvalidArgument, "upload URL must use https, got scheme %q", parsed.Scheme)
}
if isDefaultUploadService(parsed) {
return nil
}
return denyPrivileged(ctx,
"uploading a debug bundle to an upload service other than the default one",
ipcauth.ElevatedCommand("netbird debug bundle -U --upload-bundle-url <url>"))
}
// isDefaultUploadService reports whether the URL points at the upload service
// NetBird runs. Only the host is compared: the service's path may differ between
// releases, and the host is what decides who receives the bundle.
func isDefaultUploadService(parsed *url.URL) bool {
defaultURL, err := url.Parse(types.DefaultBundleURL)
if err != nil {
return false
}
return parsed.Scheme == defaultURL.Scheme && strings.EqualFold(parsed.Host, defaultURL.Host)
}
+157
View File
@@ -0,0 +1,157 @@
//go:build !android && !ios
package server
import (
"os"
"path/filepath"
"runtime"
"testing"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/upload-server/types"
)
func TestRegisterUILogRefusesUnidentifiedCaller(t *testing.T) {
s := &Server{}
_, err := s.RegisterUILog(noIdentityCtx(), &proto.RegisterUILogRequest{
Path: filepath.Join(t.TempDir(), uiLogFileName),
})
if gstatus.Code(err) != codes.PermissionDenied {
t.Fatalf("code = %v, want PermissionDenied", gstatus.Code(err))
}
}
func TestRegisterUILogRefusesForeignPath(t *testing.T) {
secret := "/etc/shadow"
if runtime.GOOS == "windows" {
secret = `C:\Windows\System32\config\SAM`
}
tests := []struct {
name string
path string
}{
{"empty", ""},
{"relative", filepath.Join("netbird", uiLogFileName)},
{"another file", secret},
{"directory of the log", t.TempDir()},
{"unc path", `\\attacker\share\` + uiLogFileName},
{"device path", `\\.\C:\` + uiLogFileName},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s := &Server{}
_, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: tc.path})
if gstatus.Code(err) != codes.InvalidArgument {
t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
}
if s.uiLogPath != "" {
t.Fatalf("path %q was recorded despite the refusal", s.uiLogPath)
}
})
}
}
func TestRegisterUILogRecordsPath(t *testing.T) {
s := &Server{}
path := filepath.Join(t.TempDir(), uiLogFileName)
if _, err := s.RegisterUILog(userCtx(), &proto.RegisterUILogRequest{Path: path}); err != nil {
t.Fatalf("register: %v", err)
}
if s.uiLogPath != path {
t.Fatalf("path = %q, want %q", s.uiLogPath, path)
}
}
// The UI log is opened as the bundle requester, so a second local user cannot
// collect a log they do not own, and an unidentified requester collects nothing.
func TestUILogOpenerBindsToRequester(t *testing.T) {
path := filepath.Join(t.TempDir(), uiLogFileName)
if err := os.WriteFile(path, []byte("log line"), 0600); err != nil {
t.Fatalf("write log: %v", err)
}
// A different unprivileged user than the file's owner: refused.
if _, err := uiLogOpener(unprivilegedIdentity(), true)(path); err == nil {
t.Fatal("expected a file the requester does not own to be refused")
}
// No verified identity: refused.
if _, err := uiLogOpener(ipcauth.Identity{}, false)(path); err == nil {
t.Fatal("expected an unidentified requester to be refused")
}
// The requester that owns the file: allowed. The test process created it, so
// its own identity is the owner (and a privileged runner is exempt anyway).
owner, err := ipcauth.CurrentProcessIdentity()
if err != nil {
t.Fatalf("current identity: %v", err)
}
f, err := uiLogOpener(owner, true)(path)
if err != nil {
t.Fatalf("expected the owning requester to be allowed, got %v", err)
}
_ = f.Close()
}
func TestRequirePrivilegeForUploadURL(t *testing.T) {
tests := []struct {
name string
url string
insecure bool
unprivOK bool
invalid bool
rootAlso bool
}{
{name: "no upload", url: "", unprivOK: true},
{name: "default service", url: types.DefaultBundleURL, unprivOK: true},
{name: "default service, other path", url: "https://upload.debug.netbird.io/other", unprivOK: true},
{name: "loopback exfiltration endpoint", url: "https://127.0.0.1:8080/upload-url", rootAlso: true},
{name: "custom upload service", url: "https://attacker.example/upload-url", rootAlso: true},
{name: "plaintext default host", url: "http://upload.debug.netbird.io/upload-url", invalid: true},
{name: "plaintext custom host", url: "http://attacker.example/upload-url", invalid: true},
{name: "unsupported scheme", url: "file:///etc/shadow", invalid: true},
// insecure relaxes transport security; privileged only, whatever the host.
{name: "insecure http custom", url: "http://selfhosted.local/upload-url", insecure: true, rootAlso: true},
{name: "insecure https custom", url: "https://selfhosted.local/upload-url", insecure: true, rootAlso: true},
{name: "insecure default host", url: types.DefaultBundleURL, insecure: true, rootAlso: true},
// --insecure must not widen the URL to non-http(s) schemes or a hostless URL.
{name: "insecure file scheme", url: "file:///etc/shadow", insecure: true, invalid: true},
{name: "insecure hostless", url: "https:///upload-url", insecure: true, invalid: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
err := requirePrivilegeForUploadURL(userCtx(), tc.url, tc.insecure)
switch {
case tc.invalid:
if gstatus.Code(err) != codes.InvalidArgument {
t.Fatalf("code = %v, want InvalidArgument", gstatus.Code(err))
}
return
case tc.unprivOK:
assertAllowed(t, err)
return
default:
assertDenied(t, err)
}
if tc.rootAlso {
assertAllowed(t, requirePrivilegeForUploadURL(rootCtx(), tc.url, tc.insecure))
}
})
}
}
+32
View File
@@ -1,7 +1,9 @@
package server
import (
"github.com/google/uuid"
log "github.com/sirupsen/logrus"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/netbirdio/netbird/client/proto"
)
@@ -16,6 +18,15 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
log.Debug("client subscribed to events")
s.startUpdateManagerForGUI()
// Replay the current log level to this subscriber so a freshly-connected UI
// learns it even when the daemon was already started with --log-level debug
// (the change-driven publishLogLevelChanged only fires on SetLogLevel). Sent
// directly on this stream rather than via PublishEvent so it reaches only
// the new subscriber, not every connected client.
if err := s.sendCurrentLogLevel(stream); err != nil {
return err
}
for {
select {
case event := <-subscription.Events():
@@ -28,3 +39,24 @@ func (s *Server) SubscribeEvents(req *proto.SubscribeRequest, stream proto.Daemo
}
}
}
// sendCurrentLogLevel sends a marked log-level-changed SystemEvent carrying the
// daemon's current level directly to one subscriber. Mirrors the shape
// publishLogLevelChanged emits so the UI's dispatchSystemEvent handles both the
// same way.
func (s *Server) sendCurrentLogLevel(stream proto.DaemonService_SubscribeEventsServer) error {
level := log.GetLevel().String()
event := &proto.SystemEvent{
Id: uuid.New().String(),
Severity: proto.SystemEvent_INFO,
Category: proto.SystemEvent_SYSTEM,
Message: "Log level changed",
Metadata: map[string]string{proto.MetadataKindKey: proto.MetadataKindLogLevelChanged, proto.MetadataLevelKey: level},
Timestamp: timestamppb.Now(),
}
if err := stream.Send(event); err != nil {
log.Warnf("error sending initial log level event: %v", err)
return err
}
return nil
}
+42
View File
@@ -0,0 +1,42 @@
package server
import (
"errors"
"fmt"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
)
func TestInnermostStatus(t *testing.T) {
t.Run("wrapped gRPC status", func(t *testing.T) {
inner := gstatus.Error(codes.PermissionDenied, "peer is already registered by a different User or a Setup Key")
// Mirror the daemon wrap chain: engine wraps with %w, mgm error is the inner status.
wrapped := fmt.Errorf("extend auth session on management: %w", inner)
st := innermostStatus(wrapped)
require.NotNil(t, st)
require.Equal(t, codes.PermissionDenied, st.Code())
require.Equal(t, "peer is already registered by a different User or a Setup Key", st.Message())
})
t.Run("deepest status wins over an outer one", func(t *testing.T) {
inner := gstatus.Error(codes.PermissionDenied, "deepest")
chain := fmt.Errorf("outer: %w", fmt.Errorf("mid: %w", inner))
st := innermostStatus(chain)
require.NotNil(t, st)
require.Equal(t, codes.PermissionDenied, st.Code())
require.Equal(t, "deepest", st.Message())
})
t.Run("no status in chain", func(t *testing.T) {
require.Nil(t, innermostStatus(errors.New("plain error")))
})
t.Run("nil error", func(t *testing.T) {
require.Nil(t, innermostStatus(nil))
})
}
+49 -4
View File
@@ -6,11 +6,21 @@ import (
"github.com/awnumar/memguard"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
type jwtCache struct {
mu sync.RWMutex
enclave *memguard.Enclave
mu sync.RWMutex
enclave *memguard.Enclave
owner *ipcauth.Identity
// generation counts the invalidations. A caller that starts an
// authentication takes the generation first and hands it back to store, so
// a token obtained under a session that ended while the IdP was being
// polled cannot land in the cache the new session is using.
generation uint64
expiresAt time.Time
timer *time.Timer
maxTokenSize int
@@ -22,10 +32,23 @@ func newJWTCache() *jwtCache {
}
}
func (c *jwtCache) store(token string, maxAge time.Duration) {
func (c *jwtCache) currentGeneration() uint64 {
c.mu.RLock()
defer c.mu.RUnlock()
return c.generation
}
// store keeps the token only while generation is still the current one, and
// reports whether it did. See the generation field.
func (c *jwtCache) store(token string, owner ipcauth.Identity, maxAge time.Duration, generation uint64) bool {
c.mu.Lock()
defer c.mu.Unlock()
if c.generation != generation {
return false
}
c.cleanup()
if c.timer != nil {
@@ -35,6 +58,7 @@ func (c *jwtCache) store(token string, maxAge time.Duration) {
tokenBytes := []byte(token)
c.enclave = memguard.NewEnclave(tokenBytes)
c.owner = &owner
c.expiresAt = time.Now().Add(maxAge)
var timer *time.Timer
@@ -49,9 +73,12 @@ func (c *jwtCache) store(token string, maxAge time.Duration) {
log.Debugf("JWT token cache expired after %v, securely wiped from memory", maxAge)
})
c.timer = timer
return true
}
func (c *jwtCache) get() (string, bool) {
// get returns the cached token to the identity that stored it.
func (c *jwtCache) get(caller ipcauth.Identity) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
@@ -59,6 +86,11 @@ func (c *jwtCache) get() (string, bool) {
return "", false
}
if c.owner == nil || !c.owner.SameUser(caller) {
log.Warnf("refusing the cached SSH JWT: caller %s is not the identity that obtained it", caller)
return "", false
}
buffer, err := c.enclave.Open()
if err != nil {
log.Debugf("Failed to open JWT token enclave: %v", err)
@@ -70,10 +102,23 @@ func (c *jwtCache) get() (string, bool) {
return token, true
}
func (c *jwtCache) clear() {
c.mu.Lock()
defer c.mu.Unlock()
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.cleanup()
c.generation++
}
// cleanup destroys the secure enclave, must be called with lock held
func (c *jwtCache) cleanup() {
if c.enclave != nil {
c.enclave = nil
}
c.owner = nil
c.expiresAt = time.Time{}
}
+176
View File
@@ -0,0 +1,176 @@
package server
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/ipcauth"
)
const testTTL = time.Minute
func unixCaller(uid uint32) ipcauth.Identity {
return ipcauth.Identity{UID: uid, GID: uid}
}
func windowsCaller(sid string) ipcauth.Identity {
return ipcauth.Identity{SID: sid}
}
func TestJWTCache_ServesTheOwner(t *testing.T) {
c := newJWTCache()
owner := unixCaller(1000)
c.store("token-for-1000", owner, testTTL, c.currentGeneration())
got, found := c.get(owner)
require.True(t, found, "the identity that stored the token must get it back")
assert.Equal(t, "token-for-1000", got)
}
// The disclosure this cache guards against: one local account collecting the
// SSH JWT another account's authentication put in the daemon-wide cache.
func TestJWTCache_RefusesAnotherLocalUser(t *testing.T) {
tests := []struct {
name string
owner ipcauth.Identity
caller ipcauth.Identity
}{
{"different uid", unixCaller(1000), unixCaller(65534)},
{"root is not the owner either", unixCaller(1000), unixCaller(0)},
{"different sid", windowsCaller("S-1-5-21-1-2-3-1001"), windowsCaller("S-1-5-21-1-2-3-1002")},
{"windows caller against a unix owner", unixCaller(0), windowsCaller("S-1-5-18")},
{"unix caller against a windows owner", windowsCaller("S-1-5-18"), unixCaller(0)},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := newJWTCache()
c.store("victim-token", tt.owner, testTTL, c.currentGeneration())
got, found := c.get(tt.caller)
assert.False(t, found, "a caller that is not the owner must get a miss")
assert.Empty(t, got)
})
}
}
func TestJWTCache_EmptyCacheMatchesNobody(t *testing.T) {
c := newJWTCache()
got, found := c.get(unixCaller(0))
assert.False(t, found)
assert.Empty(t, got)
}
// An entry with no recorded owner must match nobody, root included: an
// unidentified caller arrives as the zero Identity, which carries uid 0. This
// pins the nil-owner guard rather than the comparison, so it sets up an entry
// that exists and then drops its owner.
func TestJWTCache_UnownedEntryMatchesNobody(t *testing.T) {
c := newJWTCache()
c.store("token", unixCaller(1000), testTTL, c.currentGeneration())
c.owner = nil
got, found := c.get(unixCaller(0))
assert.False(t, found)
assert.Empty(t, got)
}
// The same user calling once elevated and once not is still the same user, so
// hiding their own token from them would be wrong.
func TestJWTCache_ElevationDoesNotChangeTheOwner(t *testing.T) {
c := newJWTCache()
sid := "S-1-5-21-1-2-3-1001"
owner := windowsCaller(sid)
owner.Elevated = true
c.store("token", owner, testTTL, c.currentGeneration())
got, found := c.get(windowsCaller(sid))
require.True(t, found)
assert.Equal(t, "token", got)
}
func TestJWTCache_Expiry(t *testing.T) {
c := newJWTCache()
owner := unixCaller(1000)
c.store("token", owner, testTTL, c.currentGeneration())
c.expiresAt = time.Now().Add(-time.Second)
_, found := c.get(owner)
assert.False(t, found)
}
// Logout and SwitchProfile call clear — Down deliberately does not: the NetBird
// session the token speaks for is over, so not even its owner may have it back.
func TestJWTCache_ClearDropsTheEntry(t *testing.T) {
c := newJWTCache()
owner := unixCaller(1000)
c.store("token", owner, testTTL, c.currentGeneration())
c.clear()
_, found := c.get(owner)
assert.False(t, found)
assert.Nil(t, c.owner, "clear must forget the owner too")
assert.Nil(t, c.timer, "clear must stop the expiry timer")
}
// WaitJWTToken polls the IdP unlocked, so a logout or a profile switch can
// clear the cache while a flow is still in the air. The token that flow returns
// belongs to the session that ended, so it must not land in the cache the new
// session is using.
func TestJWTCache_StoreFromAnEndedSessionIsDropped(t *testing.T) {
c := newJWTCache()
owner := unixCaller(1000)
// The generation a caller takes when its authentication starts.
generation := c.currentGeneration()
c.clear() // logout or profile switch, while the IdP is still being polled
stored := c.store("stale-token", owner, testTTL, generation)
assert.False(t, stored, "a token from an ended session must not be cached")
_, found := c.get(owner)
assert.False(t, found, "the cache must stay empty after the session ended")
}
// The same caller must still be able to store once it re-reads the generation, so
// the guard does not wedge the cache after any invalidation.
func TestJWTCache_StoreWorksAgainAfterClear(t *testing.T) {
c := newJWTCache()
owner := unixCaller(1000)
c.clear()
require.True(t, c.store("token", owner, testTTL, c.currentGeneration()))
got, found := c.get(owner)
require.True(t, found)
assert.Equal(t, "token", got)
}
func TestJWTCache_StoreReplacesThePreviousOwner(t *testing.T) {
c := newJWTCache()
first := unixCaller(1000)
second := unixCaller(1001)
c.store("first-token", first, testTTL, c.currentGeneration())
c.store("second-token", second, testTTL, c.currentGeneration())
_, found := c.get(first)
assert.False(t, found, "the previous owner must not reach the new token")
got, found := c.get(second)
require.True(t, found)
assert.Equal(t, "second-token", got)
}
+51
View File
@@ -0,0 +1,51 @@
package server
import (
"testing"
"time"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/proto"
)
// The daemon takes guardedConfigMu before s.mutex. authorizeAndPrepareLogin
// takes s.mutex while holding guardedConfigMu, so a SetConfig that grabbed
// s.mutex first and then waited for guardedConfigMu would deadlock the daemon
// against a concurrent login: two unprivileged IPC calls are enough.
//
// The held guardedConfigMu below stands in for that login. While SetConfig waits
// for it, s.mutex must stay free, otherwise the login waiting for s.mutex could
// never release guardedConfigMu.
func TestSetConfig_TakesGuardedConfigMuBeforeServerMutex(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
s.guardedConfigMu.Lock()
done := make(chan error, 1)
go func() {
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
})
done <- err
}()
require.Never(t, func() bool {
if !s.mutex.TryLock() {
return true
}
s.mutex.Unlock()
return false
}, 500*time.Millisecond, 10*time.Millisecond,
"SetConfig held s.mutex while waiting for guardedConfigMu, which deadlocks against a concurrent login")
s.guardedConfigMu.Unlock()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("SetConfig did not finish after guardedConfigMu was released")
}
}
+127
View File
@@ -0,0 +1,127 @@
package server
import (
"context"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
// A refused login must not leave the profile switched. Login can both switch
// profiles and carry the guarded config fields, so the gate has to run before the
// switch: otherwise a caller whose change is refused still gets the side effect of
// activating whichever profile the request named.
func TestLogin_RefusedChangeLeavesTheProfileAlone(t *testing.T) {
s, _, activeProfile, username, _ := setupServerWithProfile(t)
// Login reads process state off the daemon's root context.
s.rootCtx = internal.CtxInitState(context.Background())
// A second profile that runs the SSH server, which is what makes repointing
// its management binding a privileged change.
target := "ssh-enabled"
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
ManagementURL: "https://api.netbird.io:443",
ServerSSHAllowed: boolPtr(true),
})
require.NoError(t, err)
_, err = s.Login(userCtx(), &proto.LoginRequest{
ProfileName: &target,
Username: &username,
ManagementUrl: "https://mgmt.attacker.example:443",
})
require.Error(t, err, "an unprivileged caller must not move the management URL of an SSH-enabled profile")
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
active, err := s.profileManager.GetActiveProfileState()
require.NoError(t, err)
require.Equal(t, profilemanager.ID(activeProfile), active.ID,
"the refused login switched the active profile anyway")
}
// A caller whose change becomes privileged only after its first check must be
// refused without having cancelled a login or switched profiles: the first check is
// unsynchronized, so the SSH server can be enabled by a concurrent privileged
// request in between, and the authoritative check happens before any side effect.
func TestLogin_ChangeThatBecomesPrivilegedMidRequestHasNoSideEffects(t *testing.T) {
s, _, activeProfile, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
// The target profile has SSH off, so the first check lets the request through.
target := "ssh-later"
targetPath := filepath.Join(profilemanager.DefaultConfigPathDir, target+".json")
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: targetPath,
ManagementURL: "https://api.netbird.io:443",
ServerSSHAllowed: boolPtr(false),
})
require.NoError(t, err)
cancelled := false
s.actCancel = func() { cancelled = true }
// Stand in for a privileged SetConfig that enables the SSH server between the
// two checks, which is the interleaving the lock has to make safe.
afterLoginPreCheck = func() {
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: targetPath,
ServerSSHAllowed: boolPtr(true),
})
require.NoError(t, err)
}
t.Cleanup(func() { afterLoginPreCheck = nil })
_, err = s.Login(userCtx(), &proto.LoginRequest{
ProfileName: &target,
Username: &username,
ManagementUrl: "https://mgmt.attacker.example:443",
})
require.Error(t, err)
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
require.False(t, cancelled, "the refused login cancelled the login already in progress")
active, err := s.profileManager.GetActiveProfileState()
require.NoError(t, err)
require.Equal(t, profilemanager.ID(activeProfile), active.ID, "the refused login switched the active profile anyway")
stored, err := profilemanager.ReadConfig(targetPath)
require.NoError(t, err)
require.Equal(t, "https://api.netbird.io:443", stored.ManagementURL.String(), "the refused login moved the management URL")
}
// Login cancels whatever login is already in progress before starting its own. A
// refused caller must not get that far, otherwise anyone able to reach the socket
// can abort someone else's login by sending a request that is denied.
func TestLogin_RefusedChangeLeavesAnInProgressLoginAlone(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
target := "ssh-enabled"
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
ManagementURL: "https://api.netbird.io:443",
ServerSSHAllowed: boolPtr(true),
})
require.NoError(t, err)
cancelled := false
s.actCancel = func() { cancelled = true }
_, err = s.Login(userCtx(), &proto.LoginRequest{
ProfileName: &target,
Username: &username,
ManagementUrl: "https://mgmt.attacker.example:443",
})
require.Error(t, err)
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want a privilege refusal, got %v", err)
require.False(t, cancelled, "the refused login cancelled the login already in progress")
}
+110
View File
@@ -0,0 +1,110 @@
package server
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
// A login that never reached Management is not a decision about the peer's
// credentials, so it must come back as a retryable error rather than an SSO
// prompt: the user cannot finish a browser login while Management is down, and
// the CLI's own backoff resolves the outage on its own once the daemon reports
// the failure. Reproduces `netbird down; netbird up` printing a device-code URL
// because Management happened to be restarting when the daemon dialed it.
func TestLogin_ManagementUnreachableIsReturnedInsteadOfDemandingSSO(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
unreachable := errors.New("create connection: dial context: context deadline exceeded")
attempts := 0
s.isLoginRequiredFn = func(context.Context) (bool, error) {
attempts++
return false, unreachable
}
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
require.ErrorIs(t, err, unreachable, "the transport failure was replaced by something else")
require.Nil(t, resp, "a failed login must not answer with a login response")
require.Equal(t, 1, attempts)
require.Nil(t, s.oauthAuthFlow.flow, "the daemon started an SSO flow for a peer whose login was never decided")
status, err := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, err)
require.Equal(t, internal.StatusLoginFailed, status,
"a peer that could not reach Management is not waiting on a login")
}
// The counterpart: Management refusing the peer's credentials is a decision, and
// the SSO flow still has to start for it. The profile carries an unusable
// private key so the flow setup fails immediately instead of dialing, which is
// enough to show the branch was entered — the refusal itself is never what comes
// back out.
func TestLogin_AuthRefusalStartsSSOFlow(t *testing.T) {
s, _, _, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
breakProfilePrivateKey(t, cfgPath)
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return true, nil
}
_, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username})
require.Error(t, err)
status, stateErr := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, stateErr)
require.Equal(t, internal.StatusLoginFailed, status,
"the SSO flow setup was never reached with the broken key")
}
func TestLogin_SetupKeyStillRunsWhenPeerNeedsLogin(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
s.isLoginRequiredFn = func(context.Context) (bool, error) {
return true, nil
}
var keysTried []string
s.loginAttemptFn = func(_ context.Context, setupKey, _ string) (internal.StatusType, error) {
keysTried = append(keysTried, setupKey)
return "", nil
}
setupKey := "A2C8E32F-AEB2-4B45-8FD3-8A0C1B2D3E4F"
resp, err := s.Login(userCtx(), &proto.LoginRequest{Username: &username, SetupKey: setupKey})
require.NoError(t, err, "the probe's outcome leaked out as the login result")
require.NotNil(t, resp)
require.Equal(t, []string{setupKey}, keysTried, "the setup key never reached the login attempt")
require.Nil(t, s.oauthAuthFlow.flow, "a setup-key login started an SSO flow")
status, err := internal.CtxGetState(s.rootCtx).Status()
require.NoError(t, err)
require.Equal(t, internal.StatusIdle, status)
}
// breakProfilePrivateKey replaces the profile's private key with an unparseable
// one, which makes any attempt to build a Management client fail on the spot.
func breakProfilePrivateKey(t *testing.T, cfgPath string) {
t.Helper()
raw, err := os.ReadFile(cfgPath)
require.NoError(t, err)
var cfg map[string]any
require.NoError(t, json.Unmarshal(raw, &cfg))
cfg["PrivateKey"] = "not-a-key"
patched, err := json.Marshal(cfg)
require.NoError(t, err)
require.NoError(t, os.WriteFile(cfgPath, patched, 0o600))
}
+93
View File
@@ -0,0 +1,93 @@
package server
import (
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
func TestPersistLoginOverrides(t *testing.T) {
strPtr := func(s string) *string { return &s }
tests := []struct {
name string
initialMgmtURL string
initialPSK string
newMgmtURL string
newPSK *string
wantMgmtURL string
wantPSK string
}{
{
name: "persist new management URL",
initialMgmtURL: "https://old.example.com:33073",
newMgmtURL: "https://new.example.com:33073",
wantMgmtURL: "https://new.example.com:33073",
},
{
name: "persist new pre-shared key",
initialMgmtURL: "https://existing.example.com:33073",
initialPSK: "old-key",
newPSK: strPtr("new-key"),
wantMgmtURL: "https://existing.example.com:33073",
wantPSK: "new-key",
},
{
name: "persist both",
initialMgmtURL: "https://old.example.com:33073",
initialPSK: "old-key",
newMgmtURL: "https://new.example.com:33073",
newPSK: strPtr("new-key"),
wantMgmtURL: "https://new.example.com:33073",
wantPSK: "new-key",
},
{
name: "no inputs preserves existing",
initialMgmtURL: "https://existing.example.com:33073",
initialPSK: "existing-key",
wantMgmtURL: "https://existing.example.com:33073",
wantPSK: "existing-key",
},
{
name: "empty PSK pointer is ignored",
initialMgmtURL: "https://existing.example.com:33073",
initialPSK: "existing-key",
newPSK: strPtr(""),
wantMgmtURL: "https://existing.example.com:33073",
wantPSK: "existing-key",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
origDefault := profilemanager.DefaultConfigPath
t.Cleanup(func() { profilemanager.DefaultConfigPath = origDefault })
dir := t.TempDir()
profilemanager.DefaultConfigPath = filepath.Join(dir, "default.json")
seed := profilemanager.ConfigInput{
ConfigPath: profilemanager.DefaultConfigPath,
ManagementURL: tt.initialMgmtURL,
}
if tt.initialPSK != "" {
seed.PreSharedKey = strPtr(tt.initialPSK)
}
_, err := profilemanager.UpdateOrCreateConfig(seed)
require.NoError(t, err, "seed config")
activeProf := &profilemanager.ActiveProfileState{ID: "default"}
err = persistLoginOverrides(activeProf, tt.newMgmtURL, tt.newPSK)
require.NoError(t, err, "persistLoginOverrides")
cfg, err := profilemanager.ReadConfig(profilemanager.DefaultConfigPath)
require.NoError(t, err, "read back config")
require.Equal(t, tt.wantMgmtURL, cfg.ManagementURL.String(), "management URL")
require.Equal(t, tt.wantPSK, cfg.PreSharedKey, "pre-shared key")
})
}
}
+200
View File
@@ -0,0 +1,200 @@
package server
import (
"context"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
// unreachableManagementURL keeps a test that is expected to stop at a gate from
// reaching the network if the gate ever regresses: the profiles a logout must
// not touch point here, so a leak fails fast instead of contacting a real
// management server.
const unreachableManagementURL = "https://127.0.0.1:9"
// enableSSHOnProfile rewrites the profile config at cfgPath with the SSH server
// enabled. Deregistering an SSH-enabled profile is a privileged change, so an
// unprivileged caller is refused by requirePrivilegeForDeregistration before any
// management connection is attempted, which is what keeps these tests offline.
func enableSSHOnProfile(t *testing.T, cfgPath string) {
t.Helper()
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: cfgPath,
ManagementURL: "https://api.netbird.io:443",
ServerSSHAllowed: boolPtr(true),
})
require.NoError(t, err)
}
// Logging out of the profile the daemon is already running is a deregistration,
// not profile management, so the profiles-disabled kill switch must not block
// it. The desktop UI always addresses logout by profile (both the profile menu
// and the session-expiration dialog), so gating it left users with
// disableProfiles enforced unable to log out at all.
func TestLogout_ActiveProfileAllowedWhenProfilesDisabled(t *testing.T) {
s, _, activeProfile, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
enableSSHOnProfile(t, cfgPath)
s.profilesDisabled = true
_, err := s.Logout(userCtx(), &proto.LogoutRequest{
ProfileName: &activeProfile,
Username: &username,
})
require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller")
require.Equal(t, codes.PermissionDenied, gstatus.Code(err),
"logout of the active profile must reach the deregistration path, not be refused as profile management: %v", err)
require.NotContains(t, gstatus.Convert(err).Message(), errProfilesDisabled)
}
// A profile-addressed logout that targets some *other* profile does manage
// profiles, so it stays gated: with profiles disabled the daemon must not
// deregister a peer the user is not currently running.
func TestLogout_OtherProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
other := "other-profile"
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, other+".json"),
ManagementURL: unreachableManagementURL,
})
require.NoError(t, err)
s.profilesDisabled = true
_, err = s.Logout(userCtx(), &proto.LogoutRequest{
ProfileName: &other,
Username: &username,
})
require.Error(t, err)
require.Equal(t, codes.Unavailable, gstatus.Code(err), "want the profiles-disabled refusal, got %v", err)
require.Contains(t, gstatus.Convert(err).Message(), errProfilesDisabled)
}
// A legacy profile ID is a display name, so two users can hold the same ID in
// their own profile directories. Matching on the ID alone would let one user's
// logout pass the gate against the other user's active profile, so the username
// is part of the comparison.
func TestLogout_ForeignUserProfileStaysGatedWhenProfilesDisabled(t *testing.T) {
s, _, _, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
// A legacy-style profile whose ID is its filename stem, and an active state
// claiming that same ID for a different user.
shared := "shared-legacy-name"
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"),
ManagementURL: unreachableManagementURL,
})
require.NoError(t, err)
require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: profilemanager.ID(shared),
Username: "someone-else",
}))
s.profilesDisabled = true
_, err = s.Logout(userCtx(), &proto.LogoutRequest{
ProfileName: &shared,
Username: &username,
})
require.Error(t, err)
require.Equal(t, codes.Unavailable, gstatus.Code(err),
"another user's profile must not pass the gate on an ID match alone: %v", err)
}
// Deregistering a namesake profile must not go out with the running config.
// logoutFromProfile reuses the connected client's config when the target is the
// active profile, and on an ID-only match a shared legacy ID made it reuse it
// for another user's profile, deregistering the active peer instead.
func TestLogout_ForeignUserProfileDoesNotUseTheRunningConfig(t *testing.T) {
s, _, _, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
// The running config has the SSH server enabled, so reusing it would be
// refused with PermissionDenied. The namesake profile does not, so the
// correct path gets as far as dialing its own unreachable management URL.
enableSSHOnProfile(t, cfgPath)
running, err := profilemanager.GetConfig(cfgPath)
require.NoError(t, err)
s.config = running
s.connectClient = newDummyConnectClient(context.Background())
shared := "shared-legacy-name"
_, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, shared+".json"),
ManagementURL: unreachableManagementURL,
})
require.NoError(t, err)
require.NoError(t, s.profileManager.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: profilemanager.ID(shared),
Username: "someone-else",
}))
// Bounded so the deregistration the fixed path attempts fails on the dial
// rather than sitting in gRPC backoff for the whole test timeout.
ctx, cancel := context.WithTimeout(userCtx(), 2*time.Second)
t.Cleanup(cancel)
_, err = s.Logout(ctx, &proto.LogoutRequest{
ProfileName: &shared,
Username: &username,
})
require.Error(t, err)
require.NotEqual(t, codes.PermissionDenied, gstatus.Code(err),
"the namesake profile was deregistered with the running config: %v", err)
}
// The connection teardown follows the profile that is active when the logout
// completes, not the one seen before it started: Login switches profiles under
// guardedConfigMu, which the logout path does not hold, so a login that landed
// meanwhile must keep its connection.
func TestCleanupAfterProfileLogout_FollowsTheCurrentActiveProfile(t *testing.T) {
s, _, activeProfile, username, _ := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
state := internal.CtxGetState(s.rootCtx)
s.cleanupAfterProfileLogout("some-other-profile", username)
status, err := state.Status()
require.NoError(t, err)
require.NotEqual(t, internal.StatusNeedsLogin, status,
"logging out of a profile that is not active must not ask for a new login")
s.cleanupAfterProfileLogout(profilemanager.ID(activeProfile), username)
status, err = state.Status()
require.NoError(t, err)
require.Equal(t, internal.StatusNeedsLogin, status,
"logging out of the active profile must ask for a new login")
}
// With profiles enabled the gate is out of the way on both surfaces; the active
// profile still reaches the deregistration path.
func TestLogout_ActiveProfileAllowedWhenProfilesEnabled(t *testing.T) {
s, _, activeProfile, username, cfgPath := setupServerWithProfile(t)
s.rootCtx = internal.CtxInitState(context.Background())
enableSSHOnProfile(t, cfgPath)
_, err := s.Logout(userCtx(), &proto.LogoutRequest{
ProfileName: &activeProfile,
Username: &username,
})
require.Error(t, err, "the SSH privilege gate is expected to refuse this unprivileged caller")
require.Equal(t, codes.PermissionDenied, gstatus.Code(err), "want the privilege refusal, got %v", err)
}
+330
View File
@@ -0,0 +1,330 @@
package server
import (
"context"
"fmt"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto"
)
// onMDMPolicyChange is invoked by the MDM reload ticker every time the
// OS-native managed-config store reports a diff vs the last observation.
//
// Restart sequence:
// 1. Cancel the active engine context (terminates connectWithRetryRuns).
// 2. Wait briefly for that goroutine to exit (giveUpChan is closed on exit).
// 3. Re-resolve Config from disk + MDM policy (Config.apply re-runs
// applyMDMPolicy with the freshly loaded Policy).
// 4. Spawn a fresh connectWithRetryRuns with the new context and config.
// 5. Broadcast a SystemEvent so any GUI / CLI subscriber (SubscribeEvents
// RPC) can refresh its cached config view without polling.
//
// The callback runs in the ticker's own goroutine. Ticker has already
// logged the per-key diff before invoking this hook.
func (s *Server) onMDMPolicyChange(_, _ *mdm.Policy) error {
log.Warn("MDM policy changed; restarting engine to apply new configuration")
// Hold s.mutex for the entire restart sequence (cancel + quiescence
// wait + re-spawn). Any concurrent Up/Down/Status arriving while
// MDM is restarting blocks on the Lock until we are done — they
// then observe the post-restart state coherently. This is safe
// because the connectWithRetryRuns goroutine no longer acquires
// s.mutex in its defer (intent vs. goroutine-alive concerns are
// fully separated; see the connectionGoroutineRunning helper).
s.mutex.Lock()
defer s.mutex.Unlock()
if !s.clientRunning {
// The client is not running, so there's no engine to restart.
return nil
}
if s.actCancel != nil {
s.actCancel()
}
// Wait for previous connectWithRetryRuns to exit so we don't end up
// with two goroutines fighting over the same status recorder + engine.
// The teardown engages a fan-out of engine goroutines (peer workers,
// signal handler, route manager, ...). close(clientGiveUpChan)
// happens in the function-scope defer of connectWithRetryRuns, on
// every exit path (ctx cancel, backoff exhausted, panic) — see the
// defer in server.go.
if s.clientGiveUpChan != nil {
select {
case <-s.clientGiveUpChan:
case <-time.After(10 * time.Second):
return fmt.Errorf("failed to restart the engine due to timeout")
}
}
if err := s.restartEngineForMDMLocked(); err != nil {
log.Errorf("MDM restart failed: %v", err)
return err
}
// publishConfigChangedEvent has already fired inside
// restartEngineForMDMLocked with source="mdm". Emit an MDM-specific
// user-visible toast so the operator knows their IT policy was
// applied (UserMessage != "" triggers the GUI notifier).
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
"MDM policy applied",
"NetBird configuration was updated by your IT policy.",
map[string]string{
proto.MetadataSourceKey: proto.MetadataSourceMDM,
proto.MetadataTypeKey: proto.MetadataTypePolicyApplied,
},
)
return nil
}
// publishConfigChangedEvent broadcasts a SystemEvent informing any active
// SubscribeEvents subscriber (typically the GUI tray) that the daemon's
// effective Config has been replaced and any cached client-side view
// should be refreshed. Callers pass a stable `source` label so the GUI
// can distinguish a startup spawn from a user-triggered Up or an
// MDM-driven restart. Reusing the SYSTEM category keeps the proto enum
// stable; metadata.type="config_changed" routes to the GUI's refresh
// handler. UserMessage is left empty so the system tray does not toast
// for every internal restart; the MDM path emits a separate
// "policy_applied" event (with UserMessage) for that purpose.
func (s *Server) publishConfigChangedEvent(source string) {
if s.statusRecorder == nil {
return
}
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
proto.SystemEvent_SYSTEM,
fmt.Sprintf("daemon config changed (source=%s)", source),
"",
map[string]string{
proto.MetadataSourceKey: source,
proto.MetadataTypeKey: proto.MetadataTypeConfigChanged,
},
)
}
// restartEngineForMDMLocked re-resolves the active profile config
// (re-running applyMDMPolicy via Config.apply) and re-spawns
// connectWithRetryRuns. Mirrors the tail of Server.Start so a runtime
// MDM change behaves identically to a fresh boot under the new policy.
//
// MUST be called with s.mutex held — onMDMPolicyChange holds the lock
// for the entire restart sequence (cancel + quiescence wait + re-spawn)
// so concurrent Up/Down/Status RPCs observe a coherent post-restart
// state.
func (s *Server) restartEngineForMDMLocked() error {
activeProf, err := s.profileManager.GetActiveProfileState()
if err != nil {
return fmt.Errorf("get active profile state: %w", err)
}
config, _, err := s.getConfig(activeProf)
if err != nil {
return fmt.Errorf("get active profile config: %w", err)
}
s.config = config
s.statusRecorder.UpdateManagementAddress(config.ManagementURL.String())
s.statusRecorder.UpdateRosenpass(config.RosenpassEnabled, config.RosenpassPermissive)
ctx, cancel := context.WithCancel(s.rootCtx)
s.actCancel = cancel
s.clientRunning = true
s.clientRunningChan = make(chan struct{})
s.clientGiveUpChan = make(chan struct{})
log.Info("MDM restart: spawning connectWithRetryRuns with re-resolved config")
go s.connectWithRetryRuns(ctx, config, s.statusRecorder, s.clientRunningChan, s.clientGiveUpChan)
s.publishConfigChangedEvent(proto.MetadataSourceMDM)
return nil
}
// mdmManagedFieldConflicts returns the names of MDM-managed keys whose
// requested value in the SetConfigRequest differs from the MDM-enforced
// value. A field set to the same value the policy already enforces is
// treated as a no-op echo (the GUI tray sends a full Config snapshot on
// every toggle, so most fields in a typical request match the policy
// exactly and must NOT be flagged as conflicts). The redacted PSK
// sentinel ("**********") returned by GetConfig is recognised and
// treated as no-op so the UI can safely round-trip it.
func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) []string {
if msg == nil {
return nil
}
pskGot := msg.OptionalPreSharedKey
if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
pskGot = nil
}
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
})
}
// setConfigRequestHasConfigOverrides reports whether the SetConfigRequest
// carries ANY field that would actually mutate the persisted config.
// The CLI builds a SetConfigRequest unconditionally on every
// `netbird up` (see setupSetConfigReq in cmd/up.go) — a plain
// `netbird up` produces a request with every field at its zero value;
// the gate must skip such no-op invocations or it would always fire
// even when the user did not pass any --flag. Returns false on a nil
// msg; true when any management/admin URL, PSK, DNS/NAT list+clean
// flag, interface/port/MTU, or any optional bool/duration field is set.
func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
if msg == nil {
return false
}
return msg.ManagementUrl != "" ||
msg.AdminURL != "" ||
msg.OptionalPreSharedKey != nil ||
len(msg.CustomDNSAddress) > 0 ||
len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
len(msg.ExtraIFaceBlacklist) > 0 ||
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
msg.DnsRouteInterval != nil ||
msg.RosenpassEnabled != nil ||
msg.RosenpassPermissive != nil ||
msg.InterfaceName != nil ||
msg.WireguardPort != nil ||
msg.Mtu != nil ||
msg.DisableAutoConnect != nil ||
msg.ServerSSHAllowed != nil ||
msg.RemoteJobsAllowed != nil ||
msg.NetworkMonitor != nil ||
msg.DisableClientRoutes != nil ||
msg.DisableServerRoutes != nil ||
msg.DisableDns != nil ||
msg.DisableFirewall != nil ||
msg.BlockLanAccess != nil ||
msg.DisableNotifications != nil ||
msg.BlockInbound != nil ||
msg.DisableIpv6 != nil ||
msg.EnableSSHRoot != nil ||
msg.EnableSSHSFTP != nil ||
msg.EnableSSHLocalPortForwarding != nil ||
msg.EnableSSHRemotePortForwarding != nil ||
msg.DisableSSHAuth != nil ||
msg.SshJWTCacheTTL != nil ||
msg.EnableLocalMetrics != nil ||
msg.LocalMetricsAddress != nil
}
// loginRequestHasConfigOverrides reports whether the LoginRequest
// carries ANY field that would mutate persisted daemon configuration
// (as opposed to pure-auth fields like setupKey, hostname, hint,
// profileName, username). Used by the Login handler to decide whether
// the `--disable-update-settings` / MDM gates must run: a re-auth that
// changes nothing about the configuration is always allowed.
func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
if msg == nil {
return false
}
return msg.ManagementUrl != "" ||
msg.AdminURL != "" ||
msg.PreSharedKey != "" || //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
msg.OptionalPreSharedKey != nil ||
len(msg.CustomDNSAddress) > 0 ||
len(msg.NatExternalIPs) > 0 || msg.CleanNATExternalIPs ||
msg.RosenpassEnabled != nil ||
msg.InterfaceName != nil ||
msg.WireguardPort != nil ||
msg.DisableAutoConnect != nil ||
msg.ServerSSHAllowed != nil ||
msg.RemoteJobsAllowed != nil ||
msg.RosenpassPermissive != nil ||
len(msg.ExtraIFaceBlacklist) > 0 ||
msg.NetworkMonitor != nil ||
msg.DnsRouteInterval != nil ||
msg.DisableClientRoutes != nil ||
msg.DisableServerRoutes != nil ||
msg.DisableDns != nil ||
msg.DisableFirewall != nil ||
msg.BlockLanAccess != nil ||
msg.DisableNotifications != nil ||
len(msg.DnsLabels) > 0 || msg.CleanDNSLabels ||
msg.BlockInbound != nil ||
msg.EnableLocalMetrics != nil ||
msg.LocalMetricsAddress != nil
}
// loginRequestMDMConflicts mirrors mdmManagedFieldConflicts but for the
// LoginRequest surface. Same value-aware semantics: a field set to the
// MDM-enforced value is a no-op echo, not a conflict; only a divergent
// value is flagged. PSK has two proto fields — PreSharedKey (deprecated)
// and OptionalPreSharedKey (current); either route trips the gate if it
// diverges from the MDM-enforced PSK. OptionalPreSharedKey wins when
// both are set; the redaction sentinel ("**********") is accepted as
// a no-op echo.
func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []string {
if msg == nil {
return nil
}
pskGot := msg.OptionalPreSharedKey
if pskGot == nil && msg.PreSharedKey != "" { //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
pskGot = &msg.PreSharedKey //nolint:staticcheck // SA1019
}
if pskGot != nil && *pskGot == mdm.PreSharedKeyRedactedSentinel {
pskGot = nil
}
return mdm.ResolveConflicts(policy, []mdm.ConflictCheck{
mdm.ConflictURL(mdm.KeyManagementURL, msg.ManagementUrl),
mdm.ConflictStringPtr(mdm.KeyPreSharedKey, pskGot),
mdm.ConflictBool(mdm.KeyRosenpassEnabled, msg.RosenpassEnabled),
mdm.ConflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive),
mdm.ConflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect),
mdm.ConflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed),
mdm.ConflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed),
mdm.ConflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes),
mdm.ConflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes),
mdm.ConflictBool(mdm.KeyBlockInbound, msg.BlockInbound),
mdm.ConflictInt64(mdm.KeyWireguardPort, msg.WireguardPort),
mdm.ConflictBool(mdm.KeyEnableLocalMetrics, msg.EnableLocalMetrics),
mdm.ConflictStringPtr(mdm.KeyLocalMetricsAddress, msg.LocalMetricsAddress),
})
}
// rejectMDMManagedFieldConflicts returns a FailedPrecondition gRPC error
// with an MDMManagedFieldsViolation detail when any of the requested
// fields tries to change an MDM-enforced value to something else, and
// nil otherwise. The whole request is rejected on any conflict; non-
// conflicting fields in the same request are not applied either (no
// partial apply).
func rejectMDMManagedFieldConflicts(conflicts []string) error {
if len(conflicts) == 0 {
return nil
}
log.Warnf("MDM rejected request: tried to modify %d managed key(s): %v",
len(conflicts), conflicts)
st := gstatus.New(
codes.FailedPrecondition,
fmt.Sprintf("fields managed by MDM cannot be modified: %v", conflicts),
)
detailed, err := st.WithDetails(&proto.MDMManagedFieldsViolation{Fields: conflicts})
if err != nil {
// Detail attachment is best-effort; fall back to the plain status
// so the caller still gets a usable FailedPrecondition.
return st.Err()
}
return detailed.Err()
}
+35 -29
View File
@@ -8,7 +8,6 @@ import (
"sort"
"strings"
"golang.org/x/exp/maps"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
@@ -18,10 +17,11 @@ import (
)
type selectRoute struct {
NetID route.NetID
Network netip.Prefix
Domains domain.List
Selected bool
NetID route.NetID
Network netip.Prefix
Domains domain.List
Selected bool
extraNetworks []netip.Prefix
}
// ListNetworks returns a list of all available networks.
@@ -29,7 +29,7 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro
s.mutex.Lock()
defer s.mutex.Unlock()
if s.networksDisabled {
if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -50,18 +50,32 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro
routesMap := routeMgr.GetClientRoutesWithNetID()
routeSelector := routeMgr.GetRouteSelector()
v6ExitMerged := route.V6ExitMergeSet(routesMap)
var routes []*selectRoute
for id, rt := range routesMap {
if len(rt) == 0 {
continue
}
route := &selectRoute{
// Skip v6 exit nodes that are merged into their v4 counterpart.
if _, ok := v6ExitMerged[id]; ok {
continue
}
r := &selectRoute{
NetID: id,
Network: rt[0].Network,
Domains: rt[0].Domains,
Selected: routeSelector.IsSelected(id),
}
routes = append(routes, route)
// Merge paired v6 exit node prefix into this entry.
v6ID := route.NetID(string(id) + route.V6ExitSuffix)
if _, ok := v6ExitMerged[v6ID]; ok && len(routesMap[v6ID]) > 0 {
r.extraNetworks = []netip.Prefix{routesMap[v6ID][0].Network}
}
routes = append(routes, r)
}
sort.Slice(routes, func(i, j int) bool {
@@ -82,9 +96,13 @@ func (s *Server) ListNetworks(context.Context, *proto.ListNetworksRequest) (*pro
resolvedDomains := s.statusRecorder.GetResolvedDomainsStates()
var pbRoutes []*proto.Network
for _, route := range routes {
rangeStr := route.Network.String()
for _, extra := range route.extraNetworks {
rangeStr += ", " + extra.String()
}
pbRoute := &proto.Network{
ID: string(route.NetID),
Range: route.Network.String(),
Range: rangeStr,
Domains: route.Domains.ToSafeStringList(),
ResolvedIPs: map[string]*proto.IPList{},
Selected: route.Selected,
@@ -124,7 +142,7 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
s.mutex.Lock()
defer s.mutex.Unlock()
if s.networksDisabled {
if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -142,17 +160,11 @@ func (s *Server) SelectNetworks(_ context.Context, req *proto.SelectNetworksRequ
return nil, fmt.Errorf("no route manager")
}
routeSelector := routeManager.GetRouteSelector()
if req.GetAll() {
routeSelector.SelectAllRoutes()
} else {
routes := toNetIDs(req.GetNetworkIDs())
netIdRoutes := maps.Keys(routeManager.GetClientRoutesWithNetID())
if err := routeSelector.SelectRoutes(routes, req.GetAppend(), netIdRoutes); err != nil {
return nil, fmt.Errorf("select routes: %w", err)
}
routeManager.SelectAllRoutes()
} else if err := routeManager.SelectRoutes(toNetIDs(req.GetNetworkIDs()), req.GetAppend()); err != nil {
return nil, err
}
routeManager.TriggerSelection(routeManager.GetClientRoutes())
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
@@ -174,7 +186,7 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
s.mutex.Lock()
defer s.mutex.Unlock()
if s.networksDisabled {
if s.checkNetworksDisabled() {
return nil, gstatus.Errorf(codes.Unavailable, errNetworksDisabled)
}
@@ -192,17 +204,11 @@ func (s *Server) DeselectNetworks(_ context.Context, req *proto.SelectNetworksRe
return nil, fmt.Errorf("no route manager")
}
routeSelector := routeManager.GetRouteSelector()
if req.GetAll() {
routeSelector.DeselectAllRoutes()
} else {
routes := toNetIDs(req.GetNetworkIDs())
netIdRoutes := maps.Keys(routeManager.GetClientRoutesWithNetID())
if err := routeSelector.DeselectRoutes(routes, netIdRoutes); err != nil {
return nil, fmt.Errorf("deselect routes: %w", err)
}
routeManager.DeselectAllRoutes()
} else if err := routeManager.DeselectRoutes(toNetIDs(req.GetNetworkIDs())); err != nil {
return nil, err
}
routeManager.TriggerSelection(routeManager.GetClientRoutes())
s.statusRecorder.PublishEvent(
proto.SystemEvent_INFO,
+2 -1
View File
@@ -3,6 +3,7 @@
package server
import (
"errors"
"fmt"
"os"
"path"
@@ -69,7 +70,7 @@ func setStdHandle(f *os.File) error {
handle := f.Fd()
r0, _, e1 := setStdHandleFn.Call(stdErrorHandle, handle)
if r0 == 0 {
if e1 != nil {
if !errors.Is(e1, syscall.Errno(0)) {
return e1
}
return syscall.EINVAL
+88
View File
@@ -0,0 +1,88 @@
package server
import (
"context"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
// healthProbeRunner runs the full, expensive probe (network round-trips to
// management, signal and the relays) and reports whether every component was
// healthy. ctx cancels the probe when the caller gives up. Satisfied by
// *internal.Engine.
type healthProbeRunner interface {
RunHealthProbes(ctx context.Context, waitForResult bool) bool
}
// statsRefresher does the cheap WireGuard-stats refresh callers fall back to
// when a fresh probe isn't warranted. Satisfied by *peer.Status.
type statsRefresher interface {
RefreshWireGuardStats() error
}
// probeThrottle rate-limits and single-flights the daemon's health probes.
//
// Health probes are expensive (network round-trips to management, signal and
// the relays), while Status(GetFullPeerStatus=true) RPCs can arrive frequently
// and concurrently — the desktop UI alone issues one per connect/disconnect.
// probeThrottle keeps that load bounded with two rules:
//
// - Single-flight: only one probe runs at a time. Callers that pile up while
// a probe is in flight share its result instead of each launching another,
// even when that probe failed. A failed probe therefore does not make every
// waiter re-probe in turn; the next, non-overlapping caller can try again.
// - Throttle: after a fully successful probe the result is cached for
// interval. While any component is unhealthy the cache is not advanced, so
// later callers keep probing frequently and notice recovery quickly — the
// intentional "probe often while unhealthy" behaviour from the original
// design.
type probeThrottle struct {
interval time.Duration
mu sync.Mutex
lastOK time.Time // last fully-successful probe; drives the throttle window
completedAt time.Time // when the most recent probe finished; drives single-flight sharing
}
func newProbeThrottle(interval time.Duration) *probeThrottle {
return &probeThrottle{interval: interval}
}
// Run decides whether to run a fresh health probe or serve the most recent
// result. It serialises concurrent callers: at most one runner.RunHealthProbes
// executes at a time and the rest call refresher.RefreshWireGuardStats and read
// the snapshot it produced.
//
// Both calls run while the throttle's lock is held, so a slow probe blocks
// other callers until it completes — that blocking is the single-flight
// guarantee. ctx is forwarded to RunHealthProbes so a caller that gives up
// cancels the in-flight probe (and any caller still queued on the lock falls
// through quickly once it acquires it, since the probe ctx is already done).
func (t *probeThrottle) Run(ctx context.Context, runner healthProbeRunner, refresher statsRefresher, waitForResult bool) {
entered := time.Now()
t.mu.Lock()
defer t.mu.Unlock()
// A probe that finished after we entered ran while we were waiting on the
// lock — i.e. a peer in the same burst already probed for us, so share its
// result rather than launch another. This holds even when that probe
// failed, so a failed probe doesn't make every waiter re-probe in turn.
sharedRecentProbe := t.completedAt.After(entered)
throttled := time.Since(t.lastOK) <= t.interval
if sharedRecentProbe || throttled {
if err := refresher.RefreshWireGuardStats(); err != nil {
log.Debugf("failed to refresh WireGuard stats: %v", err)
}
return
}
healthy := runner.RunHealthProbes(ctx, waitForResult)
t.completedAt = time.Now()
if healthy {
t.lastOK = t.completedAt
}
}
+109
View File
@@ -0,0 +1,109 @@
package server
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
)
// fakeProber implements both healthProbeRunner and statsRefresher with
// caller-supplied behaviour.
type fakeProber struct {
onProbe func() bool
onRefresh func()
}
func (f fakeProber) RunHealthProbes(context.Context, bool) bool {
return f.onProbe()
}
func (f fakeProber) RefreshWireGuardStats() error {
if f.onRefresh != nil {
f.onRefresh()
}
return nil
}
func TestProbeThrottle_CachesAfterSuccess(t *testing.T) {
pt := newProbeThrottle(time.Minute)
var probes, refreshes int
prober := fakeProber{
onProbe: func() bool { probes++; return true },
onRefresh: func() { refreshes++ },
}
pt.Run(context.Background(), prober, prober, false)
pt.Run(context.Background(), prober, prober, false)
if probes != 1 {
t.Fatalf("expected 1 probe within the throttle window, got %d", probes)
}
if refreshes != 1 {
t.Fatalf("expected the throttled caller to refresh stats once, got %d", refreshes)
}
}
func TestProbeThrottle_StaysOpenWhileUnhealthy(t *testing.T) {
pt := newProbeThrottle(time.Minute)
var probes int
prober := fakeProber{onProbe: func() bool { probes++; return false }} // never healthy
// Sequential, non-overlapping callers must each re-probe while unhealthy:
// a failed probe does not advance the throttle window.
pt.Run(context.Background(), prober, prober, false)
pt.Run(context.Background(), prober, prober, false)
pt.Run(context.Background(), prober, prober, false)
if probes != 3 {
t.Fatalf("expected every non-overlapping caller to probe while unhealthy, got %d", probes)
}
}
func TestProbeThrottle_SingleFlightSharesResult(t *testing.T) {
pt := newProbeThrottle(time.Minute)
var probes int32
release := make(chan struct{})
started := make(chan struct{})
// First caller blocks inside the probe until released, holding the lock so
// the others pile up behind it.
prober := fakeProber{onProbe: func() bool {
if atomic.AddInt32(&probes, 1) == 1 {
close(started)
<-release
}
return false // unhealthy — the share must happen regardless of result
}}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
pt.Run(context.Background(), prober, prober, false)
}()
<-started // ensure the first probe is in flight before the burst arrives
const waiters = 9
wg.Add(waiters)
for i := 0; i < waiters; i++ {
go func() {
defer wg.Done()
pt.Run(context.Background(), prober, prober, false)
}()
}
// Give the waiters time to block on the lock, then let the first finish.
time.Sleep(50 * time.Millisecond)
close(release)
wg.Wait()
if got := atomic.LoadInt32(&probes); got != 1 {
t.Fatalf("expected a concurrent burst to run exactly 1 probe, got %d", got)
}
}
+1302 -286
View File
File diff suppressed because it is too large Load Diff
+12 -4
View File
@@ -18,6 +18,10 @@ func newTestServer() *Server {
return &Server{
rootCtx: context.Background(),
statusRecorder: peer.NewRecorder(""),
// New always populates the SSH JWT cache and the logout and
// profile-switch paths call into it unconditionally, so a Server
// assembled field by field has to populate it too.
jwtCache: newJWTCache(),
}
}
@@ -101,6 +105,7 @@ func TestCleanupConnection_ClearsConnectClient(t *testing.T) {
require.NoError(t, err)
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
assert.False(t, s.clientRunning, "clientRunning should be cleared after cleanup (intent = down)")
}
// TestCleanState_NilConnectClient validates that CleanState doesn't panic
@@ -144,17 +149,20 @@ func TestDownThenUp_StaleRunningChan(t *testing.T) {
_, cancel := context.WithCancel(context.Background())
s.actCancel = cancel
// Simulate Down(): cleanupConnection sets connectClient = nil
// Simulate Down(): cleanupConnection sets connectClient = nil and
// flips clientRunning to false (intent = down). The connectionGoroutineRunning state
// remains independent of intent — derived from clientGiveUpChan.
s.mutex.Lock()
err := s.cleanupConnection()
s.mutex.Unlock()
require.NoError(t, err)
// After cleanup: connectClient is nil, clientRunning still true
// (goroutine hasn't exited yet)
// After cleanup: connectClient is nil, clientRunning is false (intent
// cleared by cleanupConnection), connectionGoroutineRunning may still be true
// (goroutine teardown is independent of the intent flag).
s.mutex.Lock()
assert.Nil(t, s.connectClient, "connectClient should be nil after cleanup")
assert.True(t, s.clientRunning, "clientRunning still true until goroutine exits")
assert.False(t, s.clientRunning, "clientRunning should be cleared by cleanupConnection (intent = down)")
s.mutex.Unlock()
// waitForUp() returns immediately due to stale closed clientRunningChan
+188
View File
@@ -0,0 +1,188 @@
package server
import (
"context"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
)
// These cover the RPC side of the cache: the cache itself is exercised in
// jwt_cache_test.go, but a correct cache buys nothing if the handlers around it
// consult the wrong identity or forget to clear it.
func TestCachedJWT_ServesTheOwner(t *testing.T) {
s := newTestServer()
owner := unprivilegedIdentity()
s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration())
got, found := s.cachedJWT(ctxWithIdentity(owner))
require.True(t, found, "the identity that obtained the token must get it back")
assert.Equal(t, "token", got)
}
func TestCachedJWT_RefusesAnotherCaller(t *testing.T) {
s := newTestServer()
s.jwtCache.store("token", unprivilegedIdentity(), testTTL, s.jwtCache.currentGeneration())
got, found := s.cachedJWT(ctxWithIdentity(privilegedIdentity()))
assert.False(t, found, "a caller that did not obtain the token must get a miss")
assert.Empty(t, got)
}
// A control channel that carries no caller identity — a TCP daemon socket, or a
// platform with no peer-credential primitive — cannot tell one local user from
// another, so cachedJWT must fail closed there.
func TestCachedJWT_WithoutCallerIdentity(t *testing.T) {
s := newTestServer()
s.jwtCache.store("token", unprivilegedIdentity(), testTTL, s.jwtCache.currentGeneration())
got, found := s.cachedJWT(context.Background())
assert.False(t, found)
assert.Empty(t, got)
}
// profileFixture points the profile globals at a temp dir holding a single
// default profile, which is the one ActiveProfileState.FilePath resolves
// without consulting the current OS user.
func profileFixture(t *testing.T) string {
t.Helper()
dir := t.TempDir()
defaultConfig := filepath.Join(dir, "default.json")
require.NoError(t, os.WriteFile(defaultConfig, []byte("{}"), 0o600))
origDir := profilemanager.DefaultConfigPathDir
origDefault := profilemanager.DefaultConfigPath
origState := profilemanager.ActiveProfileStatePath
origOverride := profilemanager.ConfigDirOverride
profilemanager.DefaultConfigPathDir = dir
profilemanager.DefaultConfigPath = defaultConfig
profilemanager.ActiveProfileStatePath = filepath.Join(dir, "active_profile.json")
profilemanager.ConfigDirOverride = dir
t.Cleanup(func() {
profilemanager.DefaultConfigPathDir = origDir
profilemanager.DefaultConfigPath = origDefault
profilemanager.ActiveProfileStatePath = origState
profilemanager.ConfigDirOverride = origOverride
})
return defaultConfig
}
// A profile carries its own NetBird account, so a token obtained under the
// previous one must not survive the switch even for the local user who
// obtained it.
func TestSwitchProfile_ClearsJWTCache(t *testing.T) {
defaultConfig := profileFixture(t)
// localmetrics.NewManager runs until its context is done, so the manager
// must not outlive the test.
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
s := newTestServer()
s.profileManager = profilemanager.NewServiceManager(defaultConfig)
s.localMetrics = localmetrics.NewManager(ctx, s.statusRecorder, nil)
// A second profile to move to, so the request goes through
// switchProfileIfNeeded rather than the no-op path a nil request takes.
const target = "second"
username := "tester"
_, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: filepath.Join(profilemanager.DefaultConfigPathDir, target+".json"),
ManagementURL: "https://api.netbird.io:443",
})
require.NoError(t, err)
owner := unprivilegedIdentity()
s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration())
name := target
_, err = s.SwitchProfile(ctx, &proto.SwitchProfileRequest{ProfileName: &name, Username: &username})
require.NoError(t, err)
active, err := s.profileManager.GetActiveProfileState()
require.NoError(t, err)
require.Equal(t, profilemanager.ID(target), active.ID, "the profile must actually have changed")
_, found := s.jwtCache.get(owner)
assert.False(t, found, "switching profile must drop the cached SSH JWT")
}
// Down ends the connection, not the session: the peer stays enrolled and the
// token still belongs to the same NetBird identity, so `down` followed by `up`
// must not cost the owner a fresh device-code flow.
//
// The logout handlers do call cleanupConnection, and SwitchProfile does not;
// what they have in common is that each clears the cache itself, right after,
// so tearing the connection down is no longer what decides the token's fate.
func TestCleanupConnection_KeepsJWTCache(t *testing.T) {
s := newTestServer()
_, cancel := context.WithCancel(context.Background())
s.actCancel = cancel
owner := unprivilegedIdentity()
s.jwtCache.store("token", owner, testTTL, s.jwtCache.currentGeneration())
require.NoError(t, s.cleanupConnection())
got, found := s.jwtCache.get(owner)
require.True(t, found, "going down must not drop the cached SSH JWT")
assert.Equal(t, "token", got)
}
// fakeOAuthFlow stands in for the IdP round trip so a test can drive
// WaitJWTToken without a real device-code flow.
type fakeOAuthFlow struct {
token string
}
func (f *fakeOAuthFlow) RequestAuthInfo(context.Context) (auth.AuthFlowInfo, error) {
return auth.AuthFlowInfo{DeviceCode: "device-code"}, nil
}
func (f *fakeOAuthFlow) WaitToken(context.Context, auth.AuthFlowInfo) (auth.TokenInfo, error) {
return auth.TokenInfo{AccessToken: f.token}, nil
}
func (f *fakeOAuthFlow) GetClientID(context.Context) string { return "client-id" }
// The flow outlives a profile switch, because SwitchProfile does not reset
// s.oauthAuthFlow. A switch between RequestJWTAuth and the IdP answering must
// still keep the token out of the cache the new profile uses, and the
// generation the flow carries is what decides it: reading the cache's own
// generation at store time would already be the new one.
func TestWaitJWTToken_DropsTokenFromASessionThatEndedBeforeTheWait(t *testing.T) {
s := newTestServer()
owner := unprivilegedIdentity()
ttl := int(testTTL.Seconds())
s.config = &profilemanager.Config{SSHJWTCacheTTL: &ttl}
// RequestJWTAuth ran under the previous session and recorded its generation.
s.oauthAuthFlow.flow = &fakeOAuthFlow{token: "token-from-the-old-session"}
s.oauthAuthFlow.info = auth.AuthFlowInfo{DeviceCode: "device-code"}
s.oauthAuthFlow.cacheGeneration = s.jwtCache.currentGeneration()
// A profile switch or a logout lands before the caller reaches WaitJWTToken.
s.jwtCache.clear()
_, err := s.WaitJWTToken(ctxWithIdentity(owner), &proto.WaitJWTTokenRequest{DeviceCode: "device-code"})
require.NoError(t, err)
_, found := s.jwtCache.get(owner)
assert.False(t, found, "a token whose flow started under the previous session must not be cached")
}
+252
View File
@@ -0,0 +1,252 @@
//go:build privileged
package server
import (
"context"
"net"
"os/user"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"go.uber.org/mock/gomock"
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/job"
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/groups"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/management/internals/modules/permissions"
"github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/activity"
nbcache "github.com/netbirdio/netbird/management/server/cache"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
"github.com/netbirdio/netbird/management/server/settings"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/telemetry"
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/signal/proto"
signalServer "github.com/netbirdio/netbird/signal/server"
)
var (
kaep = keepalive.EnforcementPolicy{
MinTime: 15 * time.Second,
PermitWithoutStream: true,
}
kasp = keepalive.ServerParameters{
MaxConnectionIdle: 15 * time.Second,
MaxConnectionAgeGrace: 5 * time.Second,
Time: 5 * time.Second,
Timeout: 2 * time.Second,
}
)
// TestConnectStopsRetryOnPermissionDenied verifies connectWithRetryRuns stops after a single login
// attempt on PermissionDenied, despite the fast retry config that would otherwise drive several.
func TestConnectStopsRetryOnPermissionDenied(t *testing.T) {
// Redirect profile paths to a temp dir so the test does not need root.
tempDir := t.TempDir()
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
origDefaultConfigPath := profilemanager.DefaultConfigPath
profilemanager.ConfigDirOverride = tempDir
profilemanager.DefaultConfigPathDir = tempDir
profilemanager.ActiveProfileStatePath = filepath.Join(tempDir, "active_profile.json")
profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
t.Cleanup(func() {
profilemanager.DefaultConfigPathDir = origDefaultProfileDir
profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
profilemanager.DefaultConfigPath = origDefaultConfigPath
profilemanager.ConfigDirOverride = ""
})
// start the signal server
_, signalAddr, err := startSignal(t)
if err != nil {
t.Fatalf("failed to start signal server: %v", err)
}
counter := 0
// start the management server
_, mgmtAddr, err := startManagement(t, signalAddr, &counter)
if err != nil {
t.Fatalf("failed to start management server: %v", err)
}
ctx := internal.CtxInitState(context.Background())
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second))
defer cancel()
// create new server
ic := profilemanager.ConfigInput{
ManagementURL: "http://" + mgmtAddr,
ConfigPath: t.TempDir() + "/test-profile.json",
}
config, err := profilemanager.UpdateOrCreateConfig(ic)
if err != nil {
t.Fatalf("failed to create config: %v", err)
}
currUser, err := user.Current()
require.NoError(t, err)
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: "test-profile",
Username: currUser.Username,
})
if err != nil {
t.Fatalf("failed to set active profile state: %v", err)
}
s := New(ctx, "debug", "", false, false, false, false)
s.config = config
s.statusRecorder = peer.NewRecorder(config.ManagementURL.String())
t.Setenv(retryInitialIntervalVar, "1s")
t.Setenv(maxRetryIntervalVar, "2s")
t.Setenv(maxRetryTimeVar, "5s")
t.Setenv(retryMultiplierVar, "1")
s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
if counter != 1 {
t.Fatalf("expected exactly 1 login attempt (PermissionDenied must stop the retry loop), got %d", counter)
}
}
type mockServer struct {
mgmtProto.ManagementServiceServer
counter *int
}
func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
*m.counter++
return m.ManagementServiceServer.Login(ctx, req)
}
func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) {
t.Helper()
dataDir := t.TempDir()
config := &config.Config{
Stuns: []*config.Host{},
TURNConfig: &config.TURNConfig{},
Signal: &config.Host{
Proto: "http",
URI: signalAddr,
},
Datadir: dataDir,
HttpConfig: nil,
}
lis, err := net.Listen("tcp", "localhost:0")
if err != nil {
return nil, "", err
}
s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir)
if err != nil {
return nil, "", err
}
t.Cleanup(cleanUp)
eventStore := &activity.InMemoryEventStore{}
if err != nil {
return nil, "", err
}
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
permissionsManagerMock := permissions.NewMockManager(ctrl)
peersManager := peers.NewManager(store)
settingsManagerMock := settings.NewMockManager(ctrl)
jobManager := job.NewJobManager(nil, store, peersManager)
cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
if err != nil {
return nil, "", err
}
ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
require.NoError(t, err)
settingsMockManager := settings.NewMockManager(ctrl)
groupsManager := groups.NewManagerMock()
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config, nil)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil {
return nil, "", err
}
secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
if err != nil {
return nil, "", err
}
mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil, nil)
if err != nil {
return nil, "", err
}
mock := &mockServer{
ManagementServiceServer: mgmtServer,
counter: counter,
}
mgmtProto.RegisterManagementServiceServer(s, mock)
go func() {
if err = s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}()
return s, lis.Addr().String(), nil
}
func startSignal(t *testing.T) (*grpc.Server, string, error) {
t.Helper()
s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
lis, err := net.Listen("tcp", "localhost:0")
if err != nil {
return nil, "", err
}
srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
require.NoError(t, err)
proto.RegisterSignalExchangeServer(s, srv)
go func() {
if err = s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}()
return s, lis.Addr().String(), nil
}
+5 -224
View File
@@ -2,125 +2,22 @@ package server
import (
"context"
"net"
"net/url"
"os/user"
"path/filepath"
"testing"
"time"
"github.com/golang/mock/gomock"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel"
"github.com/netbirdio/management-integrations/integrations"
"github.com/netbirdio/netbird/management/internals/modules/permissions"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
"github.com/netbirdio/netbird/management/internals/modules/peers"
"github.com/netbirdio/netbird/management/internals/modules/peers/ephemeral/manager"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/job"
"github.com/netbirdio/netbird/management/internals/server/config"
"github.com/netbirdio/netbird/management/server/groups"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/keepalive"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
daemonProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/management/server"
"github.com/netbirdio/netbird/management/server/activity"
nbcache "github.com/netbirdio/netbird/management/server/cache"
"github.com/netbirdio/netbird/management/server/integrations/port_forwarding"
"github.com/netbirdio/netbird/management/server/settings"
"github.com/netbirdio/netbird/management/server/store"
"github.com/netbirdio/netbird/management/server/telemetry"
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/signal/proto"
signalServer "github.com/netbirdio/netbird/signal/server"
)
var (
kaep = keepalive.EnforcementPolicy{
MinTime: 15 * time.Second,
PermitWithoutStream: true,
}
kasp = keepalive.ServerParameters{
MaxConnectionIdle: 15 * time.Second,
MaxConnectionAgeGrace: 5 * time.Second,
Time: 5 * time.Second,
Timeout: 2 * time.Second,
}
)
// TestConnectWithRetryRuns checks that the connectWithRetry function runs and runs the retries according to the times specified via environment variables
// we will use a management server started via to simulate the server and capture the number of retries
func TestConnectWithRetryRuns(t *testing.T) {
// start the signal server
_, signalAddr, err := startSignal(t)
if err != nil {
t.Fatalf("failed to start signal server: %v", err)
}
counter := 0
// start the management server
_, mgmtAddr, err := startManagement(t, signalAddr, &counter)
if err != nil {
t.Fatalf("failed to start management server: %v", err)
}
ctx := internal.CtxInitState(context.Background())
ctx, cancel := context.WithDeadline(ctx, time.Now().Add(30*time.Second))
defer cancel()
// create new server
ic := profilemanager.ConfigInput{
ManagementURL: "http://" + mgmtAddr,
ConfigPath: t.TempDir() + "/test-profile.json",
}
config, err := profilemanager.UpdateOrCreateConfig(ic)
if err != nil {
t.Fatalf("failed to create config: %v", err)
}
currUser, err := user.Current()
require.NoError(t, err)
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
Name: "test-profile",
Username: currUser.Username,
})
if err != nil {
t.Fatalf("failed to set active profile state: %v", err)
}
s := New(ctx, "debug", "", false, false, false)
s.config = config
s.statusRecorder = peer.NewRecorder(config.ManagementURL.String())
t.Setenv(retryInitialIntervalVar, "1s")
t.Setenv(maxRetryIntervalVar, "2s")
t.Setenv(maxRetryTimeVar, "5s")
t.Setenv(retryMultiplierVar, "1")
s.connectWithRetryRuns(ctx, config, s.statusRecorder, nil, nil)
if counter < 3 {
t.Fatalf("expected counter > 2, got %d", counter)
}
}
func TestServer_Up(t *testing.T) {
tempDir := t.TempDir()
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
@@ -159,14 +56,14 @@ func TestServer_Up(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
Name: profName,
ID: profilemanager.ID(profName),
Username: currUser.Username,
})
if err != nil {
t.Fatalf("failed to set active profile state: %v", err)
}
s := New(ctx, "console", "", false, false, false)
s := New(ctx, "console", "", false, false, false, false)
err = s.Start()
require.NoError(t, err)
@@ -229,14 +126,14 @@ func TestServer_SubcribeEvents(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
Name: "default",
ID: "default",
Username: currUser.Username,
})
if err != nil {
t.Fatalf("failed to set active profile state: %v", err)
}
s := New(ctx, "console", "", false, false, false)
s := New(ctx, "console", "", false, false, false, false)
err = s.Start()
require.NoError(t, err)
@@ -260,119 +157,3 @@ func TestServer_SubcribeEvents(t *testing.T) {
assert.NoError(t, err)
}
type mockServer struct {
mgmtProto.ManagementServiceServer
counter *int
}
func (m *mockServer) Login(ctx context.Context, req *mgmtProto.EncryptedMessage) (*mgmtProto.EncryptedMessage, error) {
*m.counter++
return m.ManagementServiceServer.Login(ctx, req)
}
func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Server, string, error) {
t.Helper()
dataDir := t.TempDir()
config := &config.Config{
Stuns: []*config.Host{},
TURNConfig: &config.TURNConfig{},
Signal: &config.Host{
Proto: "http",
URI: signalAddr,
},
Datadir: dataDir,
HttpConfig: nil,
}
lis, err := net.Listen("tcp", "localhost:0")
if err != nil {
return nil, "", err
}
s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
store, cleanUp, err := store.NewTestStoreFromSQL(context.Background(), "", config.Datadir)
if err != nil {
return nil, "", err
}
t.Cleanup(cleanUp)
eventStore := &activity.InMemoryEventStore{}
if err != nil {
return nil, "", err
}
ctrl := gomock.NewController(t)
t.Cleanup(ctrl.Finish)
permissionsManagerMock := permissions.NewMockManager(ctrl)
peersManager := peers.NewManager(store)
settingsManagerMock := settings.NewMockManager(ctrl)
jobManager := job.NewJobManager(nil, store, peersManager)
cacheStore, err := nbcache.NewStore(context.Background(), 100*time.Millisecond, 300*time.Millisecond, 100)
if err != nil {
return nil, "", err
}
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
require.NoError(t, err)
settingsMockManager := settings.NewMockManager(ctrl)
groupsManager := groups.NewManagerMock()
requestBuffer := server.NewAccountRequestBuffer(context.Background(), store)
peersUpdateManager := update_channel.NewPeersUpdateManager(metrics)
networkMapController := controller.NewController(context.Background(), store, metrics, peersUpdateManager, requestBuffer, server.MockIntegratedValidator{}, settingsMockManager, "netbird.selfhosted", port_forwarding.NewControllerMock(), manager.NewEphemeralManager(store, peersManager), config)
accountManager, err := server.BuildManager(context.Background(), config, store, networkMapController, jobManager, nil, "", eventStore, nil, false, ia, metrics, port_forwarding.NewControllerMock(), settingsMockManager, permissionsManagerMock, false, cacheStore)
if err != nil {
return nil, "", err
}
secretsManager, err := nbgrpc.NewTimeBasedAuthSecretsManager(peersUpdateManager, config.TURNConfig, config.Relay, settingsMockManager, groupsManager)
if err != nil {
return nil, "", err
}
mgmtServer, err := nbgrpc.NewServer(config, accountManager, settingsMockManager, jobManager, secretsManager, nil, nil, &server.MockIntegratedValidator{}, networkMapController, nil)
if err != nil {
return nil, "", err
}
mock := &mockServer{
ManagementServiceServer: mgmtServer,
counter: counter,
}
mgmtProto.RegisterManagementServiceServer(s, mock)
go func() {
if err = s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}()
return s, lis.Addr().String(), nil
}
func startSignal(t *testing.T) (*grpc.Server, string, error) {
t.Helper()
s := grpc.NewServer(grpc.KeepaliveEnforcementPolicy(kaep), grpc.KeepaliveParams(kasp))
lis, err := net.Listen("tcp", "localhost:0")
if err != nil {
log.Fatalf("failed to listen: %v", err)
}
srv, err := signalServer.NewServer(context.Background(), otel.Meter(""))
require.NoError(t, err)
proto.RegisterSignalExchangeServer(s, srv)
go func() {
if err = s.Serve(lis); err != nil {
log.Fatalf("failed to serve: %v", err)
}
}()
return s, lis.Addr().String(), nil
}
+366
View File
@@ -0,0 +1,366 @@
package server
import (
"context"
"os/user"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/mdm"
"github.com/netbirdio/netbird/client/proto"
)
// fakeMDMFetcher implements mdm.PolicyFetcher returning a pre-set
// policy map. Tests build one per Server instance to inject a
// scripted MDM overlay via a Loader rather than via package-level state.
type fakeMDMFetcher struct{ values map[string]any }
func (f *fakeMDMFetcher) Fetch() map[string]any { return f.values }
// withMDMPolicy installs an mdm.Loader on the given Server whose
// loadPlatform returns the supplied Policy's underlying values. Use
// after setupServerWithProfile to inject the scripted policy the
// SetConfig / Login MDM gates will observe.
func withMDMPolicy(t *testing.T, s *Server, policy *mdm.Policy) {
t.Helper()
values := map[string]any{}
if policy != nil {
for _, k := range policy.ManagedKeys() {
if v, ok := policy.GetString(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetInt(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetBool(k); ok {
values[k] = v
continue
}
if v, ok := policy.GetStringSlice(k); ok {
values[k] = v
}
}
}
s.mdmLoader = mdm.NewLoader(&fakeMDMFetcher{values: values})
}
// setupServerWithProfile mirrors the boilerplate of TestSetConfig_AllFieldsSaved:
// overrides profilemanager paths to a temp dir, seeds a profile, sets it
// active, and constructs a Server instance. Returns the constructed server
// plus context + profile name + username + cfgPath for the seeded profile.
func setupServerWithProfile(t *testing.T) (s *Server, ctx context.Context, profName, username, cfgPath string) {
t.Helper()
tempDir := t.TempDir()
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
origDefaultConfigPath := profilemanager.DefaultConfigPath
origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
profilemanager.ConfigDirOverride = tempDir
profilemanager.DefaultConfigPathDir = tempDir
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
t.Cleanup(func() {
profilemanager.DefaultConfigPathDir = origDefaultProfileDir
profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
profilemanager.DefaultConfigPath = origDefaultConfigPath
profilemanager.ConfigDirOverride = ""
})
currUser, err := user.Current()
require.NoError(t, err)
profName = "test-profile-mdm"
cfgPath = filepath.Join(tempDir, profName+".json")
_, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
ConfigPath: cfgPath,
ManagementURL: "https://api.netbird.io:443",
})
require.NoError(t, err)
pm := profilemanager.ServiceManager{}
require.NoError(t, pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
ID: profilemanager.ID(profName),
Username: currUser.Username,
}))
// The privileged-change gate reads the caller's kernel identity from the
// context, which a real caller gets from the daemon's transport credentials.
// This test drives the handler directly, so it stands in for a root caller;
// without an identity the gate would (correctly) refuse the SSH fields.
ctx = privilegedTestCtx()
s = New(ctx, "console", "", false, false, false, false)
return s, ctx, profName, currUser.Username, cfgPath
}
// extractViolation pulls the MDMManagedFieldsViolation detail from a
// FailedPrecondition error. Fails the test if absent or malformed.
func extractViolation(t *testing.T, err error) *proto.MDMManagedFieldsViolation {
t.Helper()
require.Error(t, err)
st, ok := gstatus.FromError(err)
require.True(t, ok, "error must be a gRPC status: %v", err)
require.Equal(t, codes.FailedPrecondition, st.Code(), "expected FailedPrecondition, got %s", st.Code())
for _, d := range st.Details() {
if v, ok := d.(*proto.MDMManagedFieldsViolation); ok {
return v
}
}
t.Fatalf("MDMManagedFieldsViolation detail not found on status; details: %v", st.Details())
return nil
}
func TestSetConfig_MDMReject_SingleField(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "https://mdm.example.com:443",
}))
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
ManagementUrl: "https://user.tried.this.com:443",
})
v := extractViolation(t, err)
assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
}
func TestSetConfig_MDMReject_MultipleFields(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "https://mdm.example.com:443",
mdm.KeyBlockInbound: true,
mdm.KeyRosenpassEnabled: true,
}))
blockInbound := false
rosenpassEnabled := false
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
ManagementUrl: "https://user.tried.this.com:443",
BlockInbound: &blockInbound,
RosenpassEnabled: &rosenpassEnabled,
})
v := extractViolation(t, err)
assert.ElementsMatch(t, []string{
mdm.KeyManagementURL,
mdm.KeyBlockInbound,
mdm.KeyRosenpassEnabled,
}, v.GetFields())
}
func TestSetConfig_MDMReject_LocalMetrics(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyEnableLocalMetrics: true,
mdm.KeyLocalMetricsAddress: "127.0.0.1:9191",
}))
enabled := false
addr := "0.0.0.0:9999"
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
EnableLocalMetrics: &enabled,
LocalMetricsAddress: &addr,
})
v := extractViolation(t, err)
assert.ElementsMatch(t, []string{
mdm.KeyEnableLocalMetrics,
mdm.KeyLocalMetricsAddress,
}, v.GetFields())
}
// An explicitly empty address still changes the effective listen address
// (the manager falls back to the default), so presence must be honored
// rather than collapsed to "field not set".
func TestSetConfig_MDMReject_LocalMetricsEmptyAddress(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyLocalMetricsAddress: "127.0.0.1:9999",
}))
addr := ""
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
LocalMetricsAddress: &addr,
})
v := extractViolation(t, err)
assert.ElementsMatch(t, []string{mdm.KeyLocalMetricsAddress}, v.GetFields())
}
func TestSetConfig_MDMReject_EmptyPreSharedKey(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: "mdm-enforced-psk",
}))
psk := ""
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
OptionalPreSharedKey: &psk,
})
v := extractViolation(t, err)
assert.ElementsMatch(t, []string{mdm.KeyPreSharedKey}, v.GetFields())
}
func TestSetConfig_MDMAllow_PreSharedKeySentinelEcho(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: "mdm-enforced-psk",
}))
psk := mdm.PreSharedKeyRedactedSentinel
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
OptionalPreSharedKey: &psk,
})
require.NoError(t, err)
require.NotNil(t, resp)
}
func TestLoginRequestMDMConflicts_PreSharedKey(t *testing.T) {
policy := mdm.NewPolicy(map[string]any{
mdm.KeyPreSharedKey: "mdm-enforced-psk",
})
empty := ""
sentinel := mdm.PreSharedKeyRedactedSentinel
same := "mdm-enforced-psk"
other := "user-psk"
tests := []struct {
name string
msg *proto.LoginRequest
want []string
}{
{name: "unset", msg: &proto.LoginRequest{}, want: nil},
{name: "optional empty", msg: &proto.LoginRequest{OptionalPreSharedKey: &empty}, want: []string{mdm.KeyPreSharedKey}},
{name: "optional sentinel echo", msg: &proto.LoginRequest{OptionalPreSharedKey: &sentinel}, want: nil},
{name: "optional same value", msg: &proto.LoginRequest{OptionalPreSharedKey: &same}, want: nil},
{name: "optional divergent", msg: &proto.LoginRequest{OptionalPreSharedKey: &other}, want: []string{mdm.KeyPreSharedKey}},
{name: "legacy empty is unset", msg: &proto.LoginRequest{PreSharedKey: ""}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
{name: "legacy sentinel echo", msg: &proto.LoginRequest{PreSharedKey: sentinel}, want: nil}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
{name: "legacy divergent", msg: &proto.LoginRequest{PreSharedKey: other}, want: []string{mdm.KeyPreSharedKey}}, //nolint:staticcheck // SA1019: legacy proto field still accepted by Login
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
assert.Equal(t, tc.want, loginRequestMDMConflicts(tc.msg, policy))
})
}
}
func TestSetConfig_MDMReject_AllOrNothing(t *testing.T) {
// MDM enforces ManagementURL only; user request touches both the
// enforced field AND a non-enforced field (RosenpassEnabled).
// The whole request must be rejected — non-conflicting fields are not
// applied either.
s, ctx, profName, username, cfgPath := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "https://mdm.example.com:443",
}))
rosenpassEnabled := true
_, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
ManagementUrl: "https://user.tried.this.com:443",
RosenpassEnabled: &rosenpassEnabled,
})
v := extractViolation(t, err)
assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields())
// Confirm RosenpassEnabled was NOT applied even though it was not
// in the conflict list: the request was rejected as a whole.
reloaded, err := profilemanager.GetConfig(cfgPath)
require.NoError(t, err)
assert.False(t, reloaded.RosenpassEnabled, "non-conflicting field must not be applied when request is rejected")
}
func TestSetConfig_MDMAllow_NonManagedFields(t *testing.T) {
// MDM enforces ManagementURL but the user only writes RosenpassEnabled.
// Request must succeed.
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: "https://mdm.example.com:443",
}))
rosenpassEnabled := true
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
RosenpassEnabled: &rosenpassEnabled,
})
require.NoError(t, err)
require.NotNil(t, resp)
}
// TestSetConfig_MDMAllow_ManagementURLPortNormalized covers the
// regression from discussion #6483: MDM URL without explicit port vs
// UI echo with the parseURL-appended default port must be treated as
// a no-op echo, not a conflict.
func TestSetConfig_MDMAllow_ManagementURLPortNormalized(t *testing.T) {
tests := []struct {
name string
mdmURL string
submitURL string
}{
{"policy_no_port_submit_with_443", "https://netbird.corp.example", "https://netbird.corp.example:443"},
{"policy_with_443_submit_no_port", "https://netbird.corp.example:443", "https://netbird.corp.example"},
{"http_policy_no_port_submit_with_80", "http://netbird.corp.example", "http://netbird.corp.example:80"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(map[string]any{
mdm.KeyManagementURL: tc.mdmURL,
}))
rosenpassEnabled := true
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
ManagementUrl: tc.submitURL,
RosenpassEnabled: &rosenpassEnabled,
})
require.NoError(t, err, "port-normalized URL echo must not trip MDM conflict gate")
require.NotNil(t, resp)
})
}
}
func TestSetConfig_MDMEmpty_NoEnforcement(t *testing.T) {
// No MDM policy active: any field can be written.
s, ctx, profName, username, _ := setupServerWithProfile(t)
withMDMPolicy(t, s, mdm.NewPolicy(nil))
resp, err := s.SetConfig(ctx, &proto.SetConfigRequest{
ProfileName: profName,
Username: username,
ManagementUrl: "https://user.changed.url.com:443",
})
require.NoError(t, err)
require.NotNil(t, resp)
}
+67 -46
View File
@@ -1,7 +1,6 @@
package server
import (
"context"
"os/user"
"path/filepath"
"reflect"
@@ -47,17 +46,22 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
pm := profilemanager.ServiceManager{}
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
Name: profName,
ID: profilemanager.ID(profName),
Username: currUser.Username,
})
require.NoError(t, err)
ctx := context.Background()
s := New(ctx, "console", "", false, false, false)
// The privileged-change gate reads the caller's kernel identity from the
// context, which a real caller gets from the daemon's transport credentials.
// This test drives the handler directly, so it stands in for a root caller;
// without an identity the gate would (correctly) refuse the SSH fields.
ctx := privilegedTestCtx()
s := New(ctx, "console", "", false, false, false, false)
rosenpassEnabled := true
rosenpassPermissive := true
serverSSHAllowed := true
remoteJobsAllowed := true
interfaceName := "utun100"
wireguardPort := int64(51820)
preSharedKey := "test-psk"
@@ -69,48 +73,53 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
disableFirewall := true
blockLANAccess := true
disableNotifications := true
lazyConnectionEnabled := true
blockInbound := true
disableIPv6 := true
mtu := int64(1280)
sshJWTCacheTTL := int32(300)
enableLocalMetrics := true
localMetricsAddress := "127.0.0.1:9292"
req := &proto.SetConfigRequest{
ProfileName: profName,
Username: currUser.Username,
ManagementUrl: "https://new-api.netbird.io:443",
AdminURL: "https://new-admin.netbird.io",
RosenpassEnabled: &rosenpassEnabled,
RosenpassPermissive: &rosenpassPermissive,
ServerSSHAllowed: &serverSSHAllowed,
InterfaceName: &interfaceName,
WireguardPort: &wireguardPort,
OptionalPreSharedKey: &preSharedKey,
DisableAutoConnect: &disableAutoConnect,
NetworkMonitor: &networkMonitor,
DisableClientRoutes: &disableClientRoutes,
DisableServerRoutes: &disableServerRoutes,
DisableDns: &disableDNS,
DisableFirewall: &disableFirewall,
BlockLanAccess: &blockLANAccess,
DisableNotifications: &disableNotifications,
LazyConnectionEnabled: &lazyConnectionEnabled,
BlockInbound: &blockInbound,
NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"},
CleanNATExternalIPs: false,
CustomDNSAddress: []byte("1.1.1.1:53"),
ExtraIFaceBlacklist: []string{"eth1", "eth2"},
DnsLabels: []string{"label1", "label2"},
CleanDNSLabels: false,
DnsRouteInterval: durationpb.New(2 * time.Minute),
Mtu: &mtu,
SshJWTCacheTTL: &sshJWTCacheTTL,
ProfileName: profName,
Username: currUser.Username,
ManagementUrl: "https://new-api.netbird.io:443",
AdminURL: "https://new-admin.netbird.io",
RosenpassEnabled: &rosenpassEnabled,
RosenpassPermissive: &rosenpassPermissive,
ServerSSHAllowed: &serverSSHAllowed,
RemoteJobsAllowed: &remoteJobsAllowed,
InterfaceName: &interfaceName,
WireguardPort: &wireguardPort,
OptionalPreSharedKey: &preSharedKey,
DisableAutoConnect: &disableAutoConnect,
NetworkMonitor: &networkMonitor,
DisableClientRoutes: &disableClientRoutes,
DisableServerRoutes: &disableServerRoutes,
DisableDns: &disableDNS,
DisableFirewall: &disableFirewall,
BlockLanAccess: &blockLANAccess,
DisableNotifications: &disableNotifications,
BlockInbound: &blockInbound,
DisableIpv6: &disableIPv6,
NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"},
CleanNATExternalIPs: false,
CustomDNSAddress: []byte("1.1.1.1:53"),
ExtraIFaceBlacklist: []string{"eth1", "eth2"},
DnsLabels: []string{"label1", "label2"},
CleanDNSLabels: false,
DnsRouteInterval: durationpb.New(2 * time.Minute),
Mtu: &mtu,
SshJWTCacheTTL: &sshJWTCacheTTL,
EnableLocalMetrics: &enableLocalMetrics,
LocalMetricsAddress: &localMetricsAddress,
}
_, err = s.SetConfig(ctx, req)
require.NoError(t, err)
profState := profilemanager.ActiveProfileState{
Name: profName,
ID: profilemanager.ID(profName),
Username: currUser.Username,
}
cfgPath, err := profState.FilePath()
@@ -125,6 +134,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
require.NotNil(t, cfg.ServerSSHAllowed)
require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed)
require.NotNil(t, cfg.RemoteJobsAllowed)
require.Equal(t, remoteJobsAllowed, *cfg.RemoteJobsAllowed)
require.Equal(t, interfaceName, cfg.WgIface)
require.Equal(t, int(wireguardPort), cfg.WgPort)
require.Equal(t, preSharedKey, cfg.PreSharedKey)
@@ -138,8 +149,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.Equal(t, blockLANAccess, cfg.BlockLANAccess)
require.NotNil(t, cfg.DisableNotifications)
require.Equal(t, disableNotifications, *cfg.DisableNotifications)
require.Equal(t, lazyConnectionEnabled, cfg.LazyConnectionEnabled)
require.Equal(t, blockInbound, cfg.BlockInbound)
require.Equal(t, disableIPv6, cfg.DisableIPv6)
require.Equal(t, []string{"1.2.3.4", "5.6.7.8"}, cfg.NATExternalIPs)
require.Equal(t, "1.1.1.1:53", cfg.CustomDNSAddress)
// IFaceBlackList contains defaults + extras
@@ -150,6 +161,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
require.Equal(t, uint16(mtu), cfg.MTU)
require.NotNil(t, cfg.SSHJWTCacheTTL)
require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL)
require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled)
require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress)
verifyAllFieldsCovered(t, req)
}
@@ -161,13 +174,14 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
t.Helper()
metadataFields := map[string]bool{
"state": true, // protobuf internal
"sizeCache": true, // protobuf internal
"unknownFields": true, // protobuf internal
"Username": true, // metadata
"ProfileName": true, // metadata
"CleanNATExternalIPs": true, // control flag for clearing
"CleanDNSLabels": true, // control flag for clearing
"state": true, // protobuf internal
"sizeCache": true, // protobuf internal
"unknownFields": true, // protobuf internal
"Username": true, // metadata
"ProfileName": true, // metadata
"CleanNATExternalIPs": true, // control flag for clearing
"CleanDNSLabels": true, // control flag for clearing
"LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied
}
expectedFields := map[string]bool{
@@ -176,6 +190,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"RosenpassEnabled": true,
"RosenpassPermissive": true,
"ServerSSHAllowed": true,
"RemoteJobsAllowed": true,
"InterfaceName": true,
"WireguardPort": true,
"OptionalPreSharedKey": true,
@@ -187,8 +202,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"DisableFirewall": true,
"BlockLanAccess": true,
"DisableNotifications": true,
"LazyConnectionEnabled": true,
"BlockInbound": true,
"DisableIpv6": true,
"NatExternalIPs": true,
"CustomDNSAddress": true,
"ExtraIFaceBlacklist": true,
@@ -201,6 +216,8 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
"EnableSSHRemotePortForwarding": true,
"DisableSSHAuth": true,
"SshJWTCacheTTL": true,
"EnableLocalMetrics": true,
"LocalMetricsAddress": true,
}
val := reflect.ValueOf(req).Elem()
@@ -236,6 +253,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
"enable-rosenpass": "RosenpassEnabled",
"rosenpass-permissive": "RosenpassPermissive",
"allow-server-ssh": "ServerSSHAllowed",
"allow-remote-jobs": "RemoteJobsAllowed",
"interface-name": "InterfaceName",
"wireguard-port": "WireguardPort",
"preshared-key": "OptionalPreSharedKey",
@@ -247,7 +265,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
"disable-firewall": "DisableFirewall",
"block-lan-access": "BlockLanAccess",
"block-inbound": "BlockInbound",
"enable-lazy-connection": "LazyConnectionEnabled",
"disable-ipv6": "DisableIpv6",
"external-ip-map": "NatExternalIPs",
"dns-resolver-address": "CustomDNSAddress",
"extra-iface-blacklist": "ExtraIFaceBlacklist",
@@ -260,11 +278,14 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
"enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding",
"disable-ssh-auth": "DisableSSHAuth",
"ssh-jwt-cache-ttl": "SshJWTCacheTTL",
"enable-local-metrics": "EnableLocalMetrics",
"local-metrics-address": "LocalMetricsAddress",
}
// SetConfigRequest fields that don't have CLI flags (settable only via UI or other means).
fieldsWithoutCLIFlags := map[string]bool{
"DisableNotifications": true, // Only settable via UI
"DisableNotifications": true, // Only settable via UI
"LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN)
}
// Get all SetConfigRequest fields to verify our map is complete.
+61 -14
View File
@@ -2,13 +2,18 @@ package server
import (
"context"
"os"
"strconv"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/sleep"
"github.com/netbirdio/netbird/client/proto"
)
const envDisableSleepDetector = "NB_DISABLE_SLEEP_DETECTOR"
// serverAgent adapts Server to the handler.Agent and handler.StatusChecker interfaces
type serverAgent struct {
s *Server
@@ -28,19 +33,61 @@ func (a *serverAgent) Status() (internal.StatusType, error) {
return internal.CtxGetState(a.s.rootCtx).Status()
}
// NotifyOSLifecycle handles operating system lifecycle events by executing appropriate logic based on the request type.
func (s *Server) NotifyOSLifecycle(callerCtx context.Context, req *proto.OSLifecycleRequest) (*proto.OSLifecycleResponse, error) {
switch req.GetType() {
case proto.OSLifecycleRequest_WAKEUP:
if err := s.sleepHandler.HandleWakeUp(callerCtx); err != nil {
return &proto.OSLifecycleResponse{}, err
}
case proto.OSLifecycleRequest_SLEEP:
if err := s.sleepHandler.HandleSleep(callerCtx); err != nil {
return &proto.OSLifecycleResponse{}, err
}
default:
log.Errorf("unknown OSLifecycleRequest type: %v", req.GetType())
// startSleepDetector starts the OS sleep/wake detector and forwards events to
// the sleep handler. On platforms without a supported detector the attempt
// logs a warning and returns. Setting NB_DISABLE_SLEEP_DETECTOR=true skips
// registration entirely.
func (s *Server) startSleepDetector() {
if sleepDetectorDisabled() {
log.Info("sleep detection disabled via " + envDisableSleepDetector)
return
}
return &proto.OSLifecycleResponse{}, nil
svc, err := sleep.New()
if err != nil {
log.Warnf("failed to initialize sleep detection: %v", err)
return
}
err = svc.Register(func(event sleep.EventType) {
switch event {
case sleep.EventTypeSleep:
log.Info("handling sleep event")
if err := s.sleepHandler.HandleSleep(s.rootCtx); err != nil {
log.Errorf("failed to handle sleep event: %v", err)
}
case sleep.EventTypeWakeUp:
log.Info("handling wakeup event")
if err := s.sleepHandler.HandleWakeUp(s.rootCtx); err != nil {
log.Errorf("failed to handle wakeup event: %v", err)
}
}
})
if err != nil {
log.Errorf("failed to register sleep detector: %v", err)
return
}
log.Info("sleep detection service initialized")
go func() {
<-s.rootCtx.Done()
log.Info("stopping sleep event listener")
if err := svc.Deregister(); err != nil {
log.Errorf("failed to deregister sleep detector: %v", err)
}
}()
}
func sleepDetectorDisabled() bool {
val := os.Getenv(envDisableSleepDetector)
if val == "" {
return false
}
disabled, err := strconv.ParseBool(val)
if err != nil {
log.Warnf("failed to parse %s=%q: %v", envDisableSleepDetector, val, err)
return false
}
return disabled
}
+351
View File
@@ -0,0 +1,351 @@
package server
import (
"context"
"fmt"
"net/url"
"runtime"
"strings"
log "github.com/sirupsen/logrus"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/localmetrics"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
// The daemon runs as root/LocalSystem, so a handful of config changes cross the
// user-to-root boundary and are restricted to privileged callers:
//
// - Enabling SSH root login, or disabling SSH authentication, turns the
// daemon's SSH server into a root (or unauthenticated) shell.
// - Enabling the SSH server at all is what makes the above reachable, and a
// profile the caller owns is not a privilege they hold.
// - While the SSH server is enabled, repointing the profile at another
// management identity hands SSH authorization decisions, including which
// keys and users are accepted, to whoever controls that identity. Changing
// the management URL and deregistering the peer are both ways to do that.
// - Binding the local metrics endpoint to a non-loopback address publishes
// peer names and connectivity state to the network without authentication.
//
// Everything else stays unauthenticated, so this is not an authorization model:
// it only refuses the changes that would let a local user become root. A caller
// whose identity cannot be established is refused as well.
// privilegedConfigChange is the subset of a config request that crosses the
// user-to-root boundary. Fields are nil or empty when the request leaves them
// untouched.
type privilegedConfigChange struct {
managementURL string
serverSSHAllowed *bool
remoteJobsAllowed *bool
enableSSHRoot *bool
disableSSHAuth *bool
enableLocalMetrics *bool
localMetricsAddress *string
}
func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange {
return privilegedConfigChange{
managementURL: msg.GetManagementUrl(),
serverSSHAllowed: msg.ServerSSHAllowed,
remoteJobsAllowed: msg.RemoteJobsAllowed,
enableSSHRoot: msg.EnableSSHRoot,
disableSSHAuth: msg.DisableSSHAuth,
enableLocalMetrics: msg.EnableLocalMetrics,
localMetricsAddress: msg.LocalMetricsAddress,
}
}
func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange {
return privilegedConfigChange{
managementURL: msg.GetManagementUrl(),
serverSSHAllowed: msg.ServerSSHAllowed,
remoteJobsAllowed: msg.RemoteJobsAllowed,
enableSSHRoot: msg.EnableSSHRoot,
disableSSHAuth: msg.DisableSSHAuth,
enableLocalMetrics: msg.EnableLocalMetrics,
localMetricsAddress: msg.LocalMetricsAddress,
}
}
// requirePrivilegeForConfigChange refuses the privileged parts of a config
// change when the caller is not root/administrator. stored is the profile's
// current config, or nil when it has none yet.
//
// Each check compares against the stored value so that a request restating a
// value it does not change is never refused: a UI that submits the whole
// settings form must not start failing once an administrator has enabled SSH.
func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager.Config, change privilegedConfigChange) error {
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.EnableSSHRoot }), change.enableSSHRoot) {
return denyPrivileged(ctx, "enabling SSH root login", ipcauth.UpCommand("--enable-ssh-root"))
}
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.DisableSSHAuth }), change.disableSSHAuth) {
return denyPrivileged(ctx, "disabling SSH authentication", ipcauth.UpCommand("--disable-ssh-auth"))
}
if enables(sshServerCurrentlyAllowed(stored), change.serverSSHAllowed) {
return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh"))
}
// Enabling remote jobs lets the management server run jobs (e.g. debug
// bundles) on this host, so turning it on crosses the user-to-root
// boundary the same way enabling the SSH server does. The stored value
// defaults to off (nil = off), so a legacy config is correctly seen as
// off and turning it on requires privilege.
if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.RemoteJobsAllowed }), change.remoteJobsAllowed) {
return denyPrivileged(ctx, "enabling remote jobs", ipcauth.UpCommand("--allow-remote-jobs"))
}
if addr, exposes := exposesLocalMetrics(stored, change); exposes {
return denyPrivileged(ctx,
"exposing the local metrics endpoint on a non-loopback address",
ipcauth.UpCommand("--enable-local-metrics --local-metrics-address "+addr))
}
// Only guard the management binding while the SSH server is enabled: that is
// when the management identity decides who may open a shell here.
if !sshServerEnabled(stored) {
return nil
}
if change.managementURL != "" && !sameManagementURL(stored.ManagementURL, change.managementURL) {
return denyPrivileged(ctx,
"changing the management URL while the NetBird SSH server is enabled",
ipcauth.UpCommand("-m "+change.managementURL))
}
return nil
}
// requirePrivilegeForDeregistration refuses to deregister the peer from the
// management server when the caller is not privileged and the profile has the
// SSH server enabled. Deregistering frees the peer's key to be registered
// against another management identity, which is the same handover the
// management URL check refuses.
//
// Callers that treat deregistration as best-effort (profile removal) continue
// without it; callers that were asked to deregister surface the error.
func requirePrivilegeForDeregistration(ctx context.Context, cfg *profilemanager.Config) error {
if !sshServerEnabled(cfg) {
return nil
}
return denyPrivileged(ctx,
"deregistering this peer while the NetBird SSH server is enabled",
ipcauth.ElevatedCommand("netbird logout"))
}
// denyPrivileged returns nil when the caller is privileged, and otherwise a
// PermissionDenied whose message names the action and the command that performs
// it with the privileges it needs. The same summary and command ride along as an
// ErrorInfo detail so the CLI and the UI can present them without parsing text.
//
// action reads as the subject of a sentence ("enabling SSH root login"), and
// command is the equivalent command, already elevated for the platform.
func denyPrivileged(ctx context.Context, action, command string) error {
id, ok := ipcauth.CallerIdentity(ctx)
if !ok {
log.Warnf("denying %s: the caller's identity cannot be verified on this control channel", action)
return privilegeError(unidentifiedSummary(action), reinstallCommand())
}
if ipcauth.IsPrivilegedCaller(id) {
log.Infof("allowing %s for privileged caller %s", action, id)
return nil
}
log.Warnf("denying %s for unprivileged caller %s", action, id)
actor, command := requiredActor(command)
return privilegeError(privilegeSummary(action, actor), command)
}
// requiredActor names who may perform the operation and adjusts the command to
// match. A daemon that is not itself privileged delegates to its own identity, so
// telling that host's user to become root is wrong twice over: root is not what the
// daemon checks for, and a rootless container has neither root nor sudo.
func requiredActor(command string) (string, string) {
self, delegates := ipcauth.SelfDelegatesTo()
if !delegates {
return ipcauth.PrivilegedActor(), command
}
return fmt.Sprintf("the user the daemon runs as (%s)", self), strings.ReplaceAll(command, "sudo ", "")
}
// privilegeError builds the PermissionDenied carrying summary and command.
func privilegeError(summary, command string) error {
st := gstatus.New(codes.PermissionDenied, fmt.Sprintf("%s\n\n%s", summary, command))
detailed, err := st.WithDetails(&errdetails.ErrorInfo{
Reason: ipcauth.ErrorReasonPrivilegeRequired,
Domain: ipcauth.ErrorDomain,
Metadata: map[string]string{
ipcauth.ErrorMetaSummary: summary,
ipcauth.ErrorMetaCommand: command,
},
})
if err != nil {
log.Debugf("attach privilege error detail: %v", err)
return st.Err()
}
return detailed.Err()
}
// privilegeSummary states what is refused and what it needs, in one sentence
// that reads the same in a dialog and in a terminal.
func privilegeSummary(action, actor string) string {
return fmt.Sprintf("%s requires %s.", capitalize(action), actor)
}
// unidentifiedSummary covers a control channel that carries no caller identity.
// Elevating does not help there, so it points at the daemon's socket instead.
func unidentifiedSummary(action string) string {
return fmt.Sprintf("%s requires %s, and the daemon cannot verify who is calling over its current socket. "+
"Reinstall the service on a socket that carries the caller's identity.", capitalize(action), ipcauth.PrivilegedActor())
}
// reinstallCommand is the command that moves the daemon onto a socket whose
// callers can be identified.
func reinstallCommand() string {
if runtime.GOOS == "windows" {
return fmt.Sprintf("netbird service install --daemon-addr %s", daemonaddr.WindowsPipeAddr)
}
return "sudo netbird service install --daemon-addr unix:///var/run/netbird.sock"
}
func capitalize(s string) string {
if s == "" {
return s
}
return strings.ToUpper(s[:1]) + s[1:]
}
// enables reports whether requested turns a flag on that is currently off. A
// request that restates the stored value, or turns the flag off, is not a
// privileged change.
func enables(stored, requested *bool) bool {
if requested == nil || !*requested {
return false
}
return stored == nil || !*stored
}
// storedFlag reads a flag from the stored config, tolerating a config that does
// not exist yet.
func storedFlag(cfg *profilemanager.Config, get func(*profilemanager.Config) *bool) *bool {
if cfg == nil {
return nil
}
return get(cfg)
}
// sshServerEnabled reports whether the profile currently runs the SSH server.
//
// A nil flag means ON, matching what the engine does with the same config
// (util.ReturnBoolWithDefaultTrue in internal/connect.go, kept for configs written
// before the flag existed). Reading it as OFF here would open the management-URL
// and deregistration guards on exactly those legacy hosts, whose SSH server is
// running. Configs loaded through profilemanager have already been materialised by
// apply(), so this is the same answer by a route that does not depend on that.
func sshServerEnabled(cfg *profilemanager.Config) bool {
if cfg == nil {
return false
}
return util.ReturnBoolWithDefaultTrue(cfg.ServerSSHAllowed)
}
// sshServerCurrentlyAllowed is the value an enable request is compared against. It
// shares sshServerEnabled's nil-means-on default, so restating "on" for a legacy
// config is correctly seen as no change.
func sshServerCurrentlyAllowed(cfg *profilemanager.Config) *bool {
enabled := sshServerEnabled(cfg)
if cfg == nil {
return nil
}
return &enabled
}
// exposesLocalMetrics reports whether the change would leave the metrics
// endpoint enabled on an address that is not confirmed loopback, and returns
// that address. A request that restates the stored state is not a change, so a
// settings form resubmitted after an administrator opened the endpoint is not
// refused.
func exposesLocalMetrics(stored *profilemanager.Config, change privilegedConfigChange) (string, bool) {
storedEnabled, storedAddr := storedLocalMetrics(stored)
enabled := storedEnabled
if change.enableLocalMetrics != nil {
enabled = *change.enableLocalMetrics
}
addr := storedAddr
if change.localMetricsAddress != nil {
addr = metricsAddrOrDefault(*change.localMetricsAddress)
}
if !enabled || localmetrics.IsLoopback(addr) {
return "", false
}
if storedEnabled && storedAddr == addr {
return "", false
}
return addr, true
}
// storedLocalMetrics reads the metrics settings from the stored config,
// tolerating a config that does not exist yet.
func storedLocalMetrics(cfg *profilemanager.Config) (bool, string) {
if cfg == nil {
return false, localmetrics.DefaultListenAddress
}
return cfg.LocalMetricsEnabled, metricsAddrOrDefault(cfg.LocalMetricsAddress)
}
func metricsAddrOrDefault(addr string) string {
if addr == "" {
return localmetrics.DefaultListenAddress
}
return addr
}
// sameManagementURL reports whether requested addresses the same management
// server as stored, comparing scheme, host and effective port so that an
// equivalent spelling ("https://api.netbird.io" for a stored
// "https://api.netbird.io:443") is not treated as a change. It fails closed:
// anything unparseable counts as a change and therefore needs privilege.
func sameManagementURL(stored *url.URL, requested string) bool {
if stored == nil {
return false
}
// Normalise the requested URL through the config layer's own parser, so the
// comparison cannot drift from how the value would actually be stored.
parsed, err := profilemanager.ParseServiceURL("Management URL", requested)
if err != nil {
return false
}
return stored.Scheme == parsed.Scheme &&
stored.Hostname() == parsed.Hostname() &&
effectivePort(stored) == effectivePort(parsed)
}
func effectivePort(u *url.URL) string {
if port := u.Port(); port != "" {
return port
}
switch u.Scheme {
case "https":
return "443"
case "http":
return "80"
default:
return ""
}
}
+474
View File
@@ -0,0 +1,474 @@
package server
import (
"context"
"net/url"
"os"
"runtime"
"strings"
"testing"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/peer"
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/ipcauth"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
// ctxWithIdentity builds a request context carrying the identity the transport
// credentials would have attached.
func ctxWithIdentity(id ipcauth.Identity) context.Context {
return peer.NewContext(context.Background(), &peer.Peer{
AuthInfo: ipcauth.AuthInfo{
CommonAuthInfo: credentials.CommonAuthInfo{SecurityLevel: credentials.NoSecurity},
Identity: id,
},
})
}
// unprivUID is deliberately not this process's own uid. An unprivileged daemon
// treats a caller sharing its identity as privileged (rootless containers), and
// the test binary would otherwise stand in for both the daemon and the caller.
// os.Geteuid returns -1 on Windows, where identities are SIDs instead and this is
// unused.
var unprivUID = uint32(os.Geteuid() + 1)
// The fabricated identities have to be shaped like the platform's: a uid says
// nothing on Windows, and a zero uid there would read as root and be privileged.
func rootCtx() context.Context { return ctxWithIdentity(privilegedIdentity()) }
func userCtx() context.Context { return ctxWithIdentity(unprivilegedIdentity()) }
func privilegedIdentity() ipcauth.Identity {
if runtime.GOOS == "windows" {
// LocalSystem, which is what the Windows service account is.
return ipcauth.Identity{SID: "S-1-5-18"}
}
return ipcauth.Identity{UID: 0}
}
func unprivilegedIdentity() ipcauth.Identity {
if runtime.GOOS == "windows" {
// A plain user SID: no groups, so no BUILTIN\Administrators, and not
// elevated.
return ipcauth.Identity{SID: "S-1-5-21-1-2-3-1001"}
}
return ipcauth.Identity{UID: unprivUID, GID: unprivUID}
}
func noIdentityCtx() context.Context { return context.Background() }
func boolPtr(v bool) *bool { return &v }
func strPtr(v string) *string { return &v }
func mustURL(t *testing.T, raw string) *url.URL {
t.Helper()
u, err := url.Parse(raw)
if err != nil {
t.Fatalf("parse %q: %v", raw, err)
}
return u
}
func assertDenied(t *testing.T, err error) {
t.Helper()
if err == nil {
t.Fatal("expected the change to be refused, got nil")
}
st := gstatus.Convert(err)
if st.Code() != codes.PermissionDenied {
t.Fatalf("code = %v, want PermissionDenied", st.Code())
}
// The refusal must be machine-readable: the CLI and the UI render the
// summary and command from the detail rather than parsing the message.
var info *errdetails.ErrorInfo
for _, d := range st.Details() {
if got, ok := d.(*errdetails.ErrorInfo); ok {
info = got
}
}
if info == nil {
t.Fatal("refusal carries no ErrorInfo detail")
}
if info.GetReason() != ipcauth.ErrorReasonPrivilegeRequired || info.GetDomain() != ipcauth.ErrorDomain {
t.Fatalf("detail = %s/%s, want %s/%s", info.GetDomain(), info.GetReason(), ipcauth.ErrorDomain, ipcauth.ErrorReasonPrivilegeRequired)
}
if info.GetMetadata()[ipcauth.ErrorMetaSummary] == "" {
t.Error("detail carries no summary")
}
if info.GetMetadata()[ipcauth.ErrorMetaCommand] == "" {
t.Error("detail carries no command")
}
}
func assertAllowed(t *testing.T, err error) {
t.Helper()
if err != nil {
t.Fatalf("expected the change to be allowed, got %v", err)
}
}
func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) {
tests := []struct {
name string
stored *profilemanager.Config
change privilegedConfigChange
privileged bool
wantDeny bool
}{
{
name: "enabling the ssh server unprivileged is refused",
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
wantDeny: true,
},
{
name: "enabling the ssh server as root is allowed",
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
privileged: true,
},
{
name: "restating an already enabled ssh server is not a change",
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
},
{
name: "turning the ssh server off is not guarded",
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
change: privilegedConfigChange{serverSSHAllowed: boolPtr(false)},
},
{
name: "a profile with no config yet counts as off, so enabling is refused",
stored: nil,
change: privilegedConfigChange{serverSSHAllowed: boolPtr(true)},
wantDeny: true,
},
{
name: "enabling ssh root login unprivileged is refused",
stored: &profilemanager.Config{EnableSSHRoot: boolPtr(false)},
change: privilegedConfigChange{enableSSHRoot: boolPtr(true)},
wantDeny: true,
},
{
name: "restating ssh root login is not a change",
stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)},
change: privilegedConfigChange{enableSSHRoot: boolPtr(true)},
},
{
name: "turning ssh root login off is not guarded",
stored: &profilemanager.Config{EnableSSHRoot: boolPtr(true)},
change: privilegedConfigChange{enableSSHRoot: boolPtr(false)},
},
{
name: "disabling ssh authentication unprivileged is refused",
stored: &profilemanager.Config{DisableSSHAuth: boolPtr(false)},
change: privilegedConfigChange{disableSSHAuth: boolPtr(true)},
wantDeny: true,
},
{
name: "re-enabling ssh authentication is not guarded",
stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)},
change: privilegedConfigChange{disableSSHAuth: boolPtr(false)},
},
{
name: "enabling remote jobs unprivileged is refused",
stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)},
change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
wantDeny: true,
},
{
name: "enabling remote jobs as root is allowed",
stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)},
change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
privileged: true,
},
{
name: "a profile with no config yet counts as off, so enabling remote jobs is refused",
stored: nil,
change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
wantDeny: true,
},
{
name: "restating already-enabled remote jobs is not a change",
stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)},
change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)},
},
{
name: "turning remote jobs off is not guarded",
stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)},
change: privilegedConfigChange{remoteJobsAllowed: boolPtr(false)},
},
{
name: "a request that touches none of the guarded fields is allowed",
stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
change: privilegedConfigChange{},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := userCtx()
if tt.privileged {
ctx = rootCtx()
}
err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change)
if tt.wantDeny {
assertDenied(t, err)
return
}
assertAllowed(t, err)
})
}
}
func TestRequirePrivilegeForConfigChange_LocalMetrics(t *testing.T) {
exposed := &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "0.0.0.0:9191"}
tests := []struct {
name string
stored *profilemanager.Config
change privilegedConfigChange
privileged bool
wantDeny bool
}{
{
name: "binding a non-loopback address unprivileged is refused",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
wantDeny: true,
},
{
name: "binding a non-loopback address as root is allowed",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
privileged: true,
},
{
name: "enabling on the default loopback address is not guarded",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)},
},
{
name: "enabling on an explicit loopback address is not guarded",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("127.0.0.1:9999")},
},
{
name: "enabling on the IPv6 loopback address is not guarded",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("[::1]:9191")},
},
{
// The address alone does nothing while the endpoint stays off.
name: "a non-loopback address without enabling is not guarded",
stored: &profilemanager.Config{},
change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")},
},
{
name: "widening an already enabled loopback endpoint is refused",
stored: &profilemanager.Config{LocalMetricsEnabled: true, LocalMetricsAddress: "127.0.0.1:9191"},
change: privilegedConfigChange{localMetricsAddress: strPtr("0.0.0.0:9191")},
wantDeny: true,
},
{
name: "restating an already exposed endpoint is not a change",
stored: exposed,
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
},
{
name: "turning an exposed endpoint off is not guarded",
stored: exposed,
change: privilegedConfigChange{enableLocalMetrics: boolPtr(false)},
},
{
name: "re-enabling an exposed endpoint that was turned off is refused",
stored: &profilemanager.Config{LocalMetricsEnabled: false, LocalMetricsAddress: "0.0.0.0:9191"},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true)},
wantDeny: true,
},
{
// Fail closed: an address that cannot be parsed is not confirmed loopback.
name: "an unparseable address is refused",
stored: &profilemanager.Config{},
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("not-an-address")},
wantDeny: true,
},
{
name: "a profile with no config yet counts as off, so exposing is refused",
stored: nil,
change: privilegedConfigChange{enableLocalMetrics: boolPtr(true), localMetricsAddress: strPtr("0.0.0.0:9191")},
wantDeny: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := userCtx()
if tt.privileged {
ctx = rootCtx()
}
err := requirePrivilegeForConfigChange(ctx, tt.stored, tt.change)
if tt.wantDeny {
assertDenied(t, err)
return
}
assertAllowed(t, err)
})
}
}
func TestRequirePrivilegeForConfigChange_ManagementURL(t *testing.T) {
sshOn := func(raw string) *profilemanager.Config {
return &profilemanager.Config{ServerSSHAllowed: boolPtr(true), ManagementURL: mustURL(t, raw)}
}
sshOff := func(raw string) *profilemanager.Config {
return &profilemanager.Config{ServerSSHAllowed: boolPtr(false), ManagementURL: mustURL(t, raw)}
}
tests := []struct {
name string
stored *profilemanager.Config
requested string
privileged bool
wantDeny bool
}{
{
name: "moving the binding while ssh is enabled is refused",
stored: sshOn("https://api.netbird.io:443"),
requested: "https://attacker.example.com:443",
wantDeny: true,
},
{
name: "moving the binding as root is allowed",
stored: sshOn("https://api.netbird.io:443"),
requested: "https://selfhosted.example.com:443",
privileged: true,
},
{
name: "the same url restated is not a change",
stored: sshOn("https://api.netbird.io:443"),
requested: "https://api.netbird.io:443",
},
{
name: "an equivalent spelling of the same url is not a change",
stored: sshOn("https://api.netbird.io:443"),
requested: "https://api.netbird.io",
},
{
name: "an equivalent spelling with an explicit http port is not a change",
stored: sshOn("http://mgmt.internal:80"),
requested: "http://mgmt.internal",
},
{
name: "a different port on the same host is a change",
stored: sshOn("https://api.netbird.io:443"),
requested: "https://api.netbird.io:8443",
wantDeny: true,
},
{
name: "a different scheme on the same host is a change",
stored: sshOn("https://mgmt.internal:443"),
requested: "http://mgmt.internal:443",
wantDeny: true,
},
{
name: "with ssh disabled the binding is not guarded at all",
stored: sshOff("https://api.netbird.io:443"),
requested: "https://attacker.example.com:443",
},
{
name: "an unparseable url fails closed",
stored: sshOn("https://api.netbird.io:443"),
requested: "ht tp://%zz",
wantDeny: true,
},
{
name: "an empty url leaves the binding alone",
stored: sshOn("https://api.netbird.io:443"),
requested: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := userCtx()
if tt.privileged {
ctx = rootCtx()
}
err := requirePrivilegeForConfigChange(ctx, tt.stored, privilegedConfigChange{managementURL: tt.requested})
if tt.wantDeny {
assertDenied(t, err)
return
}
assertAllowed(t, err)
})
}
}
// A caller the daemon cannot identify must be refused, not trusted: that is the
// state on a TCP daemon socket, where no peer credentials exist.
func TestRequirePrivilegeForConfigChange_UnidentifiedCallerIsRefused(t *testing.T) {
err := requirePrivilegeForConfigChange(noIdentityCtx(),
&profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
privilegedConfigChange{serverSSHAllowed: boolPtr(true)})
assertDenied(t, err)
// The guidance must point at the socket rather than at sudo, since elevating
// would not help.
st := gstatus.Convert(err)
if !strings.Contains(st.Message(), "service install") {
t.Errorf("message %q does not tell the operator how to fix the socket", st.Message())
}
}
func TestRequirePrivilegeForDeregistration(t *testing.T) {
tests := []struct {
name string
cfg *profilemanager.Config
privileged bool
wantDeny bool
}{
{
name: "deregistering while ssh is enabled is refused",
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
wantDeny: true,
},
{
name: "deregistering while ssh is enabled is allowed for root",
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(true)},
privileged: true,
},
{
name: "deregistering with ssh disabled is not guarded",
cfg: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)},
},
{
name: "deregistering a profile with no config is not guarded",
cfg: nil,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := userCtx()
if tt.privileged {
ctx = rootCtx()
}
err := requirePrivilegeForDeregistration(ctx, tt.cfg)
if tt.wantDeny {
assertDenied(t, err)
return
}
assertAllowed(t, err)
})
}
}
// privilegedTestCtx is the context a handler-level test should use when it is
// standing in for a root/administrator caller. Tests that drive the handlers
// directly have no transport credentials, and the privileged-change gate refuses
// a caller it cannot identify.
func privilegedTestCtx() context.Context { return rootCtx() }
+3 -3
View File
@@ -46,7 +46,7 @@ func (s *Server) CleanState(ctx context.Context, req *proto.CleanStateRequest) (
if req.All {
// Reuse existing cleanup logic for all states
if err := restoreResidualState(ctx, statePath); err != nil {
if err := RestoreResidualState(ctx, statePath); err != nil {
return nil, status.Errorf(codes.Internal, "failed to clean all states: %v", err)
}
@@ -113,9 +113,9 @@ func (s *Server) DeleteState(ctx context.Context, req *proto.DeleteStateRequest)
}, nil
}
// restoreResidualState checks if the client was not shut down in a clean way and restores residual if required.
// RestoreResidualState checks if the client was not shut down in a clean way and restores residual if required.
// Otherwise, we might not be able to connect to the management server to retrieve new config.
func restoreResidualState(ctx context.Context, statePath string) error {
func RestoreResidualState(ctx context.Context, statePath string) error {
if statePath == "" {
return nil
}
+57
View File
@@ -0,0 +1,57 @@
package server
import (
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/proto"
)
// SubscribeStatus pushes a fresh StatusResponse on every connection state
// change. The first message is the current snapshot, so a re-subscribing
// client doesn't need to also call Status. Subsequent messages fire when
// the peer recorder reports any of: connected/disconnected/connecting,
// management or signal flip, address change, or peers list change.
//
// The change channel coalesces bursts to a single tick. If the consumer
// is slow the daemon drops extras (not blocks), and the next snapshot
// the consumer pulls already reflects everything.
func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
subID, ch := s.statusRecorder.SubscribeToStateChanges()
defer func() {
s.statusRecorder.UnsubscribeFromStateChanges(subID)
log.Debug("client unsubscribed from status updates")
}()
log.Debug("client subscribed to status updates")
if err := s.sendStatusSnapshot(req, stream); err != nil {
return err
}
for {
select {
case _, ok := <-ch:
if !ok {
return nil
}
if err := s.sendStatusSnapshot(req, stream); err != nil {
return err
}
case <-stream.Context().Done():
return nil
}
}
}
func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
resp, err := s.buildStatusResponse(stream.Context(), req)
if err != nil {
log.Warnf("build status snapshot for stream: %v", err)
return err
}
if err := stream.Send(resp); err != nil {
log.Warnf("send status snapshot to stream: %v", err)
return err
}
return nil
}
+63 -11
View File
@@ -24,14 +24,9 @@ func (s *Server) TracePacket(_ context.Context, req *proto.TracePacketRequest) (
return nil, err
}
srcAddr, err := s.parseAddress(req.GetSourceIp(), engine)
srcAddr, dstAddr, err := s.resolveTraceAddresses(req.GetSourceIp(), req.GetDestinationIp(), engine)
if err != nil {
return nil, fmt.Errorf("invalid source IP address: %w", err)
}
dstAddr, err := s.parseAddress(req.GetDestinationIp(), engine)
if err != nil {
return nil, fmt.Errorf("invalid destination IP address: %w", err)
return nil, err
}
protocol, err := s.parseProtocol(req.GetProtocol())
@@ -89,16 +84,73 @@ func (s *Server) getPacketTracer() (packetTracer, *internal.Engine, error) {
return tracer, engine, nil
}
func (s *Server) parseAddress(addr string, engine *internal.Engine) (netip.Addr, error) {
if addr == "self" {
return engine.GetWgAddr(), nil
// resolveTraceAddresses parses src/dst, resolving "self" to the local overlay
// address matching the peer's address family.
func (s *Server) resolveTraceAddresses(src, dst string, engine *internal.Engine) (netip.Addr, netip.Addr, error) {
srcSelf := src == "self"
dstSelf := dst == "self"
if srcSelf && dstSelf {
return netip.Addr{}, netip.Addr{}, fmt.Errorf("both source and destination cannot be 'self'")
}
var srcAddr, dstAddr netip.Addr
var err error
// Parse the non-self address first so we know the family for self resolution.
if !srcSelf {
if srcAddr, err = parseAddr(src); err != nil {
return netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid source IP: %w", err)
}
}
if !dstSelf {
if dstAddr, err = parseAddr(dst); err != nil {
return netip.Addr{}, netip.Addr{}, fmt.Errorf("invalid destination IP: %w", err)
}
}
// Determine the peer address to pick the right self address.
peer := srcAddr
if srcSelf {
peer = dstAddr
}
if srcSelf {
if srcAddr, err = selfAddr(engine, peer); err != nil {
return netip.Addr{}, netip.Addr{}, err
}
}
if dstSelf {
if dstAddr, err = selfAddr(engine, peer); err != nil {
return netip.Addr{}, netip.Addr{}, err
}
}
return srcAddr, dstAddr, nil
}
func selfAddr(engine *internal.Engine, peer netip.Addr) (netip.Addr, error) {
var addr netip.Addr
if peer.Is6() {
addr = engine.GetWgV6Addr()
} else {
addr = engine.GetWgAddr()
}
if !addr.IsValid() {
family := "IPv4"
if peer.Is6() {
family = "IPv6"
}
return netip.Addr{}, fmt.Errorf("no local %s overlay address configured", family)
}
return addr, nil
}
func parseAddr(addr string) (netip.Addr, error) {
a, err := netip.ParseAddr(addr)
if err != nil {
return netip.Addr{}, err
}
return a.Unmap(), nil
}