[management,client] Add anonymize level and upload URL to remote debug bundle jobs (#7147)

This extends the management-requested remote debug-bundle job with two
new, optional parameters. anonymize_level selects how aggressively the
bundle is scrubbed: "default" keeps internal (private) IP ranges
readable, while "strict" also anonymizes private, CGNAT and link-local
addresses; the value is trimmed and lowercased, and an unknown level is
rejected at creation. upload_url lets an operator point the peer at a
specific upload service instead of the default one; it must be a
well-formed https URL with a host, and an empty value falls back to the
default upload server. Both fields flow through the job workload API and
are surfaced in the create-debug-job modal on the dashboard. Validation
is shared so the client executor and the management boundary agree on
what a valid upload URL is, preventing drift between the two checks.
This commit is contained in:
Maycon Santos
2026-09-01 11:45:20 +02:00
committed by GitHub
parent 930a25319d
commit 1081ca006d
9 changed files with 1355 additions and 1090 deletions

View File

@@ -1373,7 +1373,17 @@ func (e *Engine) receiveJobEvents() {
}
func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobResponse_Bundle, error) {
log.Infof("handle remote debug bundle request: %s", params.String())
// The upload URL can carry a host, credentials, or query tokens, so it is
// kept out of the info-level line; the full parameters stay available at
// debug level for troubleshooting.
log.Infof("handle remote debug bundle request: anonymize=%v anonymize_level=%q log_file_count=%d bundle_for=%v bundle_for_time=%d",
params.GetAnonymize(), params.GetAnonymizeLevel(), params.GetLogFileCount(), params.GetBundleFor(), params.GetBundleForTime())
log.Debugf("remote debug bundle request parameters: %s", params.String())
if err := validateBundleUploadURL(params.GetUploadUrl()); err != nil {
return nil, err
}
syncResponse, err := e.GetLatestSyncResponse()
if err != nil {
log.Warnf("get latest sync response: %v", err)
@@ -1401,7 +1411,7 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
waitFor := time.Duration(params.BundleForTime) * time.Minute
uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String())
uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String(), params.GetUploadUrl())
if err != nil {
return nil, err
}
@@ -1414,6 +1424,26 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
return response, nil
}
// validateBundleUploadURL sanity-checks a management-supplied upload URL for a
// remote debug bundle job. An empty value is accepted — the executor falls back
// to the default upload service. A non-empty value must be a well-formed https
// URL with a host; a malformed value or a plaintext scheme is rejected. This
// deliberately does not constrain which host may receive the bundle; that
// policy is left open pending a decision on management-directed uploads.
func validateBundleUploadURL(raw string) error {
if raw == "" {
return nil
}
parsed, err := url.Parse(raw)
if err != nil {
return fmt.Errorf("parse upload URL: %w", err)
}
if parsed.Scheme != "https" || parsed.Host == "" {
return fmt.Errorf("upload URL must be an https URL with a host")
}
return nil
}
// receiveManagementEvents connects to the Management Service event stream to receive updates from the management service
// E.g. when a new peer has been registered and we are allowed to connect to it.
func (e *Engine) receiveManagementEvents() {

View File

@@ -0,0 +1,35 @@
package internal
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestValidateBundleUploadURL covers the sanity check applied to a
// management-supplied upload URL before a remote debug bundle is generated.
func TestValidateBundleUploadURL(t *testing.T) {
for _, tc := range []struct {
name string
raw string
wantErr bool
}{
{name: "empty falls back to default", raw: ""},
{name: "https with host", raw: "https://upload.debug.netbird.io/upload"},
{name: "https self-hosted host", raw: "https://upload.example.com"},
{name: "plaintext rejected", raw: "http://upload.example.com", wantErr: true},
{name: "missing host rejected", raw: "https:///upload", wantErr: true},
{name: "non-url scheme rejected", raw: "ftp://upload.example.com", wantErr: true},
{name: "garbage rejected", raw: "://not a url", wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
err := validateBundleUploadURL(tc.raw)
if tc.wantErr {
require.Error(t, err, "an invalid upload URL must be rejected")
return
}
assert.NoError(t, err, "a valid or empty upload URL must be accepted")
})
}
}

View File

