mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-27 10:01:28 +02:00
Restrict debug bundle log path and upload destinations
This commit is contained in:
@@ -229,7 +229,9 @@ scutil_dns.txt (macOS only):
|
||||
|
||||
const (
|
||||
clientLogFile = "client.log"
|
||||
uiLogFile = "gui-client.log"
|
||||
// UILogFile is the desktop UI's log file name, exported so the daemon's
|
||||
// RegisterUILog validation shares this single definition.
|
||||
UILogFile = "gui-client.log"
|
||||
errorLogFile = "netbird.err"
|
||||
stdoutLogFile = "netbird.out"
|
||||
|
||||
@@ -248,6 +250,20 @@ type MetricsExporter interface {
|
||||
Export(w io.Writer) error
|
||||
}
|
||||
|
||||
// LogOpener opens a log file for inclusion in the bundle. It exists so that log
|
||||
// files whose path was supplied by an IPC caller can be opened under a check
|
||||
// the daemon defines, instead of being opened with the daemon's privileges
|
||||
// unconditionally.
|
||||
type LogOpener func(path string) (*os.File, error)
|
||||
|
||||
func openLogFile(path string) (*os.File, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
type BundleGenerator struct {
|
||||
anonymizer *anonymize.Anonymizer
|
||||
|
||||
@@ -257,6 +273,7 @@ type BundleGenerator struct {
|
||||
syncResponse *mgmProto.SyncResponse
|
||||
logPath string
|
||||
uiLogPath string
|
||||
uiLogOpener LogOpener
|
||||
tempDir string
|
||||
statePath string
|
||||
cpuProfile []byte
|
||||
@@ -285,14 +302,20 @@ type GeneratorDependencies struct {
|
||||
SyncResponse *mgmProto.SyncResponse
|
||||
LogPath string
|
||||
UILogPath string // Absolute path to the desktop UI's gui-client.log, reported via RegisterUILog. Empty if no UI registered one.
|
||||
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
|
||||
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
|
||||
CPUProfile []byte
|
||||
CapturePath string
|
||||
RefreshStatus func()
|
||||
ClientMetrics MetricsExporter
|
||||
DaemonVersion string
|
||||
CliVersion string
|
||||
// UILogOpener opens the UI log and its rotated siblings. The path comes from
|
||||
// a local IPC caller, so the daemon must not open it with plain os.Open: the
|
||||
// opener is where the caller's right to that file is enforced. Defaults to
|
||||
// os.Open, which is only correct where the path is not caller-supplied
|
||||
// (mobile).
|
||||
UILogOpener LogOpener
|
||||
TempDir string // Directory for temporary bundle zip files. If empty, os.TempDir() is used.
|
||||
StatePath string // Path to the state file. If empty, the ServiceManager default path is used.
|
||||
CPUProfile []byte
|
||||
CapturePath string
|
||||
RefreshStatus func()
|
||||
ClientMetrics MetricsExporter
|
||||
DaemonVersion string
|
||||
CliVersion string
|
||||
}
|
||||
|
||||
func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGenerator {
|
||||
@@ -302,6 +325,11 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
logFileCount = 1
|
||||
}
|
||||
|
||||
uiLogOpener := deps.UILogOpener
|
||||
if uiLogOpener == nil {
|
||||
uiLogOpener = openLogFile
|
||||
}
|
||||
|
||||
return &BundleGenerator{
|
||||
anonymizer: anonymize.NewAnonymizer(anonymize.DefaultAddresses()),
|
||||
|
||||
@@ -310,6 +338,7 @@ func NewBundleGenerator(deps GeneratorDependencies, cfg BundleConfig) *BundleGen
|
||||
syncResponse: deps.SyncResponse,
|
||||
logPath: deps.LogPath,
|
||||
uiLogPath: deps.UILogPath,
|
||||
uiLogOpener: uiLogOpener,
|
||||
tempDir: deps.TempDir,
|
||||
statePath: deps.StatePath,
|
||||
cpuProfile: deps.CPUProfile,
|
||||
@@ -996,11 +1025,11 @@ func (g *BundleGenerator) addLogfile() error {
|
||||
|
||||
logDir := filepath.Dir(g.logPath)
|
||||
|
||||
if err := g.addSingleLogfile(g.logPath, clientLogFile); err != nil {
|
||||
if err := g.addSingleLogfile(openLogFile, g.logPath, clientLogFile); err != nil {
|
||||
return fmt.Errorf("add client log file to zip: %w", err)
|
||||
}
|
||||
|
||||
g.addRotatedLogFiles(logDir, clientLogPrefix)
|
||||
g.addRotatedLogFiles(openLogFile, logDir, clientLogPrefix)
|
||||
|
||||
stdErrLogPath := filepath.Join(logDir, errorLogFile)
|
||||
stdoutLogPath := filepath.Join(logDir, stdoutLogFile)
|
||||
@@ -1009,11 +1038,11 @@ func (g *BundleGenerator) addLogfile() error {
|
||||
stdoutLogPath = darwinStdoutLogPath
|
||||
}
|
||||
|
||||
if err := g.addSingleLogfile(stdErrLogPath, errorLogFile); err != nil {
|
||||
if err := g.addSingleLogfile(openLogFile, stdErrLogPath, errorLogFile); err != nil {
|
||||
log.Warnf("Failed to add %s to zip: %v", errorLogFile, err)
|
||||
}
|
||||
|
||||
if err := g.addSingleLogfile(stdoutLogPath, stdoutLogFile); err != nil {
|
||||
if err := g.addSingleLogfile(openLogFile, stdoutLogPath, stdoutLogFile); err != nil {
|
||||
log.Warnf("Failed to add %s to zip: %v", stdoutLogFile, err)
|
||||
}
|
||||
|
||||
@@ -1030,18 +1059,18 @@ func (g *BundleGenerator) addUILog() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := g.addSingleLogfile(g.uiLogPath, uiLogFile); err != nil {
|
||||
if err := g.addSingleLogfile(g.uiLogOpener, g.uiLogPath, UILogFile); err != nil {
|
||||
return fmt.Errorf("add UI log file to zip: %w", err)
|
||||
}
|
||||
|
||||
g.addRotatedLogFiles(filepath.Dir(g.uiLogPath), uiLogPrefix)
|
||||
g.addRotatedLogFiles(g.uiLogOpener, filepath.Dir(g.uiLogPath), uiLogPrefix)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// addSingleLogfile adds a single log file to the archive
|
||||
func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
|
||||
logFile, err := os.Open(logPath)
|
||||
func (g *BundleGenerator) addSingleLogfile(open LogOpener, logPath, targetName string) error {
|
||||
logFile, err := open(logPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open log file %s: %w", targetName, err)
|
||||
}
|
||||
@@ -1066,8 +1095,8 @@ func (g *BundleGenerator) addSingleLogfile(logPath, targetName string) error {
|
||||
}
|
||||
|
||||
// addSingleLogFileGz adds a single gzipped log file to the archive
|
||||
func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
|
||||
f, err := os.Open(logPath)
|
||||
func (g *BundleGenerator) addSingleLogFileGz(open LogOpener, logPath, targetName string) error {
|
||||
f, err := open(logPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open gz log file %s: %w", targetName, err)
|
||||
}
|
||||
@@ -1114,7 +1143,7 @@ func (g *BundleGenerator) addSingleLogFileGz(logPath, targetName string) error {
|
||||
// addRotatedLogFiles adds rotated log files to the bundle based on logFileCount.
|
||||
// prefix is the base log name without extension (e.g. "client", "gui-client");
|
||||
// the glob matches both files rotated by us and by logrotate on linux.
|
||||
func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) {
|
||||
func (g *BundleGenerator) addRotatedLogFiles(open LogOpener, logDir, prefix string) {
|
||||
if g.logFileCount == 0 {
|
||||
return
|
||||
}
|
||||
@@ -1154,9 +1183,9 @@ func (g *BundleGenerator) addRotatedLogFiles(logDir, prefix string) {
|
||||
for i := 0; i < maxFiles; i++ {
|
||||
name := filepath.Base(files[i])
|
||||
if strings.HasSuffix(name, ".gz") {
|
||||
err = g.addSingleLogFileGz(files[i], name)
|
||||
err = g.addSingleLogFileGz(open, files[i], name)
|
||||
} else {
|
||||
err = g.addSingleLogfile(files[i], name)
|
||||
err = g.addSingleLogfile(open, files[i], name)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnf("failed to add rotated log %s: %v", name, err)
|
||||
|
||||
@@ -27,7 +27,7 @@ func (g *BundleGenerator) addPlatformLog() error {
|
||||
}
|
||||
|
||||
swiftLogPath := filepath.Join(filepath.Dir(g.logPath), swiftLogFile)
|
||||
if err := g.addSingleLogfile(swiftLogPath, swiftLogFile); err != nil {
|
||||
if err := g.addSingleLogfile(openLogFile, swiftLogPath, swiftLogFile); err != nil {
|
||||
// The Swift log is best-effort: the app may not have written it yet.
|
||||
log.Warnf("failed to add %s to debug bundle: %v", swiftLogFile, err)
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ func runAddRotatedLogFilesPrefix(t *testing.T, dir, prefix string, logFileCount
|
||||
archive: zip.NewWriter(&buf),
|
||||
logFileCount: logFileCount,
|
||||
}
|
||||
g.addRotatedLogFiles(dir, prefix)
|
||||
g.addRotatedLogFiles(openLogFile, dir, prefix)
|
||||
require.NoError(t, g.archive.Close())
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
|
||||
|
||||
62
client/internal/debug/uilog_test.go
Normal file
62
client/internal/debug/uilog_test.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package debug
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// bundleEntries generates a bundle with the given generator and returns the
|
||||
// set of entry names in the resulting archive.
|
||||
func bundleEntries(t *testing.T, g *BundleGenerator) map[string]struct{} {
|
||||
t.Helper()
|
||||
|
||||
path, err := g.Generate()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = os.Remove(path) })
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
|
||||
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
|
||||
require.NoError(t, err)
|
||||
|
||||
names := make(map[string]struct{}, len(zr.File))
|
||||
for _, f := range zr.File {
|
||||
names[f.Name] = struct{}{}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func TestBundleIncludesUILogWhenOpenerAllows(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), UILogFile)
|
||||
require.NoError(t, os.WriteFile(path, []byte("gui log"), 0600))
|
||||
|
||||
g := NewBundleGenerator(GeneratorDependencies{
|
||||
UILogPath: path,
|
||||
UILogOpener: openLogFile,
|
||||
}, BundleConfig{})
|
||||
|
||||
require.Contains(t, bundleEntries(t, g), UILogFile)
|
||||
}
|
||||
|
||||
// A UILogOpener that refuses (as the ownership check does for a foreign file)
|
||||
// keeps the UI log out of the bundle without failing bundle generation.
|
||||
func TestBundleExcludesUILogWhenOpenerRefuses(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), UILogFile)
|
||||
require.NoError(t, os.WriteFile(path, []byte("secret"), 0600))
|
||||
|
||||
g := NewBundleGenerator(GeneratorDependencies{
|
||||
UILogPath: path,
|
||||
UILogOpener: func(string) (*os.File, error) {
|
||||
return nil, fmt.Errorf("not owned by the caller")
|
||||
},
|
||||
}, BundleConfig{})
|
||||
|
||||
require.NotContains(t, bundleEntries(t, g), UILogFile)
|
||||
}
|
||||
@@ -3,10 +3,12 @@ package debug
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"os"
|
||||
|
||||
"github.com/netbirdio/netbird/upload-server/types"
|
||||
@@ -14,20 +16,80 @@ import (
|
||||
|
||||
const maxBundleUploadSize = 50 * 1024 * 1024
|
||||
|
||||
func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string) (key string, err error) {
|
||||
response, err := getUploadURL(ctx, url, managementURL)
|
||||
// requireHTTPS refuses any URL the daemon would fetch or upload to that is not
|
||||
// https. The daemon runs as root and the bundle carries its logs and state, so a
|
||||
// plaintext hop is a place to intercept the bundle or the presigned redirect.
|
||||
// The server-side gate already enforces this for the desktop path; this also
|
||||
// covers the mobile and job-runner callers that reach this package directly.
|
||||
// Skipped when the caller opted into an insecure upload (self-hosted server).
|
||||
func requireHTTPS(what, rawURL string) error {
|
||||
parsed, err := neturl.Parse(rawURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", what, err)
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return fmt.Errorf("%s must use https, got scheme %q", what, parsed.Scheme)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// uploadClient returns the HTTP client for the upload requests. The default
|
||||
// client verifies TLS and refuses a redirect that would downgrade to a non-https
|
||||
// hop, so a bundle can never leave over http after an https start. The insecure
|
||||
// variant accepts http and untrusted certificates, and is only reachable for a
|
||||
// privileged caller that passed --upload-bundle-insecure (see
|
||||
// requirePrivilegeForUploadURL).
|
||||
func uploadClient(insecure bool) *http.Client {
|
||||
if !insecure {
|
||||
return &http.Client{CheckRedirect: rejectInsecureRedirect}
|
||||
}
|
||||
return &http.Client{
|
||||
Transport: &http.Transport{
|
||||
//nolint:gosec // opt-in, privileged, self-hosted upload servers
|
||||
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// rejectInsecureRedirect refuses a redirect to a non-https target and keeps the
|
||||
// standard library's 10-hop limit that a custom CheckRedirect would otherwise
|
||||
// disable.
|
||||
func rejectInsecureRedirect(req *http.Request, via []*http.Request) error {
|
||||
if req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("refusing redirect to non-https URL %s", req.URL.Redacted())
|
||||
}
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func UploadDebugBundle(ctx context.Context, url, managementURL, filePath string, insecure bool) (key string, err error) {
|
||||
if !insecure {
|
||||
if err := requireHTTPS("upload service URL", url); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
response, err := getUploadURL(ctx, url, managementURL, insecure)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err = upload(ctx, filePath, response)
|
||||
if !insecure {
|
||||
if err := requireHTTPS("upload URL from service", response.URL); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
|
||||
err = upload(ctx, filePath, response, insecure)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return response.Key, nil
|
||||
}
|
||||
|
||||
func upload(ctx context.Context, filePath string, response *types.GetURLResponse) error {
|
||||
func upload(ctx context.Context, filePath string, response *types.GetURLResponse, insecure bool) error {
|
||||
fileData, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("open file: %w", err)
|
||||
@@ -52,7 +114,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
|
||||
req.ContentLength = stat.Size()
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
|
||||
putResp, err := http.DefaultClient.Do(req)
|
||||
putResp, err := uploadClient(insecure).Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("upload failed: %v", err)
|
||||
}
|
||||
@@ -65,7 +127,7 @@ func upload(ctx context.Context, filePath string, response *types.GetURLResponse
|
||||
return nil
|
||||
}
|
||||
|
||||
func getUploadURL(ctx context.Context, url string, managementURL string) (*types.GetURLResponse, error) {
|
||||
func getUploadURL(ctx context.Context, url string, managementURL string, insecure bool) (*types.GetURLResponse, error) {
|
||||
id := getURLHash(managementURL)
|
||||
getReq, err := http.NewRequestWithContext(ctx, "GET", url+"?id="+id, nil)
|
||||
if err != nil {
|
||||
@@ -74,7 +136,7 @@ func getUploadURL(ctx context.Context, url string, managementURL string) (*types
|
||||
|
||||
getReq.Header.Set(types.ClientHeader, types.ClientHeaderValue)
|
||||
|
||||
resp, err := http.DefaultClient.Do(getReq)
|
||||
resp, err := uploadClient(insecure).Do(getReq)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get presigned URL: %w", err)
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ func TestUpload(t *testing.T) {
|
||||
fileContent := []byte("test file content")
|
||||
err := os.WriteFile(file, fileContent, 0640)
|
||||
require.NoError(t, err)
|
||||
key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file)
|
||||
key, err := UploadDebugBundle(context.Background(), testURL+types.GetURLPath, testURL, file, true)
|
||||
require.NoError(t, err)
|
||||
id := getURLHash(testURL)
|
||||
require.Contains(t, key, id+"/")
|
||||
|
||||
Reference in New Issue
Block a user