From 7da4127c7df20716c826073ab9a182d2aac30371 Mon Sep 17 00:00:00 2001 From: mlsmaycon Date: Tue, 11 Aug 2026 11:32:03 +0000 Subject: [PATCH] [client] Gate remote jobs behind an admin opt-in with MDM support Remote jobs (debug bundles requested by the management server) run on the peer with no local consent. This makes them an explicit opt-in, mirroring the SSH-server opt-in: an --allow-remote-jobs flag persisted in the client config, defaulting off. Enabling it off->on crosses the user-to-root boundary and is refused for unprivileged IPC callers by the daemon gate, the same way enabling the SSH server is. When disabled, the job-stream handler refuses every job before doing any work. Because the flag is admin-controlled, it is also MDM-managed: the allowRemoteJobs policy key can enable or lock it, and a user SetConfig that diverges from an enforced value is rejected like the other managed fields. A second MDM key, debugBundleUploadURL, overrides the debug-bundle upload service for remote jobs, taking precedence over the management-supplied value (MDM > management > default). This lets an operator pin uploads to a trusted host regardless of what management requests. The override is validated as an https URL with a host, the same as the management value. Defaulting the opt-in off is a behavior change: existing deployments that rely on management-triggered debug bundles must opt in (flag or MDM) before they work again. --- client/cmd/jobs.go | 13 ++++++ client/cmd/up.go | 9 ++++ client/internal/connect.go | 1 + client/internal/engine.go | 20 ++++++++- client/internal/profilemanager/config.go | 41 ++++++++++++++++- client/mdm/canonical_loaders.go | 2 + client/mdm/policy.go | 11 +++++ client/proto/daemon.pb.go | 57 +++++++++++++++++++----- client/proto/daemon.proto | 10 +++++ client/server/mdm.go | 2 + client/server/server.go | 3 ++ client/server/setconfig_test.go | 6 +++ client/server/ssh_gate.go | 36 ++++++++++----- client/server/ssh_gate_test.go | 28 ++++++++++++ docs/io.netbird.client.plist | 15 +++++++ 15 files changed, 227 insertions(+), 27 deletions(-) create mode 100644 client/cmd/jobs.go diff --git a/client/cmd/jobs.go b/client/cmd/jobs.go new file mode 100644 index 000000000..36aab3570 --- /dev/null +++ b/client/cmd/jobs.go @@ -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") +} diff --git a/client/cmd/up.go b/client/cmd/up.go index 142bcf6bd..91c204d9a 100644 --- a/client/cmd/up.go +++ b/client/cmd/up.go @@ -421,6 +421,9 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro if cmd.Flag(serverSSHAllowedFlag).Changed { req.ServerSSHAllowed = &serverSSHAllowed } + if cmd.Flag(remoteJobsAllowedFlag).Changed { + req.RemoteJobsAllowed = &remoteJobsAllowed + } if cmd.Flag(enableSSHRootFlag).Changed { req.EnableSSHRoot = &enableSSHRoot } @@ -523,6 +526,9 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil if cmd.Flag(serverSSHAllowedFlag).Changed { ic.ServerSSHAllowed = &serverSSHAllowed } + if cmd.Flag(remoteJobsAllowedFlag).Changed { + ic.RemoteJobsAllowed = &remoteJobsAllowed + } if cmd.Flag(enableSSHRootFlag).Changed { ic.EnableSSHRoot = &enableSSHRoot @@ -648,6 +654,9 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte if cmd.Flag(serverSSHAllowedFlag).Changed { loginRequest.ServerSSHAllowed = &serverSSHAllowed } + if cmd.Flag(remoteJobsAllowedFlag).Changed { + loginRequest.RemoteJobsAllowed = &remoteJobsAllowed + } if cmd.Flag(enableSSHRootFlag).Changed { loginRequest.EnableSSHRoot = &enableSSHRoot diff --git a/client/internal/connect.go b/client/internal/connect.go index ceb39419e..e841001c6 100644 --- a/client/internal/connect.go +++ b/client/internal/connect.go @@ -614,6 +614,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, diff --git a/client/internal/engine.go b/client/internal/engine.go index 2b8189490..9eb4341a9 100644 --- a/client/internal/engine.go +++ b/client/internal/engine.go @@ -136,6 +136,7 @@ type EngineConfig struct { RosenpassPermissive bool ServerSSHAllowed bool + RemoteJobsAllowed bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -1336,6 +1337,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) @@ -1372,7 +1380,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 } @@ -1403,7 +1419,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 } diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index e1668238e..8a2140fac 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -70,6 +70,7 @@ type ConfigInput struct { StateFilePath string PreSharedKey *string ServerSSHAllowed *bool + RemoteJobsAllowed *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -124,6 +125,7 @@ type Config struct { RosenpassEnabled bool RosenpassPermissive bool ServerSSHAllowed *bool + RemoteJobsAllowed *bool EnableSSHRoot *bool EnableSSHSFTP *bool EnableSSHLocalPortForwarding *bool @@ -184,6 +186,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:"-"` + MTU uint16 // policy is the MDM policy that produced the currently-set values for @@ -265,7 +273,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 { @@ -456,6 +467,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") @@ -712,6 +738,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 }) @@ -739,6 +766,18 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { config.LazyConnection = state logApplied(mdm.KeyLazyConnection, state) } + + if v, ok := policy.GetString(mdm.KeyBundleUploadURL); ok { + // Must be a well-formed https URL with a host, matching the client's + // remote-job upload-URL validation. Invalid values are skipped so a + // bad policy cannot break bundle uploads. + if u, err := url.Parse(v); err != nil || u.Scheme != "https" || u.Host == "" { + log.Warnf("MDM debug bundle upload URL %q invalid (must be an https URL with a host); keeping previous value", v) + } else { + config.DebugBundleUploadURL = v + logApplied(mdm.KeyBundleUploadURL, v) + } + } } // parseURL parses and validates the URL for the named service. The URL diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index 29288b511..bf6bf2077 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -30,6 +30,8 @@ var allKeys = []string{ KeySplitTunnelMode, KeySplitTunnelApps, KeyLazyConnection, + KeyRemoteJobsAllowed, + KeyBundleUploadURL, } // canonicalKey maps the lowercase form of a managed-config value name to diff --git a/client/mdm/policy.go b/client/mdm/policy.go index 1feff28f8..774d162c8 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -60,6 +60,17 @@ const ( // the management feature flag. Read as a bool (native bool, or on/off, // true/false, 1/0, yes/no); absent = defer to management. KeyLazyConnection = "lazyConnection" + + // KeyRemoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Read as a bool; absent = defer to the local config + // (which defaults to disabled). Stored on Config as RemoteJobsAllowed. + KeyRemoteJobsAllowed = "allowRemoteJobs" + + // KeyBundleUploadURL overrides the debug-bundle upload service URL for + // remote jobs, taking precedence over the management-supplied value. Read + // as a string; must be an https URL with a host. Absent = defer to the + // management-supplied URL (or the default upload server). + KeyBundleUploadURL = "debugBundleUploadURL" ) // Split-tunnel mode literals (KeySplitTunnelMode values). diff --git a/client/proto/daemon.pb.go b/client/proto/daemon.pb.go index 83243be49..3d4c0b92c 100644 --- a/client/proto/daemon.pb.go +++ b/client/proto/daemon.pb.go @@ -343,8 +343,11 @@ type LoginRequest struct { DisableSSHAuth *bool `protobuf:"varint,38,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,39,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + RemoteJobsAllowed *bool `protobuf:"varint,41,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *LoginRequest) Reset() { @@ -658,6 +661,13 @@ func (x *LoginRequest) GetDisableIpv6() bool { return false } +func (x *LoginRequest) GetRemoteJobsAllowed() bool { + if x != nil && x.RemoteJobsAllowed != nil { + return *x.RemoteJobsAllowed + } + return false +} + type LoginResponse struct { state protoimpl.MessageState `protogen:"open.v1"` NeedsSSOLogin bool `protobuf:"varint,1,opt,name=needsSSOLogin,proto3" json:"needsSSOLogin,omitempty"` @@ -1215,6 +1225,7 @@ type GetConfigResponse struct { DisableSSHAuth bool `protobuf:"varint,25,opt,name=disableSSHAuth,proto3" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL int32 `protobuf:"varint,26,opt,name=sshJWTCacheTTL,proto3" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 bool `protobuf:"varint,27,opt,name=disable_ipv6,json=disableIpv6,proto3" json:"disable_ipv6,omitempty"` + RemoteJobsAllowed bool `protobuf:"varint,29,opt,name=remoteJobsAllowed,proto3" json:"remoteJobsAllowed,omitempty"` // mDMManagedFields lists the names of configuration keys whose value is // currently enforced by an MDM policy. Names match mdm.Key* constants // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should @@ -1444,6 +1455,13 @@ func (x *GetConfigResponse) GetDisableIpv6() bool { return false } +func (x *GetConfigResponse) GetRemoteJobsAllowed() bool { + if x != nil { + return x.RemoteJobsAllowed + } + return false +} + func (x *GetConfigResponse) GetMDMManagedFields() []string { if x != nil { return x.MDMManagedFields @@ -4233,8 +4251,11 @@ type SetConfigRequest struct { DisableSSHAuth *bool `protobuf:"varint,33,opt,name=disableSSHAuth,proto3,oneof" json:"disableSSHAuth,omitempty"` SshJWTCacheTTL *int32 `protobuf:"varint,34,opt,name=sshJWTCacheTTL,proto3,oneof" json:"sshJWTCacheTTL,omitempty"` DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + RemoteJobsAllowed *bool `protobuf:"varint,36,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *SetConfigRequest) Reset() { @@ -4512,6 +4533,13 @@ func (x *SetConfigRequest) GetDisableIpv6() bool { return false } +func (x *SetConfigRequest) GetRemoteJobsAllowed() bool { + if x != nil && x.RemoteJobsAllowed != nil { + return *x.RemoteJobsAllowed + } + return false +} + type SetConfigResponse struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -7010,7 +7038,7 @@ var File_daemon_proto protoreflect.FileDescriptor const file_daemon_proto_rawDesc = "" + "\n" + "\fdaemon.proto\x12\x06daemon\x1a google/protobuf/descriptor.proto\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/duration.proto\"\x0e\n" + - "\fEmptyRequest\"\xef\x12\n" + + "\fEmptyRequest\"\xb8\x13\n" + "\fLoginRequest\x12\x1a\n" + "\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" + "\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" + @@ -7055,7 +7083,8 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18% \x01(\bH\x18R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18& \x01(\bH\x19R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + - "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01B\x13\n" + + "\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x121\n" + + "\x11remoteJobsAllowed\x18) \x01(\bH\x1cR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7083,7 +7112,8 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6\"\xb5\x01\n" + + "\r_disable_ipv6B\x14\n" + + "\x12_remoteJobsAllowed\"\xb5\x01\n" + "\rLoginResponse\x12$\n" + "\rneedsSSOLogin\x18\x01 \x01(\bR\rneedsSSOLogin\x12\x1a\n" + "\buserCode\x18\x02 \x01(\tR\buserCode\x12(\n" + @@ -7118,7 +7148,7 @@ const file_daemon_proto_rawDesc = "" + "\fDownResponse\"P\n" + "\x10GetConfigRequest\x12 \n" + "\vprofileName\x18\x01 \x01(\tR\vprofileName\x12\x1a\n" + - "\busername\x18\x02 \x01(\tR\busername\"\xaa\t\n" + + "\busername\x18\x02 \x01(\tR\busername\"\xd8\t\n" + "\x11GetConfigResponse\x12$\n" + "\rmanagementUrl\x18\x01 \x01(\tR\rmanagementUrl\x12\x1e\n" + "\n" + @@ -7150,7 +7180,8 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18\x17 \x01(\bR\x1denableSSHRemotePortForwarding\x12&\n" + "\x0edisableSSHAuth\x18\x19 \x01(\bR\x0edisableSSHAuth\x12&\n" + "\x0esshJWTCacheTTL\x18\x1a \x01(\x05R\x0esshJWTCacheTTL\x12!\n" + - "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12*\n" + + "\fdisable_ipv6\x18\x1b \x01(\bR\vdisableIpv6\x12,\n" + + "\x11remoteJobsAllowed\x18\x1d \x01(\bR\x11remoteJobsAllowed\x12*\n" + "\x10mDMManagedFields\x18\x1c \x03(\tR\x10mDMManagedFields\"\x92\x06\n" + "\tPeerState\x12\x0e\n" + "\x02IP\x18\x01 \x01(\tR\x02IP\x12\x16\n" + @@ -7378,7 +7409,7 @@ const file_daemon_proto_rawDesc = "" + "\f_profileNameB\v\n" + "\t_username\"'\n" + "\x15SwitchProfileResponse\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\"\x98\x11\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"\xe1\x11\n" + "\x10SetConfigRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + "\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" + @@ -7418,7 +7449,8 @@ const file_daemon_proto_rawDesc = "" + "\x1denableSSHRemotePortForwarding\x18 \x01(\bH\x15R\x1denableSSHRemotePortForwarding\x88\x01\x01\x12+\n" + "\x0edisableSSHAuth\x18! \x01(\bH\x16R\x0edisableSSHAuth\x88\x01\x01\x12+\n" + "\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" + - "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01B\x13\n" + + "\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x121\n" + + "\x11remoteJobsAllowed\x18$ \x01(\bH\x19R\x11remoteJobsAllowed\x88\x01\x01B\x13\n" + "\x11_rosenpassEnabledB\x10\n" + "\x0e_interfaceNameB\x10\n" + "\x0e_wireguardPortB\x17\n" + @@ -7443,7 +7475,8 @@ const file_daemon_proto_rawDesc = "" + "\x1e_enableSSHRemotePortForwardingB\x11\n" + "\x0f_disableSSHAuthB\x11\n" + "\x0f_sshJWTCacheTTLB\x0f\n" + - "\r_disable_ipv6\"\x13\n" + + "\r_disable_ipv6B\x14\n" + + "\x12_remoteJobsAllowed\"\x13\n" + "\x11SetConfigResponse\"Q\n" + "\x11AddProfileRequest\x12\x1a\n" + "\busername\x18\x01 \x01(\tR\busername\x12 \n" + diff --git a/client/proto/daemon.proto b/client/proto/daemon.proto index 18ce0e79c..e40a8e0a5 100644 --- a/client/proto/daemon.proto +++ b/client/proto/daemon.proto @@ -242,6 +242,10 @@ message LoginRequest { optional bool disableSSHAuth = 38; optional int32 sshJWTCacheTTL = 39; optional bool disable_ipv6 = 40; + + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + optional bool remoteJobsAllowed = 41; } message LoginResponse { @@ -362,6 +366,8 @@ message GetConfigResponse { bool disable_ipv6 = 27; + bool remoteJobsAllowed = 29; + // mDMManagedFields lists the names of configuration keys whose value is // currently enforced by an MDM policy. Names match mdm.Key* constants // (e.g. "managementURL", "disableClientRoutes"). UI/CLI clients should @@ -766,6 +772,10 @@ message SetConfigRequest { optional bool disableSSHAuth = 33; optional int32 sshJWTCacheTTL = 34; optional bool disable_ipv6 = 35; + + // remoteJobsAllowed opts the peer into management-requested remote jobs + // (e.g. debug bundles). Absent leaves the stored value unchanged. + optional bool remoteJobsAllowed = 36; } message SetConfigResponse{} diff --git a/client/server/mdm.go b/client/server/mdm.go index 9836c6bea..d13f174c8 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -297,6 +297,7 @@ func mdmManagedFieldConflicts(msg *proto.SetConfigRequest, policy *mdm.Policy) [ conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), @@ -418,6 +419,7 @@ func loginRequestMDMConflicts(msg *proto.LoginRequest, policy *mdm.Policy) []str conflictBool(mdm.KeyRosenpassPermissive, msg.RosenpassPermissive), conflictBool(mdm.KeyDisableAutoConnect, msg.DisableAutoConnect), conflictBool(mdm.KeyAllowServerSSH, msg.ServerSSHAllowed), + conflictBool(mdm.KeyRemoteJobsAllowed, msg.RemoteJobsAllowed), conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), diff --git a/client/server/server.go b/client/server/server.go index 01778b8e0..734902ed9 100644 --- a/client/server/server.go +++ b/client/server/server.go @@ -35,6 +35,7 @@ import ( "github.com/netbirdio/netbird/client/internal/statemanager" "github.com/netbirdio/netbird/client/internal/updater" "github.com/netbirdio/netbird/client/proto" + "github.com/netbirdio/netbird/util" "github.com/netbirdio/netbird/util/capture" "github.com/netbirdio/netbird/version" ) @@ -553,6 +554,7 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile config.RosenpassPermissive = msg.RosenpassPermissive config.DisableAutoConnect = msg.DisableAutoConnect config.ServerSSHAllowed = msg.ServerSSHAllowed + config.RemoteJobsAllowed = msg.RemoteJobsAllowed config.NetworkMonitor = msg.NetworkMonitor config.DisableClientRoutes = msg.DisableClientRoutes config.DisableServerRoutes = msg.DisableServerRoutes @@ -2107,6 +2109,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p Mtu: int64(cfg.MTU), DisableAutoConnect: cfg.DisableAutoConnect, ServerSSHAllowed: *cfg.ServerSSHAllowed, + RemoteJobsAllowed: util.ReturnBoolWithDefaultFalse(cfg.RemoteJobsAllowed), RosenpassEnabled: cfg.RosenpassEnabled, RosenpassPermissive: cfg.RosenpassPermissive, BlockInbound: cfg.BlockInbound, diff --git a/client/server/setconfig_test.go b/client/server/setconfig_test.go index db7a26f03..2f8786f01 100644 --- a/client/server/setconfig_test.go +++ b/client/server/setconfig_test.go @@ -61,6 +61,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { rosenpassEnabled := true rosenpassPermissive := true serverSSHAllowed := true + remoteJobsAllowed := true interfaceName := "utun100" wireguardPort := int64(51820) preSharedKey := "test-psk" @@ -85,6 +86,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { RosenpassEnabled: &rosenpassEnabled, RosenpassPermissive: &rosenpassPermissive, ServerSSHAllowed: &serverSSHAllowed, + RemoteJobsAllowed: &remoteJobsAllowed, InterfaceName: &interfaceName, WireguardPort: &wireguardPort, OptionalPreSharedKey: &preSharedKey, @@ -128,6 +130,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) { require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive) require.NotNil(t, cfg.ServerSSHAllowed) require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed) + require.NotNil(t, cfg.RemoteJobsAllowed) + require.Equal(t, remoteJobsAllowed, *cfg.RemoteJobsAllowed) require.Equal(t, interfaceName, cfg.WgIface) require.Equal(t, int(wireguardPort), cfg.WgPort) require.Equal(t, preSharedKey, cfg.PreSharedKey) @@ -180,6 +184,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) { "RosenpassEnabled": true, "RosenpassPermissive": true, "ServerSSHAllowed": true, + "RemoteJobsAllowed": true, "InterfaceName": true, "WireguardPort": true, "OptionalPreSharedKey": true, @@ -240,6 +245,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) { "enable-rosenpass": "RosenpassEnabled", "rosenpass-permissive": "RosenpassPermissive", "allow-server-ssh": "ServerSSHAllowed", + "allow-remote-jobs": "RemoteJobsAllowed", "interface-name": "InterfaceName", "wireguard-port": "WireguardPort", "preshared-key": "OptionalPreSharedKey", diff --git a/client/server/ssh_gate.go b/client/server/ssh_gate.go index ca1b4c4ee..906e810bd 100644 --- a/client/server/ssh_gate.go +++ b/client/server/ssh_gate.go @@ -39,27 +39,30 @@ import ( // user-to-root boundary. Fields are nil or empty when the request leaves them // untouched. type privilegedConfigChange struct { - managementURL string - serverSSHAllowed *bool - enableSSHRoot *bool - disableSSHAuth *bool + managementURL string + serverSSHAllowed *bool + remoteJobsAllowed *bool + enableSSHRoot *bool + disableSSHAuth *bool } func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfigChange { return privilegedConfigChange{ - managementURL: msg.GetManagementUrl(), - serverSSHAllowed: msg.ServerSSHAllowed, - enableSSHRoot: msg.EnableSSHRoot, - disableSSHAuth: msg.DisableSSHAuth, + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + remoteJobsAllowed: msg.RemoteJobsAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, } } func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange { return privilegedConfigChange{ - managementURL: msg.GetManagementUrl(), - serverSSHAllowed: msg.ServerSSHAllowed, - enableSSHRoot: msg.EnableSSHRoot, - disableSSHAuth: msg.DisableSSHAuth, + managementURL: msg.GetManagementUrl(), + serverSSHAllowed: msg.ServerSSHAllowed, + remoteJobsAllowed: msg.RemoteJobsAllowed, + enableSSHRoot: msg.EnableSSHRoot, + disableSSHAuth: msg.DisableSSHAuth, } } @@ -83,6 +86,15 @@ func requirePrivilegeForConfigChange(ctx context.Context, stored *profilemanager return denyPrivileged(ctx, "enabling the NetBird SSH server", ipcauth.UpCommand("--allow-server-ssh")) } + // Enabling remote jobs lets the management server run jobs (e.g. debug + // bundles) on this host, so turning it on crosses the user-to-root + // boundary the same way enabling the SSH server does. The stored value + // defaults to off (nil = off), so a legacy config is correctly seen as + // off and turning it on requires privilege. + if enables(storedFlag(stored, func(c *profilemanager.Config) *bool { return c.RemoteJobsAllowed }), change.remoteJobsAllowed) { + return denyPrivileged(ctx, "enabling remote jobs", ipcauth.UpCommand("--allow-remote-jobs")) + } + // Only guard the management binding while the SSH server is enabled: that is // when the management identity decides who may open a shell here. if !sshServerEnabled(stored) { diff --git a/client/server/ssh_gate_test.go b/client/server/ssh_gate_test.go index cbd345f16..e0c1620fb 100644 --- a/client/server/ssh_gate_test.go +++ b/client/server/ssh_gate_test.go @@ -171,6 +171,34 @@ func TestRequirePrivilegeForConfigChange_SSHFlags(t *testing.T) { stored: &profilemanager.Config{DisableSSHAuth: boolPtr(true)}, change: privilegedConfigChange{disableSSHAuth: boolPtr(false)}, }, + { + name: "enabling remote jobs unprivileged is refused", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "enabling remote jobs as root is allowed", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(false)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + privileged: true, + }, + { + name: "a profile with no config yet counts as off, so enabling remote jobs is refused", + stored: nil, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + wantDeny: true, + }, + { + name: "restating already-enabled remote jobs is not a change", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(true)}, + }, + { + name: "turning remote jobs off is not guarded", + stored: &profilemanager.Config{RemoteJobsAllowed: boolPtr(true)}, + change: privilegedConfigChange{remoteJobsAllowed: boolPtr(false)}, + }, { name: "a request that touches none of the guarded fields is allowed", stored: &profilemanager.Config{ServerSSHAllowed: boolPtr(false)}, diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist index fe10b5b63..eec96d35b 100644 --- a/docs/io.netbird.client.plist +++ b/docs/io.netbird.client.plist @@ -85,6 +85,21 @@ --> + + +