[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-13 02:12:47 +09:00
committed by Maycon Santos
parent d32573020a
commit 7da4127c7d
15 changed files with 227 additions and 27 deletions
+2
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),
+3
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,
+6
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",
+24 -12
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) {
+28
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)},