@@ -28,7 +28,11 @@ func NewExecutor() *Executor {
return &Executor{}
}
func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL string) (string, error) {
func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.GeneratorDependencies, params debug.BundleConfig, waitForDuration time.Duration, mgmURL, uploadURL string) (string, error) {
if uploadURL == "" {
uploadURL = types.DefaultBundleURL
}
if waitForDuration > MaxBundleWaitTime {
log.Warnf("bundle wait time %v exceeds maximum %v, capping to maximum", waitForDuration, MaxBundleWaitTime)
waitForDuration = MaxBundleWaitTime
@@ -54,7 +58,7 @@ func (e *Executor) BundleJob(ctx context.Context, debugBundleDependencies debug.
}
}()
key, err := debug.UploadDebugBundle(ctx, types.DefaultBundleURL, mgmURL, path, false)
key, err := debug.UploadDebugBundle(ctx, uploadURL, mgmURL, path, false)
if err != nil {
log.Errorf("failed to upload debug bundle: %v", err)
return "", fmt.Errorf("upload debug bundle: %w", err)

View File

@@ -3,10 +3,12 @@ package types
import (
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/netbirdio/netbird/client/anonymize"
"github.com/netbirdio/netbird/shared/management/http/api"
"github.com/netbirdio/netbird/shared/management/proto"
"github.com/netbirdio/netbird/shared/management/status"
@@ -150,6 +152,21 @@ func validateAndBuildBundleParams(req api.WorkloadRequest, workload *Workload) e
if bundle.Parameters.LogFileCount < 1 || bundle.Parameters.LogFileCount > 1000 {
return fmt.Errorf("log-file-count must be between 1 and 1000, got %d", bundle.Parameters.LogFileCount)
}
// validate anonymize_level: omitted or empty defaults on the client;
// otherwise it must name a known level. An unknown value is rejected here
// rather than silently escalated, so a typo surfaces at job creation. The
// normalized (trimmed, lowercased) value is persisted so it matches what
// the client parses — the client only lowercases, so a stored " default "
// would otherwise resolve to strict.
if lvl := bundle.Parameters.AnonymizeLevel; lvl != nil {
normalized := strings.ToLower(strings.TrimSpace(*lvl))
switch normalized {
case "", anonymize.LevelDefaultString, anonymize.LevelStrictString:
default:
return fmt.Errorf("anonymize_level must be %q or %q, got %q", anonymize.LevelDefaultString, anonymize.LevelStrictString, *lvl)
}
bundle.Parameters.AnonymizeLevel = &normalized
}
workload.Parameters, err = json.Marshal(bundle.Parameters)
if err != nil {
@@ -209,6 +226,17 @@ func (j *Job) ToStreamJobRequest() (*proto.JobRequest, error) {
}
}
// derefString returns the pointed-to string, or "" when the pointer is nil.
// The bundle parameters carry anonymize_level and upload_url as optional
// fields; an absent value maps to the empty proto string, which the client
// resolves to its default.
func derefString(s *string) string {
if s == nil {
return ""
}
return *s
}
func (j *Job) buildStreamBundleResponse() (*proto.JobRequest, error) {
var p api.BundleParameters
if err := json.Unmarshal(j.Workload.Parameters, &p); err != nil {
@@ -218,10 +246,12 @@ func (j *Job) buildStreamBundleResponse() (*proto.JobRequest, error) {
ID: []byte(j.ID),
WorkloadParameters: &proto.JobRequest_Bundle{
Bundle: &proto.BundleParameters{
BundleFor: p.BundleFor,
BundleForTime: int64(p.BundleForTime),
LogFileCount: int32(p.LogFileCount),
Anonymize: p.Anonymize,
BundleFor: p.BundleFor,
BundleForTime: int64(p.BundleForTime),
LogFileCount: int32(p.LogFileCount),
Anonymize: p.Anonymize,
AnonymizeLevel: derefString(p.AnonymizeLevel),
UploadUrl: derefString(p.UploadUrl),
},
},
}, nil

View File

@@ -0,0 +1,137 @@
package types
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/shared/management/http/api"
)
func strPtr(s string) *string { return &s }
// bundleJobFromParams builds a bundle Job whose stored workload parameters are
// the marshalled REST BundleParameters, mirroring what NewJob persists.
func bundleJobFromParams(t *testing.T, p api.BundleParameters) *Job {
t.Helper()
raw, err := json.Marshal(p)
require.NoError(t, err, "marshal bundle parameters")
return &Job{
ID: "job-1",
Workload: Workload{
Type: JobTypeBundle,
Parameters: raw,
Result: []byte("{}"),
},
}
}
// TestBuildStreamBundleResponse_CarriesIdentityAndUploadFields verifies the
// anonymize_level and upload_url REST fields are mapped onto the proto request
// the client receives.
func TestBuildStreamBundleResponse_CarriesIdentityAndUploadFields(t *testing.T) {
job := bundleJobFromParams(t, api.BundleParameters{
BundleFor: true,
BundleForTime: 2,
LogFileCount: 100,
Anonymize: true,
AnonymizeLevel: strPtr("strict"),
UploadUrl: strPtr("https://upload.example.com"),
})
req, err := job.ToStreamJobRequest()
require.NoError(t, err, "ToStreamJobRequest must succeed")
bundle := req.GetBundle()
require.NotNil(t, bundle, "the request must carry bundle parameters")
assert.Equal(t, "strict", bundle.GetAnonymizeLevel(), "anonymize_level must reach the client")
assert.Equal(t, "https://upload.example.com", bundle.GetUploadUrl(), "upload_url must reach the client")
assert.True(t, bundle.GetAnonymize(), "existing fields must still map")
assert.Equal(t, int32(100), bundle.GetLogFileCount(), "existing fields must still map")
}
// newBundleJobRequest builds an api.JobRequest carrying a bundle workload with
// the given parameters, mirroring what the REST handler decodes.
func newBundleJobRequest(t *testing.T, p api.BundleParameters) *api.JobRequest {
t.Helper()
var wr api.WorkloadRequest
require.NoError(t, wr.FromBundleWorkloadRequest(api.BundleWorkloadRequest{
Type: api.WorkloadTypeBundle,
Parameters: p,
}), "build bundle workload request")
return &api.JobRequest{Workload: wr}
}
// TestNewJob_AnonymizeLevelValidation verifies the management API accepts only
// known anonymization levels (empty defaults on the client) and rejects an
// unknown value instead of silently escalating it.
func TestNewJob_AnonymizeLevelValidation(t *testing.T) {
base := api.BundleParameters{BundleFor: false, LogFileCount: 100, Anonymize: true}
for _, tc := range []struct {
name string
level *string
wantErr bool
}{
{name: "omitted", level: nil},
{name: "empty", level: strPtr("")},
{name: "default", level: strPtr("default")},
{name: "strict", level: strPtr("strict")},
{name: "mixed case", level: strPtr("Strict")},
{name: "padded", level: strPtr(" default ")},
{name: "unknown", level: strPtr("verbose"), wantErr: true},
} {
t.Run(tc.name, func(t *testing.T) {
p := base
p.AnonymizeLevel = tc.level
_, err := NewJob("user-1", "acc-1", "peer-1", newBundleJobRequest(t, p))
if tc.wantErr {
require.Error(t, err, "an unknown anonymize_level must be rejected")
assert.Contains(t, err.Error(), "anonymize_level", "the error must name the offending field")
return
}
require.NoError(t, err, "a known anonymize_level must be accepted")
})
}
}
// TestNewJob_AnonymizeLevelNormalized verifies an accepted level is persisted
// trimmed and lowercased, so it reaches the client as a value the client's
// lowercase-only parser resolves correctly rather than escalating to strict.
func TestNewJob_AnonymizeLevelNormalized(t *testing.T) {
job, err := NewJob("user-1", "acc-1", "peer-1", newBundleJobRequest(t, api.BundleParameters{
BundleFor: false,
LogFileCount: 100,
Anonymize: true,
AnonymizeLevel: strPtr(" Default "),
}))
require.NoError(t, err, "a padded known level must be accepted")
req, err := job.ToStreamJobRequest()
require.NoError(t, err, "ToStreamJobRequest must succeed")
assert.Equal(t, "default", req.GetBundle().GetAnonymizeLevel(),
"the persisted level must be normalized so the client does not resolve it to strict")
}
// TestBuildStreamBundleResponse_OmittedFieldsMapToEmpty verifies that omitted
// optional fields map to the empty proto string, which the client resolves to
// its defaults (default anonymization level, default upload server).
func TestBuildStreamBundleResponse_OmittedFieldsMapToEmpty(t *testing.T) {
job := bundleJobFromParams(t, api.BundleParameters{
BundleFor: false,
BundleForTime: 1,
LogFileCount: 50,
Anonymize: false,
// AnonymizeLevel and UploadUrl intentionally nil.
})
req, err := job.ToStreamJobRequest()
require.NoError(t, err, "ToStreamJobRequest must succeed")
bundle := req.GetBundle()
require.NotNil(t, bundle, "the request must carry bundle parameters")
assert.Empty(t, bundle.GetAnonymizeLevel(), "an omitted anonymize_level must map to empty so the client defaults it")
assert.Empty(t, bundle.GetUploadUrl(), "an omitted upload_url must map to empty so the client defaults it")
}

View File

@@ -154,6 +154,14 @@ components:
type: boolean
description: Whether sensitive data should be anonymized in the bundle.
example: false
anonymize_level:
type: string
description: How much the anonymizer redacts. "default" (or empty) keeps internal IP ranges, "strict" also anonymizes them.
example: strict
upload_url:
type: string
description: Service URL the client requests an upload URL from before uploading the bundle. Empty selects the default upload server.
example: https://upload.debug.netbird.io
required:
- bundle_for
- bundle_for_time

View File

@@ -2575,6 +2575,9 @@ type BundleParameters struct {
// Anonymize Whether sensitive data should be anonymized in the bundle.
Anonymize bool `json:"anonymize"`
// AnonymizeLevel How much the anonymizer redacts. "default" (or empty) keeps internal IP ranges, "strict" also anonymizes them.
AnonymizeLevel *string `json:"anonymize_level,omitempty"`
// BundleFor Whether to generate a bundle for the given timeframe.
BundleFor bool `json:"bundle_for"`
@@ -2583,6 +2586,9 @@ type BundleParameters struct {
// LogFileCount Maximum number of log files to include in the bundle.
LogFileCount int `json:"log_file_count"`
// UploadUrl Service URL the client requests an upload URL from before uploading the bundle. Empty selects the default upload server.
UploadUrl *string `json:"upload_url,omitempty"`
}
// BundleResult defines model for BundleResult.

File diff suppressed because it is too large Load Diff

View File

@@ -114,6 +114,9 @@ message BundleParameters {
// (or empty) keeps internal IP ranges, "strict" also anonymizes them.
// Unknown values are treated as "strict".
string anonymize_level = 5;
// upload_url is the service URL the client requests an upload URL from
// before uploading the bundle. Empty selects the default upload server.
string upload_url = 6;
}
message BundleResult {