mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-21 22:29:08 +02:00
[management,client] Gate remote jobs behind an admin opt-in with MDM support (#7153)
This introduces a disabled-by-default allow-remote-jobs setting that controls whether the management server may run jobs (such as debug bundles) on a peer. The flag propagates end to end: through client configuration, the daemon SetConfig and Login requests, authentication, and system info, up to management, where it is stored on the peer and exposed on the peers API as remote_jobs_allowed. The client refuses any management-requested job unless the peer has opted in. Because enabling remote jobs crosses the user-to-root boundary, turning it on requires privilege, mirroring the SSH-server gate. Administrators can enforce the setting through MDM policy on both macOS and Windows, and MDM can also override the debug-bundle upload URL. The change ships policy documentation and generated profile templates, and adds configuration, conflict, and enforcement tests covering the opt-in, privilege, and MDM paths.
This commit is contained in:
@@ -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")
|
||||
}
|
||||
@@ -428,6 +428,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.
|
||||
@@ -477,6 +488,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 {
|
||||
@@ -568,6 +580,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
|
||||
@@ -727,6 +740,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
}
|
||||
|
||||
setSSHLoginFields(&loginRequest, cmd)
|
||||
setBoolPtrIfChanged(cmd, remoteJobsAllowedFlag, &loginRequest.RemoteJobsAllowed, remoteJobsAllowed)
|
||||
|
||||
if cmd.Flag(disableAutoConnectFlag).Changed {
|
||||
loginRequest.DisableAutoConnect = &autoConnectDisabled
|
||||
|
||||
@@ -368,6 +368,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
||||
a.config.EnableSSHLocalPortForwarding,
|
||||
a.config.EnableSSHRemotePortForwarding,
|
||||
a.config.DisableSSHAuth,
|
||||
a.config.RemoteJobsAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
mURL, _ := url.Parse("https://api.example.com:443")
|
||||
@@ -864,6 +865,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
RosenpassEnabled: true,
|
||||
RosenpassPermissive: true,
|
||||
ServerSSHAllowed: &bTrue,
|
||||
RemoteJobsAllowed: &bTrue,
|
||||
EnableSSHRoot: &bTrue,
|
||||
EnableSSHSFTP: &bTrue,
|
||||
EnableSSHLocalPortForwarding: &bTrue,
|
||||
@@ -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),
|
||||
@@ -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
|
||||
|
||||
+25
-18
@@ -137,6 +137,7 @@ type EngineConfig struct {
|
||||
RosenpassPermissive bool
|
||||
|
||||
ServerSSHAllowed bool
|
||||
RemoteJobsAllowed bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -1259,6 +1260,7 @@ func (e *Engine) applyInfoFlags(info *system.Info) {
|
||||
e.config.EnableSSHLocalPortForwarding,
|
||||
e.config.EnableSSHRemotePortForwarding,
|
||||
e.config.DisableSSHAuth,
|
||||
&e.config.RemoteJobsAllowed,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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},
|
||||
} {
|
||||
|
||||
@@ -70,6 +70,7 @@ type ConfigInput struct {
|
||||
StateFilePath string
|
||||
PreSharedKey *string
|
||||
ServerSSHAllowed *bool
|
||||
RemoteJobsAllowed *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -127,6 +128,7 @@ type Config struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed *bool
|
||||
RemoteJobsAllowed *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -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:"-"`
|
||||
|
||||
MTU uint16
|
||||
|
||||
// policy is the MDM policy that produced the currently-set values for
|
||||
@@ -289,7 +297,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 {
|
||||
@@ -492,6 +503,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")
|
||||
@@ -701,6 +727,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
|
||||
}
|
||||
@@ -748,6 +782,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 })
|
||||
@@ -781,6 +816,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
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/dynamic"
|
||||
"github.com/netbirdio/netbird/client/mdm"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
@@ -271,6 +272,83 @@ func TestUpdateConfigServerSSHAllowedNotSet(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateConfigRemoteJobsAllowed(t *testing.T) {
|
||||
// Unlike SSH (which defaults on for legacy configs), remote jobs are an
|
||||
// explicit opt-in: a pre-existing config with no value materializes to off.
|
||||
t.Run("legacy config defaults off", func(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600))
|
||||
|
||||
config, err := UpdateConfig(ConfigInput{ConfigPath: configPath})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config.RemoteJobsAllowed, "RemoteJobsAllowed should be materialized")
|
||||
assert.False(t, *config.RemoteJobsAllowed, "remote jobs must default off")
|
||||
})
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
input *bool
|
||||
want bool
|
||||
}{
|
||||
{"enable", util.True(), true},
|
||||
{"disable", util.False(), false},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
configPath := filepath.Join(t.TempDir(), "config.json")
|
||||
require.NoError(t, os.WriteFile(configPath, []byte("{}"), 0600))
|
||||
|
||||
config, err := UpdateConfig(ConfigInput{ConfigPath: configPath, RemoteJobsAllowed: tt.input})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config.RemoteJobsAllowed)
|
||||
assert.Equal(t, tt.want, *config.RemoteJobsAllowed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyMDMPolicyRemoteJobs(t *testing.T) {
|
||||
t.Run("enables remote jobs and sets the upload URL override", func(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyRemoteJobsAllowed: true,
|
||||
mdm.KeyBundleUploadURL: "https://upload.example.com",
|
||||
}))
|
||||
require.NotNil(t, cfg.RemoteJobsAllowed)
|
||||
assert.True(t, *cfg.RemoteJobsAllowed, "MDM allowRemoteJobs must enable the flag")
|
||||
assert.Equal(t, "https://upload.example.com", cfg.DebugBundleUploadURL, "MDM upload URL override must be applied")
|
||||
})
|
||||
|
||||
t.Run("a non-https upload URL is rejected", func(t *testing.T) {
|
||||
cfg := &Config{}
|
||||
cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{
|
||||
mdm.KeyBundleUploadURL: "http://insecure.example.com",
|
||||
}))
|
||||
assert.Empty(t, cfg.DebugBundleUploadURL, "a non-https upload URL must be skipped")
|
||||
})
|
||||
|
||||
t.Run("dropping the key clears a previously-applied override", func(t *testing.T) {
|
||||
cfg := &Config{DebugBundleUploadURL: "https://old.example.com"}
|
||||
// A replacement policy that no longer carries the key must not leave
|
||||
// the old upload target directing bundles.
|
||||
cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyRemoteJobsAllowed: true}))
|
||||
assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared")
|
||||
})
|
||||
|
||||
t.Run("an empty replacement policy clears a previously-applied override", func(t *testing.T) {
|
||||
cfg := &Config{DebugBundleUploadURL: "https://old.example.com"}
|
||||
// A policy that becomes empty entirely hits the IsEmpty early return;
|
||||
// the override must still be cleared rather than surviving on the
|
||||
// reused Config instance.
|
||||
cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{}))
|
||||
assert.Empty(t, cfg.DebugBundleUploadURL, "the stale upload URL override must be cleared when the policy empties")
|
||||
})
|
||||
|
||||
t.Run("an invalid upload URL clears a previously-applied override (fail closed)", func(t *testing.T) {
|
||||
cfg := &Config{DebugBundleUploadURL: "https://old.example.com"}
|
||||
cfg.applyMDMPolicy(mdm.NewPolicy(map[string]any{mdm.KeyBundleUploadURL: "not-a-url"}))
|
||||
assert.Empty(t, cfg.DebugBundleUploadURL, "an invalid override must fail closed, not keep the stale target")
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateOldManagementURL(t *testing.T) {
|
||||
origProber := newMgmProber
|
||||
newMgmProber = func(_ context.Context, _ string, _ wgtypes.Key, _ bool) (mgmProber, error) {
|
||||
|
||||
@@ -32,6 +32,8 @@ var allKeys = []string{
|
||||
KeySplitTunnelMode,
|
||||
KeySplitTunnelApps,
|
||||
KeyLazyConnection,
|
||||
KeyRemoteJobsAllowed,
|
||||
KeyBundleUploadURL,
|
||||
}
|
||||
|
||||
// canonicalKey maps the lowercase form of a managed-config value name to
|
||||
|
||||
@@ -62,6 +62,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).
|
||||
@@ -73,6 +84,8 @@ const (
|
||||
// SecretKeys lists keys whose values must be redacted in logs.
|
||||
var SecretKeys = map[string]struct{}{
|
||||
KeyPreSharedKey: {},
|
||||
// The upload URL can embed credentials or signed query tokens.
|
||||
KeyBundleUploadURL: {},
|
||||
}
|
||||
|
||||
// boolStringLiterals enumerates the textual boolean encodings the
|
||||
|
||||
+45
-12
@@ -345,8 +345,11 @@ type LoginRequest struct {
|
||||
DisableIpv6 *bool `protobuf:"varint,40,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
|
||||
EnableLocalMetrics *bool `protobuf:"varint,41,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
|
||||
LocalMetricsAddress *string `protobuf:"bytes,42,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,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,43,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *LoginRequest) Reset() {
|
||||
@@ -674,6 +677,13 @@ func (x *LoginRequest) GetLocalMetricsAddress() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
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"`
|
||||
@@ -1231,6 +1241,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
|
||||
@@ -1460,6 +1471,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
|
||||
@@ -4251,8 +4269,11 @@ type SetConfigRequest struct {
|
||||
DisableIpv6 *bool `protobuf:"varint,35,opt,name=disable_ipv6,json=disableIpv6,proto3,oneof" json:"disable_ipv6,omitempty"`
|
||||
EnableLocalMetrics *bool `protobuf:"varint,36,opt,name=enable_local_metrics,json=enableLocalMetrics,proto3,oneof" json:"enable_local_metrics,omitempty"`
|
||||
LocalMetricsAddress *string `protobuf:"bytes,37,opt,name=local_metrics_address,json=localMetricsAddress,proto3,oneof" json:"local_metrics_address,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,38,opt,name=remoteJobsAllowed,proto3,oneof" json:"remoteJobsAllowed,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *SetConfigRequest) Reset() {
|
||||
@@ -4544,6 +4565,13 @@ func (x *SetConfigRequest) GetLocalMetricsAddress() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
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
|
||||
@@ -7064,7 +7092,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\"\x92\x14\n" +
|
||||
"\fEmptyRequest\"\xdb\x14\n" +
|
||||
"\fLoginRequest\x12\x1a\n" +
|
||||
"\bsetupKey\x18\x01 \x01(\tR\bsetupKey\x12&\n" +
|
||||
"\fpreSharedKey\x18\x02 \x01(\tB\x02\x18\x01R\fpreSharedKey\x12$\n" +
|
||||
@@ -7111,7 +7139,8 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x0esshJWTCacheTTL\x18' \x01(\x05H\x1aR\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
|
||||
"\fdisable_ipv6\x18( \x01(\bH\x1bR\vdisableIpv6\x88\x01\x01\x125\n" +
|
||||
"\x14enable_local_metrics\x18) \x01(\bH\x1cR\x12enableLocalMetrics\x88\x01\x01\x127\n" +
|
||||
"\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01B\x13\n" +
|
||||
"\x15local_metrics_address\x18* \x01(\tH\x1dR\x13localMetricsAddress\x88\x01\x01\x121\n" +
|
||||
"\x11remoteJobsAllowed\x18+ \x01(\bH\x1eR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" +
|
||||
"\x11_rosenpassEnabledB\x10\n" +
|
||||
"\x0e_interfaceNameB\x10\n" +
|
||||
"\x0e_wireguardPortB\x17\n" +
|
||||
@@ -7141,7 +7170,8 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x0f_sshJWTCacheTTLB\x0f\n" +
|
||||
"\r_disable_ipv6B\x17\n" +
|
||||
"\x15_enable_local_metricsB\x18\n" +
|
||||
"\x16_local_metrics_address\"\xb5\x01\n" +
|
||||
"\x16_local_metrics_addressB\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" +
|
||||
@@ -7176,7 +7206,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" +
|
||||
@@ -7208,7 +7238,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" +
|
||||
@@ -7436,7 +7467,7 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\f_profileNameB\v\n" +
|
||||
"\t_username\"'\n" +
|
||||
"\x15SwitchProfileResponse\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\"\xbb\x12\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\"\x84\x13\n" +
|
||||
"\x10SetConfigRequest\x12\x1a\n" +
|
||||
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
|
||||
"\vprofileName\x18\x02 \x01(\tR\vprofileName\x12$\n" +
|
||||
@@ -7478,7 +7509,8 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x0esshJWTCacheTTL\x18\" \x01(\x05H\x17R\x0esshJWTCacheTTL\x88\x01\x01\x12&\n" +
|
||||
"\fdisable_ipv6\x18# \x01(\bH\x18R\vdisableIpv6\x88\x01\x01\x125\n" +
|
||||
"\x14enable_local_metrics\x18$ \x01(\bH\x19R\x12enableLocalMetrics\x88\x01\x01\x127\n" +
|
||||
"\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01B\x13\n" +
|
||||
"\x15local_metrics_address\x18% \x01(\tH\x1aR\x13localMetricsAddress\x88\x01\x01\x121\n" +
|
||||
"\x11remoteJobsAllowed\x18& \x01(\bH\x1bR\x11remoteJobsAllowed\x88\x01\x01B\x13\n" +
|
||||
"\x11_rosenpassEnabledB\x10\n" +
|
||||
"\x0e_interfaceNameB\x10\n" +
|
||||
"\x0e_wireguardPortB\x17\n" +
|
||||
@@ -7505,7 +7537,8 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x0f_sshJWTCacheTTLB\x0f\n" +
|
||||
"\r_disable_ipv6B\x17\n" +
|
||||
"\x15_enable_local_metricsB\x18\n" +
|
||||
"\x16_local_metrics_address\"\x13\n" +
|
||||
"\x16_local_metrics_addressB\x14\n" +
|
||||
"\x12_remoteJobsAllowed\"\x13\n" +
|
||||
"\x11SetConfigResponse\"Q\n" +
|
||||
"\x11AddProfileRequest\x12\x1a\n" +
|
||||
"\busername\x18\x01 \x01(\tR\busername\x12 \n" +
|
||||
|
||||
@@ -245,6 +245,9 @@ message LoginRequest {
|
||||
|
||||
optional bool enable_local_metrics = 41;
|
||||
optional string local_metrics_address = 42;
|
||||
// remoteJobsAllowed opts the peer into management-requested remote jobs
|
||||
// (e.g. debug bundles). Absent leaves the stored value unchanged.
|
||||
optional bool remoteJobsAllowed = 43;
|
||||
}
|
||||
|
||||
message LoginResponse {
|
||||
@@ -365,6 +368,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
|
||||
@@ -772,6 +777,9 @@ message SetConfigRequest {
|
||||
|
||||
optional bool enable_local_metrics = 36;
|
||||
optional string local_metrics_address = 37;
|
||||
// remoteJobsAllowed opts the peer into management-requested remote jobs
|
||||
// (e.g. debug bundles). Absent leaves the stored value unchanged.
|
||||
optional bool remoteJobsAllowed = 38;
|
||||
}
|
||||
|
||||
message SetConfigResponse{}
|
||||
|
||||
@@ -315,6 +315,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),
|
||||
@@ -352,6 +353,7 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool {
|
||||
msg.Mtu != nil ||
|
||||
msg.DisableAutoConnect != nil ||
|
||||
msg.ServerSSHAllowed != nil ||
|
||||
msg.RemoteJobsAllowed != nil ||
|
||||
msg.NetworkMonitor != nil ||
|
||||
msg.DisableClientRoutes != nil ||
|
||||
msg.DisableServerRoutes != nil ||
|
||||
@@ -392,6 +394,7 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool {
|
||||
msg.WireguardPort != nil ||
|
||||
msg.DisableAutoConnect != nil ||
|
||||
msg.ServerSSHAllowed != nil ||
|
||||
msg.RemoteJobsAllowed != nil ||
|
||||
msg.RosenpassPermissive != nil ||
|
||||
len(msg.ExtraIFaceBlacklist) > 0 ||
|
||||
msg.NetworkMonitor != nil ||
|
||||
@@ -442,6 +445,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),
|
||||
|
||||
@@ -38,6 +38,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"
|
||||
)
|
||||
@@ -586,6 +587,7 @@ func (s *Server) setConfigInputFromRequest(msg *proto.SetConfigRequest) (profile
|
||||
config.LocalMetricsAddress = msg.LocalMetricsAddress
|
||||
config.DisableAutoConnect = msg.DisableAutoConnect
|
||||
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
||||
config.RemoteJobsAllowed = msg.RemoteJobsAllowed
|
||||
config.NetworkMonitor = msg.NetworkMonitor
|
||||
config.DisableClientRoutes = msg.DisableClientRoutes
|
||||
config.DisableServerRoutes = msg.DisableServerRoutes
|
||||
@@ -2189,6 +2191,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,
|
||||
|
||||
@@ -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"
|
||||
@@ -87,6 +88,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
RosenpassEnabled: &rosenpassEnabled,
|
||||
RosenpassPermissive: &rosenpassPermissive,
|
||||
ServerSSHAllowed: &serverSSHAllowed,
|
||||
RemoteJobsAllowed: &remoteJobsAllowed,
|
||||
InterfaceName: &interfaceName,
|
||||
WireguardPort: &wireguardPort,
|
||||
OptionalPreSharedKey: &preSharedKey,
|
||||
@@ -132,6 +134,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)
|
||||
@@ -186,6 +190,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
|
||||
"RosenpassEnabled": true,
|
||||
"RosenpassPermissive": true,
|
||||
"ServerSSHAllowed": true,
|
||||
"RemoteJobsAllowed": true,
|
||||
"InterfaceName": true,
|
||||
"WireguardPort": true,
|
||||
"OptionalPreSharedKey": true,
|
||||
@@ -248,6 +253,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",
|
||||
|
||||
@@ -44,6 +44,7 @@ import (
|
||||
type privilegedConfigChange struct {
|
||||
managementURL string
|
||||
serverSSHAllowed *bool
|
||||
remoteJobsAllowed *bool
|
||||
enableSSHRoot *bool
|
||||
disableSSHAuth *bool
|
||||
enableLocalMetrics *bool
|
||||
@@ -54,6 +55,7 @@ func privilegedChangeFromSetConfig(msg *proto.SetConfigRequest) privilegedConfig
|
||||
return privilegedConfigChange{
|
||||
managementURL: msg.GetManagementUrl(),
|
||||
serverSSHAllowed: msg.ServerSSHAllowed,
|
||||
remoteJobsAllowed: msg.RemoteJobsAllowed,
|
||||
enableSSHRoot: msg.EnableSSHRoot,
|
||||
disableSSHAuth: msg.DisableSSHAuth,
|
||||
enableLocalMetrics: msg.EnableLocalMetrics,
|
||||
@@ -65,6 +67,7 @@ func privilegedChangeFromLogin(msg *proto.LoginRequest) privilegedConfigChange {
|
||||
return privilegedConfigChange{
|
||||
managementURL: msg.GetManagementUrl(),
|
||||
serverSSHAllowed: msg.ServerSSHAllowed,
|
||||
remoteJobsAllowed: msg.RemoteJobsAllowed,
|
||||
enableSSHRoot: msg.EnableSSHRoot,
|
||||
disableSSHAuth: msg.DisableSSHAuth,
|
||||
enableLocalMetrics: msg.EnableLocalMetrics,
|
||||
@@ -92,6 +95,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"))
|
||||
}
|
||||
|
||||
if addr, exposes := exposesLocalMetrics(stored, change); exposes {
|
||||
return denyPrivileged(ctx,
|
||||
"exposing the local metrics endpoint on a non-loopback address",
|
||||
|
||||
@@ -173,6 +173,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)},
|
||||
|
||||
@@ -65,6 +65,7 @@ type Info struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed bool
|
||||
RemoteJobsAllowed bool
|
||||
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -90,12 +91,16 @@ func (i *Info) SetFlags(
|
||||
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6 bool, syncMessageVersion *int,
|
||||
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
|
||||
disableSSHAuth *bool,
|
||||
remoteJobsAllowed *bool,
|
||||
) {
|
||||
i.RosenpassEnabled = rosenpassEnabled
|
||||
i.RosenpassPermissive = rosenpassPermissive
|
||||
if serverSSHAllowed != nil {
|
||||
i.ServerSSHAllowed = *serverSSHAllowed
|
||||
}
|
||||
if remoteJobsAllowed != nil {
|
||||
i.RemoteJobsAllowed = *remoteJobsAllowed
|
||||
}
|
||||
|
||||
i.DisableClientRoutes = disableClientRoutes
|
||||
i.DisableServerRoutes = disableServerRoutes
|
||||
|
||||
Reference in New Issue
Block a user