Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
7da4127
[client] Gate remote jobs behind an admin opt-in with MDM support
mlsmaycon Aug 11, 2026
1d93c5e
[management,client] Report the remote-jobs opt-in to management
mlsmaycon Aug 11, 2026
2530b1f
[client] Document the remote-jobs MDM keys in the policy schemas
mlsmaycon Aug 11, 2026
a7f7e90
[client] Test remote-jobs config default and MDM application
mlsmaycon Aug 11, 2026
90db57e
[client] Render remote-jobs config in the debug bundle
mlsmaycon Aug 12, 2026
c9568ba
[client] Address review: redact and clear the MDM upload URL
mlsmaycon Aug 12, 2026
1bbb3bf
[client] Clear MDM upload URL when the policy empties; cut complexity
mlsmaycon Aug 12, 2026
b08120b
[management,client] Plumb anonymize level and upload URL through remo…
mlsmaycon Aug 10, 2026
5c40362
[client] Assert the MDM upload URL never leaks into the debug bundle
mlsmaycon Aug 12, 2026
d0bcf82
[management] Validate anonymize_level on the debug bundle job API
mlsmaycon Aug 11, 2026
b766a9d
[management] Bump peer-metadata field-count guard for RemoteJobsAllowed
mlsmaycon Aug 12, 2026
d325730
[management,client] Normalize anonymize_level and sanity-check the bu…
mlsmaycon Aug 11, 2026
00ee386
[test] Add e2e coverage for remote-jobs opt-in and bundle params
mlsmaycon Aug 13, 2026
72a23bc
[test] Stop leaking management servers in the integration harness
mlsmaycon Aug 13, 2026
3e10f2c
[client] Cut cognitive complexity of the up request builders
mlsmaycon Aug 13, 2026
a171aa9
Merge origin/main into debug-bundle-anonymize-level-upload-url
mlsmaycon Aug 27, 2026
3145ad5
Merge debug-bundle-anonymize-level-upload-url into feat/remote-jobs-o…
mlsmaycon Aug 27, 2026
b7c2a16
Match main's protoc version header in generated management.pb.go
mlsmaycon Aug 27, 2026
567cd28
Match base's protoc version header in generated management.pb.go
mlsmaycon Aug 27, 2026
16cdd35
Address review feedback on remote-jobs opt-in + MDM
mlsmaycon Aug 27, 2026
b074013
Merge branch 'main' into debug-bundle-anonymize-level-upload-url
mlsmaycon Aug 27, 2026
0bb270c
sync pb file
mlsmaycon Aug 27, 2026
680d87d
Merge branch 'debug-bundle-anonymize-level-upload-url' into feat/remo…
mlsmaycon Aug 27, 2026
32d1546
sync pb file
mlsmaycon Aug 27, 2026
e52a076
Fix SonarCloud code smells in netbird-macos.sh
mlsmaycon Aug 27, 2026
a3176c9
Merge remote-tracking branch 'origin/main' into debug-bundle-anonymiz…
mlsmaycon Sep 1, 2026
6230a95
Merge branch 'debug-bundle-anonymize-level-upload-url' into feat/remo…
mlsmaycon Sep 1, 2026
dbeda09
Merge branch 'main' into feat/remote-jobs-optin-mdm
mlsmaycon Sep 1, 2026
40adafb
Address cubic review: revert Flags in isEmpty, reject host-less uploa…
mlsmaycon Sep 1, 2026
4a61019
Install the macOS MDM plist 0600 instead of world-readable
mlsmaycon Sep 1, 2026
2955fd6
Remove stale temp plist before writing it (macOS MDM script)
mlsmaycon Sep 1, 2026
58bb700
Chmod the temp plist to 0600 instead of deleting it
mlsmaycon Sep 1, 2026
f778409
Extract the e2e upload URL into a const to clear the SonarCloud smell
mlsmaycon Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions client/cmd/jobs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package cmd

