mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-26 16:49:08 +02:00
The remote job and both mobile SDKs applied the debugBundleUploadURL policy; the daemon did not. So `netbird debug bundle -U` and the desktop UI — the two paths a person drives by hand, and the ones a policy exists to constrain — ignored the pin and uploaded wherever the caller or the management server said. Read the policy under the same lock that already reads the management URL, and pass it to the resolver, which puts it ahead of the caller's URL. Reported by @pappz in review on #7514.
302 lines
10 KiB
Go
302 lines
10 KiB
Go
//go:build !android && !ios
|
|
|
|
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"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(callerCtx context.Context, req *proto.DebugBundleRequest) (resp *proto.DebugBundleResponse, err error) {
|
|
if err := requirePrivilegeForUploadURL(callerCtx, req.GetUploadURL(), req.GetUploadInsecure(), req.GetUpload()); 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, publishedUploadURL, mdmUploadURL, err := s.generateDebugBundle(req, uiLogOpener(callerID, callerIdentified))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !req.GetUpload() && req.GetUploadURL() == "" {
|
|
return &proto.DebugBundleResponse{Path: path}, nil
|
|
}
|
|
|
|
// The destination the management server publishes needs no privilege check:
|
|
// it is the operator of this deployment naming their own upload service, and
|
|
// the peer already trusts that server for its whole configuration. Only a
|
|
// URL the local caller named goes through requirePrivilegeForUploadURL above.
|
|
if mdmUploadURL != "" && req.GetUploadURL() != "" && mdmUploadURL != req.GetUploadURL() {
|
|
log.Infof("using the MDM debug bundle upload URL instead of the one the caller named")
|
|
}
|
|
uploadURL := debug.ResolveUploadURL(mdmUploadURL, req.GetUploadURL(), publishedUploadURL)
|
|
|
|
// 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, uploadURL, managementURL, path, req.GetUploadInsecure())
|
|
if err != nil {
|
|
log.Errorf("failed to upload debug bundle to %s: %v", redactUploadURL(uploadURL), err)
|
|
return &proto.DebugBundleResponse{Path: path, UploadFailureReason: err.Error()}, nil
|
|
}
|
|
|
|
log.Infof("debug bundle uploaded to %s with key %s", redactUploadURL(uploadURL), key)
|
|
|
|
return &proto.DebugBundleResponse{Path: path, UploadedKey: key}, nil
|
|
}
|
|
|
|
// redactUploadURL reduces an upload URL to scheme://host for logging. The URL
|
|
// reaches the daemon from the management server or the caller and can carry
|
|
// userinfo or a token in its query, neither of which belongs in a log file that
|
|
// ends up inside the very bundles this uploads.
|
|
func redactUploadURL(raw string) string {
|
|
parsed, err := url.Parse(raw)
|
|
if err != nil || parsed.Host == "" {
|
|
return "(unparsable upload URL)"
|
|
}
|
|
return parsed.Scheme + "://" + parsed.Host
|
|
}
|
|
|
|
// generateDebugBundle builds the bundle under s.mutex and returns its path plus
|
|
// the management URL and the upload service the management server publishes,
|
|
// both 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, publishedUploadURL string, mdmUploadURL string, err error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
syncResponse, err := s.getLatestSyncResponse()
|
|
if err != nil {
|
|
log.Warnf("failed to get latest sync response: %v", err)
|
|
}
|
|
|
|
var clientMetrics debug.MetricsExporter
|
|
if s.connectClient != nil {
|
|
if engine := s.connectClient.Engine(); engine != nil {
|
|
if cm := engine.GetClientMetrics(); cm != nil {
|
|
clientMetrics = cm
|
|
}
|
|
publishedUploadURL = engine.DebugUploadURL()
|
|
}
|
|
}
|
|
|
|
var cpuProfileData []byte
|
|
if s.cpuProfileBuf != nil && !s.cpuProfiling {
|
|
cpuProfileData = s.cpuProfileBuf.Bytes()
|
|
defer func() {
|
|
s.cpuProfileBuf = nil
|
|
}()
|
|
}
|
|
|
|
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")
|
|
// 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)
|
|
}
|
|
}
|
|
}
|
|
|
|
bundleGenerator := debug.NewBundleGenerator(
|
|
debug.GeneratorDependencies{
|
|
InternalConfig: s.config,
|
|
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()
|
|
if err != nil {
|
|
return "", "", "", "", fmt.Errorf("generate debug bundle: %w", err)
|
|
}
|
|
|
|
if s.config != nil {
|
|
if s.config.ManagementURL != nil {
|
|
managementURL = s.config.ManagementURL.String()
|
|
}
|
|
mdmUploadURL = s.config.DebugBundleUploadURL
|
|
}
|
|
|
|
return path, managementURL, publishedUploadURL, mdmUploadURL, nil
|
|
}
|
|
|
|
// GetLogLevel gets the current logging level for the server.
|
|
func (s *Server) GetLogLevel(_ context.Context, _ *proto.GetLogLevelRequest) (*proto.GetLogLevelResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
level := ParseLogLevel(log.GetLevel().String())
|
|
return &proto.GetLogLevelResponse{Level: level}, nil
|
|
}
|
|
|
|
// SetLogLevel sets the logging level for the server.
|
|
func (s *Server) SetLogLevel(_ context.Context, req *proto.SetLogLevelRequest) (*proto.SetLogLevelResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
level, err := log.ParseLevel(req.Level.String())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid log level: %w", err)
|
|
}
|
|
|
|
log.SetLevel(level)
|
|
|
|
if s.connectClient != nil {
|
|
s.connectClient.SetLogLevel(level)
|
|
}
|
|
|
|
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()
|
|
defer s.mutex.Unlock()
|
|
|
|
enabled := req.GetEnabled()
|
|
s.persistSyncResponse = enabled
|
|
if s.connectClient != nil {
|
|
s.connectClient.SetSyncResponsePersistence(enabled)
|
|
}
|
|
|
|
return &proto.SetSyncResponsePersistenceResponse{}, nil
|
|
}
|
|
|
|
func (s *Server) getLatestSyncResponse() (*mgmProto.SyncResponse, error) {
|
|
cClient := s.connectClient
|
|
if cClient == nil {
|
|
return nil, errors.New("connect client is not initialized")
|
|
}
|
|
|
|
return cClient.GetLatestSyncResponse()
|
|
}
|
|
|
|
// StartCPUProfile starts CPU profiling in the daemon.
|
|
func (s *Server) StartCPUProfile(_ context.Context, _ *proto.StartCPUProfileRequest) (*proto.StartCPUProfileResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
if s.cpuProfiling {
|
|
return nil, fmt.Errorf("CPU profiling already in progress")
|
|
}
|
|
|
|
s.cpuProfileBuf = &bytes.Buffer{}
|
|
s.cpuProfiling = true
|
|
if err := pprof.StartCPUProfile(s.cpuProfileBuf); err != nil {
|
|
s.cpuProfileBuf = nil
|
|
s.cpuProfiling = false
|
|
return nil, fmt.Errorf("start CPU profile: %w", err)
|
|
}
|
|
|
|
log.Info("CPU profiling started")
|
|
return &proto.StartCPUProfileResponse{}, nil
|
|
}
|
|
|
|
// StopCPUProfile stops CPU profiling in the daemon.
|
|
func (s *Server) StopCPUProfile(_ context.Context, _ *proto.StopCPUProfileRequest) (*proto.StopCPUProfileResponse, error) {
|
|
s.mutex.Lock()
|
|
defer s.mutex.Unlock()
|
|
|
|
if !s.cpuProfiling {
|
|
return nil, fmt.Errorf("CPU profiling not in progress")
|
|
}
|
|
|
|
pprof.StopCPUProfile()
|
|
s.cpuProfiling = false
|
|
|
|
if s.cpuProfileBuf != nil {
|
|
log.Infof("CPU profiling stopped, captured %d bytes", s.cpuProfileBuf.Len())
|
|
}
|
|
|
|
return &proto.StopCPUProfileResponse{}, nil
|
|
}
|