mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-13 18:29:07 +02:00
Merge remote-tracking branch 'origin/main' into refactor/permissions-manager
This commit is contained in:
@@ -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
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
@@ -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{}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user