// remoteJobsAllowedFlag opts this peer into running remote jobs (e.g. debug
// bundles) requested by the management server. It defaults to false: remote
// jobs are an explicit opt-in, and enabling it is a privileged change (see the
// daemon gate in client/server), mirroring the SSH server opt-in.
const remoteJobsAllowedFlag = "allow-remote-jobs"

var remoteJobsAllowed bool

func init() {
upCmd.PersistentFlags().BoolVar(&remoteJobsAllowed, remoteJobsAllowedFlag, false, "Allow the management server to run remote jobs (e.g. debug bundles) on this peer")
}
14 changes: 14 additions & 0 deletions client/cmd/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -398,6 +398,17 @@ func doDaemonUp(ctx context.Context, cmd *cobra.Command, client proto.DaemonServ
return nil
}

// setBoolPtrIfChanged points dst at a copy of val when the named bool flag was
// explicitly set on cmd. It collapses the repeated
// "if cmd.Flag(x).Changed { field = &val }" pattern in the request builders into
// a single call, keeping their cognitive complexity within bounds.
func setBoolPtrIfChanged(cmd *cobra.Command, name string, dst **bool, val bool) {
if cmd.Flag(name).Changed {
dst2 := val
*dst = &dst2
}
}

// setSSHSetConfigFields copies the SSH server flags the user actually
// passed into req, leaving the rest unset so the daemon keeps the
// persisted values.
Expand Down Expand Up @@ -447,6 +458,7 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
req.RosenpassPermissive = &rosenpassPermissive
}
setSSHSetConfigFields(&req, cmd)
setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &req.RemoteJobsAllowed, remoteJobsAllowed)

if cmd.Flag(interfaceNameFlag).Changed {
if err := parseInterfaceName(interfaceName); err != nil {
Expand Down Expand Up @@ -538,6 +550,7 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
if cmd.Flag(serverSSHAllowedFlag).Changed {
ic.ServerSSHAllowed = &serverSSHAllowed
}
setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &ic.RemoteJobsAllowed, remoteJobsAllowed)

if cmd.Flag(enableSSHRootFlag).Changed {
ic.EnableSSHRoot = &enableSSHRoot
Expand Down Expand Up @@ -697,6 +710,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
}

setSSHLoginFields(&loginRequest, cmd)
setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &loginRequest.RemoteJobsAllowed, remoteJobsAllowed)

if cmd.Flag(disableAutoConnectFlag).Changed {
loginRequest.DisableAutoConnect = &autoConnectDisabled
Expand Down
1 change: 1 addition & 0 deletions client/internal/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
a.config.EnableSSHLocalPortForwarding,
a.config.EnableSSHRemotePortForwarding,
a.config.DisableSSHAuth,
a.config.RemoteJobsAllowed,
)
}

