[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.
This commit is contained in:
mlsmaycon
2026-08-11 11:32:03 +00:00
committed by Maycon Santos
parent d32573020a
commit 7da4127c7d
15 changed files with 227 additions and 27 deletions

13
client/cmd/jobs.go Normal file
View File

@@ -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")
}

View File

@@ -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

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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

View File

@@ -30,6 +30,8 @@ var allKeys = []string{
KeySplitTunnelMode,
KeySplitTunnelApps,
KeyLazyConnection,
KeyRemoteJobsAllowed,
KeyBundleUploadURL,
}
// canonicalKey maps the lowercase form of a managed-config value name to

View File

@@ -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).

View File

@@ -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" +

View File

@@ -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{}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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)},

View File

@@ -85,6 +85,21 @@
<false/>
-->
<!-- ===== Remote jobs (debug bundles) =====
allowRemoteJobs : opt this device into management-requested
remote jobs (e.g. debug bundles). Off by
default; enabling is a privileged change.
debugBundleUploadURL : override the debug-bundle upload service URL
for remote jobs (https URL with a host). Takes
precedence over the management-supplied value. -->
<!--
<key>allowRemoteJobs</key>
<true/>
<key>debugBundleUploadURL</key>
<string>https://upload.example.com</string>
-->
<!-- ===== WireGuard UDP port =====
Range 1-65535. Omit to keep the daemon default. -->
<!--