[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
+34 -4
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
+137
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")
}