Expand Down
2 changes: 2 additions & 0 deletions client/internal/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,6 +652,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
RosenpassEnabled: config.RosenpassEnabled,
RosenpassPermissive: config.RosenpassPermissive,
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(config.RemoteJobsAllowed),
EnableSSHRoot: config.EnableSSHRoot,
EnableSSHSFTP: config.EnableSSHSFTP,
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
Expand Down Expand Up @@ -749,6 +750,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
config.EnableSSHLocalPortForwarding,
config.EnableSSHRemotePortForwarding,
config.DisableSSHAuth,
config.RemoteJobsAllowed,
)
return client.Login(sysInfo, pubSSHKey, config.DNSLabels)
}
Expand Down
3 changes: 3 additions & 0 deletions client/internal/debug/debug.go
Original file line number Diff line number Diff line change
Expand Up @@ -711,6 +711,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
if g.internalConfig.ServerSSHAllowed != nil {
configContent.WriteString(fmt.Sprintf("ServerSSHAllowed: %v\n", *g.internalConfig.ServerSSHAllowed))
}
if g.internalConfig.RemoteJobsAllowed != nil {
configContent.WriteString(fmt.Sprintf("RemoteJobsAllowed: %v\n", *g.internalConfig.RemoteJobsAllowed))
}
if g.internalConfig.EnableSSHRoot != nil {
configContent.WriteString(fmt.Sprintf("EnableSSHRoot: %v\n", *g.internalConfig.EnableSSHRoot))
}
Expand Down
22 changes: 16 additions & 6 deletions client/internal/debug/debug_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -839,12 +839,13 @@ COMMIT`
// the excluded set with a justification.
func TestAddConfig_AllFieldsCovered(t *testing.T) {
excluded := map[string]string{
"PrivateKey": "sensitive: WireGuard private key",
"PreSharedKey": "sensitive: WireGuard pre-shared key",
"SSHKey": "sensitive: SSH private key",
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
"Name": "non-config: profile name is not needed for debug purposes",
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
"PrivateKey": "sensitive: WireGuard private key",
"PreSharedKey": "sensitive: WireGuard pre-shared key",
"SSHKey": "sensitive: SSH private key",
"ClientCertKeyPair": "non-config: parsed cert pair, not serialized",
"Name": "non-config: profile name is not needed for debug purposes",
"policy": "non-config: in-memory MDM policy snapshot, surfaced via Config.Policy() / GetConfigResponse.MDMManagedFields",
"DebugBundleUploadURL": "sensitive: MDM-provided upload URL may carry credentials or query tokens; kept out of the shared bundle",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

mURL, _ := url.Parse("https://api.example.com:443")
Expand All @@ -864,6 +865,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
RosenpassEnabled: true,
RosenpassPermissive: true,
ServerSSHAllowed: &bTrue,
RemoteJobsAllowed: &bTrue,
EnableSSHRoot: &bTrue,
EnableSSHSFTP: &bTrue,
EnableSSHLocalPortForwarding: &bTrue,
Expand All @@ -886,6 +888,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
ClientCertPath: "/tmp/cert",
ClientCertKeyPath: "/tmp/key",
LazyConnection: "on",
DebugBundleUploadURL: "https://upload.example.test/bundle?token=secret",
MTU: 1280,
DisableIPv6: true,
SyncMessageVersion: func(v int) *int { return &v }(1),
Expand All @@ -903,6 +906,13 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
g.addCommonConfigFields(&sb)
rendered := sb.String() + renderAddConfigSpecific(g)

// DebugBundleUploadURL is an MDM-provided value that can carry
// credentials or signed query tokens. It is deliberately excluded
// above; assert it never reaches the rendered bundle — neither the
// field name nor the token — in either anonymize mode.
assert.NotContains(t, rendered, "DebugBundleUploadURL:", "MDM upload URL field must not be serialized into the debug bundle")
assert.NotContains(t, rendered, "token=secret", "MDM upload URL value must not leak into the debug bundle")

val := reflect.ValueOf(cfg).Elem()
typ := val.Type()
var missing []string
Expand Down
43 changes: 25 additions & 18 deletions client/internal/engine.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ type EngineConfig struct {
RosenpassPermissive bool

ServerSSHAllowed bool
RemoteJobsAllowed bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
Expand Down Expand Up @@ -1259,6 +1260,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
e.config.EnableSSHLocalPortForwarding,
e.config.EnableSSHRemotePortForwarding,
e.config.DisableSSHAuth,
&e.config.RemoteJobsAllowed,
)
}

Expand Down Expand Up @@ -1344,6 +1346,13 @@ func (e *Engine) receiveJobEvents() {
ID: msg.ID,
Status: mgmProto.JobStatus_failed,
}
// Remote jobs are an explicit opt-in. When not enabled on this
// peer, every job is refused before any work is done.
if !e.config.RemoteJobsAllowed {
log.Warnf("refusing remote job: remote jobs are not enabled on this peer (enable with --allow-remote-jobs)")
resp.Reason = []byte("remote jobs are not enabled on this peer")
return &resp
}
switch params := msg.WorkloadParameters.(type) {
case *mgmProto.JobRequest_Bundle:
bundleResult, err := e.handleBundle(params.Bundle)
Expand Down Expand Up @@ -1380,7 +1389,15 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
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 {
// Resolve the upload destination: an MDM override, when set, takes
// precedence over the management-supplied URL. Both are validated the same
// way; an empty result falls back to the default upload server downstream.
uploadURL := params.GetUploadUrl()
if override := e.config.ProfileConfig.DebugBundleUploadURL; override != "" {
log.Infof("using MDM debug bundle upload URL override instead of the management-supplied value")
uploadURL = override
}
if err := validateBundleUploadURL(uploadURL); err != nil {
return nil, err
}

Expand Down Expand Up @@ -1411,7 +1428,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(), params.GetUploadUrl())
uploadKey, err := e.jobExecutor.BundleJob(e.ctx, bundleDeps, bundleJobParams, waitFor, e.config.ProfileConfig.ManagementURL.String(), uploadURL)
if err != nil {
return nil, err
}
Expand All @@ -1425,23 +1442,13 @@ func (e *Engine) handleBundle(params *mgmProto.BundleParameters) (*mgmProto.JobR
}

// 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.
// remote debug bundle job. It delegates to profilemanager.ValidateBundleUploadURL
// so the executor and the MDM policy override share one definition of the rule
// (empty accepted; otherwise a well-formed https URL with a host) and cannot
// drift. The host is deliberately left unconstrained 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
return profilemanager.ValidateBundleUploadURL(raw)
}

// receiveManagementEvents connects to the Management Service event stream to receive updates from the management service
Expand Down
1 change: 1 addition & 0 deletions client/internal/engine_bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func TestValidateBundleUploadURL(t *testing.T) {
{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: "port-only authority rejected", raw: "https://:443", wantErr: true},
{name: "non-url scheme rejected", raw: "ftp://upload.example.com", wantErr: true},
{name: "garbage rejected", raw: "://not a url", wantErr: true},
} {
Expand Down
83 changes: 82 additions & 1 deletion client/internal/profilemanager/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ type ConfigInput struct {
StateFilePath string
PreSharedKey *string
ServerSSHAllowed *bool
RemoteJobsAllowed *bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
Expand Down Expand Up @@ -127,6 +128,7 @@ type Config struct {
RosenpassEnabled bool
RosenpassPermissive bool
ServerSSHAllowed *bool
RemoteJobsAllowed *bool
EnableSSHRoot *bool
EnableSSHSFTP *bool
EnableSSHLocalPortForwarding *bool
Expand Down Expand Up @@ -192,6 +194,12 @@ type Config struct {
// Runtime-only: re-derived from MDM policy on each load, never persisted.
LazyConnection string `json:"-"`

// DebugBundleUploadURL is the MDM-managed debug-bundle upload URL override.
// When set, it takes precedence over the management-supplied upload URL for
// remote debug bundle jobs. Runtime-only: re-derived from MDM policy on each
// load, never persisted.
DebugBundleUploadURL string `json:"-"`
Comment thread
coderabbitai[bot] marked this conversation as resolved.

MTU uint16

// policy is the MDM policy that produced the currently-set values for
Expand Down Expand Up @@ -273,7 +281,10 @@ func createNewConfig(input ConfigInput) (*Config, error) {
config := &Config{
// defaults to false only for new (post 0.26) configurations
ServerSSHAllowed: util.False(),
WgPort: iface.DefaultWgPort,
// Remote jobs are an explicit opt-in and default off, including for
// legacy configs (a nil value materializes to false at connect time).
RemoteJobsAllowed: util.False(),
WgPort: iface.DefaultWgPort,
}

if _, err := config.apply(input); err != nil {
Expand Down Expand Up @@ -476,6 +487,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
updated = true
}

if input.RemoteJobsAllowed != nil && (config.RemoteJobsAllowed == nil || *input.RemoteJobsAllowed != *config.RemoteJobsAllowed) {
if *input.RemoteJobsAllowed {
log.Infof("enabling remote jobs")
} else {
log.Infof("disabling remote jobs")
}
config.RemoteJobsAllowed = input.RemoteJobsAllowed
updated = true
} else if config.RemoteJobsAllowed == nil {
// Remote jobs are an explicit opt-in: unlike SSH, a pre-existing config
// with no value defaults to disabled rather than being turned on.
config.RemoteJobsAllowed = util.False()
updated = true
}

if input.EnableSSHRoot != nil && (config.EnableSSHRoot == nil || *input.EnableSSHRoot != *config.EnableSSHRoot) {
if *input.EnableSSHRoot {
log.Infof("enabling SSH root login")
Expand Down Expand Up @@ -685,6 +711,14 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
// for the key, so per-field rejection of user writes still applies).
func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
config.policy = policy

// DebugBundleUploadURL is a runtime-only override re-derived from MDM on
// every apply. Resolve it unconditionally (before the IsEmpty early return)
// so a policy that drops the key, becomes empty, or carries an invalid
// value can never leave a previously-enforced upload target active on a
// reused Config instance.
config.DebugBundleUploadURL = mdmDebugBundleUploadURL(policy)

if policy.IsEmpty() {
return
}
Expand Down Expand Up @@ -732,6 +766,7 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
}

applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv })
applyBool(mdm.KeyRemoteJobsAllowed, func(v bool) { bv := v; config.RemoteJobsAllowed = &bv })
applyBool(mdm.KeyDisableClientRoutes, func(v bool) { config.DisableClientRoutes = v })
applyBool(mdm.KeyDisableServerRoutes, func(v bool) { config.DisableServerRoutes = v })
applyBool(mdm.KeyBlockInbound, func(v bool) { config.BlockInbound = v })
Expand Down Expand Up @@ -765,6 +800,52 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) {
config.LazyConnection = state
logApplied(mdm.KeyLazyConnection, state)
}

}

// ValidateBundleUploadURL sanity-checks a debug-bundle upload URL. 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. It deliberately does not constrain
// which host may receive the bundle. This is the single source of truth for the
// rule, shared by the remote-job executor (client/internal) and the MDM policy
// override below so the two validation paths cannot drift.
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)
}
// Hostname(), not Host: an authority like ":443" is non-empty but has no
// host, and would fail the actual upload.
if parsed.Scheme != "https" || parsed.Hostname() == "" {
return fmt.Errorf("upload URL must be an https URL with a host")
}
return nil
}

// mdmDebugBundleUploadURL resolves the MDM-enforced debug-bundle upload URL
// override from the policy, returning the empty string when the policy does
// not carry a valid KeyBundleUploadURL. An absent or invalid value fails
// closed to "" so it falls back to the management-supplied or default upload
// target rather than a previously-enforced one. The URL is never logged: it
// can embed credentials or signed query tokens (KeyBundleUploadURL is in
// mdm.SecretKeys).
func mdmDebugBundleUploadURL(policy *mdm.Policy) string {
v, ok := policy.GetString(mdm.KeyBundleUploadURL)
if !ok || v == "" {
return ""
}
// Must be a well-formed https URL with a host, matching the client's
// remote-job upload-URL validation (shared validator, single source of truth).
if err := ValidateBundleUploadURL(v); err != nil {
log.Warnf("MDM debug bundle upload URL is invalid (must be an https URL with a host); ignoring the override")
return ""
}
log.Infof("MDM override %s = ********** (secret)", mdm.KeyBundleUploadURL)
return v
}

// parseURL parses and validates the URL for the named service. The URL
Expand Down
Loading
Loading