mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 20:41:28 +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:
13
client/cmd/jobs.go
Normal file
13
client/cmd/jobs.go
Normal 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")
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. -->
|
||||
<!--
|
||||
|
||||
@@ -121,6 +121,19 @@
|
||||
<false/>
|
||||
-->
|
||||
|
||||
<!-- ===== Remote jobs (debug bundles) =====
|
||||
allowRemoteJobs : opt into management-requested
|
||||
remote jobs. Off by default.
|
||||
debugBundleUploadURL : override the debug-bundle upload
|
||||
service (https URL with a host);
|
||||
precedence over the management value. -->
|
||||
<!--
|
||||
<key>allowRemoteJobs</key>
|
||||
<true/>
|
||||
<key>debugBundleUploadURL</key>
|
||||
<string>https://upload.example.com</string>
|
||||
-->
|
||||
|
||||
<!-- ===== WireGuard UDP port (int) =====
|
||||
Range 1-65535. Omit to keep the default. -->
|
||||
<!--
|
||||
|
||||
@@ -36,7 +36,9 @@
|
||||
# IDEMPOTENCY: re-running with the same values is a no-op from the
|
||||
# daemon's point of view (the 1-minute reload ticker diff returns empty).
|
||||
#
|
||||
# SECURITY: PreSharedKey is redacted in this script's log output.
|
||||
# SECURITY: PreSharedKey (and any secret-bearing debugBundleUploadURL) is
|
||||
# redacted in this script's log output, and the installed plist is 0600
|
||||
# root:wheel so its values are not readable by local non-root users.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -56,6 +58,8 @@ NULL='__UNSET__'
|
||||
managementURL='https://api.netbird.io:443'
|
||||
preSharedKey="$NULL" # secret; redacted in log
|
||||
allowServerSSH='true'
|
||||
allowRemoteJobs="$NULL"
|
||||
debugBundleUploadURL="$NULL" # HTTPS URL with a host; overrides management
|
||||
blockInbound="$NULL"
|
||||
disableAutoConnect="$NULL"
|
||||
disableAutostart="$NULL"
|
||||
@@ -107,21 +111,35 @@ end_plist() {
|
||||
EOF
|
||||
}
|
||||
|
||||
# emit_string appends a plist `<key>`/`<string>` entry for the given key and value to "$PLIST_PATH.tmp", XML-escaping `&`, `<`, and `>`, and logs the assignment (masking the logged value as `********** (secret)` when the key is `preSharedKey`).
|
||||
# emit_string appends a plist `<key>`/`<string>` entry for the given key and value to "$PLIST_PATH.tmp", XML-escaping `&`, `<`, and `>`, and logs the assignment (masking the logged value as `********** (secret)` for secret keys — `preSharedKey` and `debugBundleUploadURL`, which can embed credentials or a signed query token).
|
||||
emit_string() {
|
||||
local key="$1" value="$2" log_value="$2"
|
||||
# Escape XML entities in the value
|
||||
local escaped
|
||||
escaped="$(printf '%s' "$value" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g')"
|
||||
printf ' <key>%s</key>\n <string>%s</string>\n' "$key" "$escaped" >> "$PLIST_PATH.tmp"
|
||||
if [[ "$key" == "preSharedKey" ]]; then
|
||||
log_value='********** (secret)'
|
||||
fi
|
||||
case "$key" in
|
||||
preSharedKey|debugBundleUploadURL) log_value='********** (secret)' ;;
|
||||
*) ;;
|
||||
esac
|
||||
log "set $key = $log_value"
|
||||
}
|
||||
|
||||
# emit_bool writes a boolean plist entry for a given key into the temporary plist file.
|
||||
# emit_bool writes a boolean plist entry for a key when the provided value matches an accepted boolean token; logs an error and skips the key on invalid input.
|
||||
# is_bool returns success if the value is an accepted boolean token.
|
||||
is_bool() {
|
||||
local value="$1"
|
||||
case "$value" in
|
||||
true|True|TRUE|1|yes|false|False|FALSE|0|no) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
# emit_bool writes a boolean plist entry for a key when the provided value matches
|
||||
# an accepted boolean token; logs an error and skips the key on invalid input.
|
||||
# It returns success even on invalid input (like emit_int) so a single typo in one
|
||||
# boolean does not abort the whole policy push under `set -euo pipefail`. Callers
|
||||
# that must fail closed on an invalid value (e.g. allowRemoteJobs) validate with
|
||||
# is_bool before calling and substitute a safe default themselves.
|
||||
emit_bool() {
|
||||
local key="$1" value="$2"
|
||||
local xml_bool
|
||||
@@ -145,15 +163,35 @@ emit_int() {
|
||||
log "set $key = $value"
|
||||
}
|
||||
|
||||
# main builds the NetBird MDM plist from configured policy variables, validates and installs it to /Library/Managed Preferences/io.netbird.client.plist (root:wheel, 644) and optionally triggers the NetBird daemon to reload.
|
||||
# main builds the NetBird MDM plist from configured policy variables, validates and installs it to /Library/Managed Preferences/io.netbird.client.plist (root:wheel, 600 — the daemon reads it directly as root, so it need not be world-readable) and optionally triggers the NetBird daemon to reload.
|
||||
main() {
|
||||
log "applying NetBird MDM policy to $PLIST_PATH"
|
||||
# Restrict the temp plist while it is being built: it carries the same
|
||||
# secret-bearing values as the final file, which is installed 0600 below.
|
||||
umask 077
|
||||
/bin/mkdir -p "$PLIST_DIR"
|
||||
start_plist
|
||||
# Force 0600 on the temp file explicitly: start_plist writes it with a
|
||||
# truncating redirect, which keeps an existing file's mode, so a leftover
|
||||
# 0644 tmp from an interrupted run would not be tightened by umask alone.
|
||||
# start_plist only wrote the header so far — the secret-bearing values are
|
||||
# appended after this point.
|
||||
/bin/chmod 600 "$PLIST_PATH.tmp"
|
||||
|
||||
is_set "$managementURL" && emit_string managementURL "$managementURL"
|
||||
is_set "$preSharedKey" && emit_string preSharedKey "$preSharedKey"
|
||||
is_set "$allowServerSSH" && emit_bool allowServerSSH "$allowServerSSH"
|
||||
# Fail closed: an invalid allowRemoteJobs value must not drop the key and
|
||||
# leave a conflicting local opt-in active — enforce the safe default (false).
|
||||
if is_set "$allowRemoteJobs"; then
|
||||
if is_bool "$allowRemoteJobs"; then
|
||||
emit_bool allowRemoteJobs "$allowRemoteJobs"
|
||||
else
|
||||
log "invalid boolean for allowRemoteJobs: $allowRemoteJobs; enforcing safe default (false)"
|
||||
emit_bool allowRemoteJobs false
|
||||
fi
|
||||
fi
|
||||
is_set "$debugBundleUploadURL" && emit_string debugBundleUploadURL "$debugBundleUploadURL"
|
||||
is_set "$blockInbound" && emit_bool blockInbound "$blockInbound"
|
||||
is_set "$disableAutoConnect" && emit_bool disableAutoConnect "$disableAutoConnect"
|
||||
is_set "$disableAutostart" && emit_bool disableAutostart "$disableAutostart"
|
||||
@@ -181,7 +219,12 @@ main() {
|
||||
|
||||
/bin/mv -f "$PLIST_PATH.tmp" "$PLIST_PATH"
|
||||
/usr/sbin/chown root:wheel "$PLIST_PATH"
|
||||
/bin/chmod 644 "$PLIST_PATH"
|
||||
# 0600, not 0644: the daemon's loader (client/mdm/policy_darwin.go) opens the
|
||||
# plist directly as root, so it does not need to be world-readable. Restricting
|
||||
# it keeps secret-bearing values (preSharedKey, a signed debugBundleUploadURL)
|
||||
# from any local non-root user. The loader's only mode check refuses a
|
||||
# world-writable file, which 0600 satisfies.
|
||||
/bin/chmod 600 "$PLIST_PATH"
|
||||
|
||||
log "policy installed; NetBird daemon will pick it up within the next 1-minute reload tick"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -39,6 +39,12 @@
|
||||
<string id="AllowServerSSH_Name">Allow server SSH</string>
|
||||
<string id="AllowServerSSH_Help">When enabled, this client accepts incoming SSH sessions via NetBird SSH. Equivalent to --allow-server-ssh.</string>
|
||||
|
||||
<string id="AllowRemoteJobs_Name">Allow remote jobs</string>
|
||||
<string id="AllowRemoteJobs_Help">When enabled, this client accepts management-requested remote jobs (e.g. debug bundles). Off by default. Equivalent to --allow-remote-jobs.</string>
|
||||
|
||||
<string id="DebugBundleUploadURL_Name">Debug bundle upload URL</string>
|
||||
<string id="DebugBundleUploadURL_Help">Overrides the upload service used for debug bundles produced by remote jobs, taking precedence over the value requested by management. Must be an https URL with a host.</string>
|
||||
|
||||
<string id="RosenpassEnabled_Name">Enable Rosenpass</string>
|
||||
<string id="RosenpassEnabled_Help">Enables Rosenpass post-quantum key exchange on WireGuard tunnels. Both peers must support it.</string>
|
||||
|
||||
@@ -79,6 +85,12 @@
|
||||
</textBox>
|
||||
</presentation>
|
||||
|
||||
<presentation id="DebugBundleUploadURL_Pres">
|
||||
<textBox refId="DebugBundleUploadURL_Text">
|
||||
<label>Debug bundle upload URL:</label>
|
||||
</textBox>
|
||||
</presentation>
|
||||
|
||||
<presentation id="PreSharedKey_Pres">
|
||||
<textBox refId="PreSharedKey_Text">
|
||||
<label>Pre-shared key:</label>
|
||||
|
||||
@@ -124,6 +124,31 @@
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="AllowRemoteJobs"
|
||||
class="Machine"
|
||||
displayName="$(string.AllowRemoteJobs_Name)"
|
||||
explainText="$(string.AllowRemoteJobs_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
valueName="AllowRemoteJobs">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<enabledValue><decimal value="1" /></enabledValue>
|
||||
<disabledValue><decimal value="0" /></disabledValue>
|
||||
</policy>
|
||||
|
||||
<policy name="DebugBundleUploadURL"
|
||||
class="Machine"
|
||||
displayName="$(string.DebugBundleUploadURL_Name)"
|
||||
explainText="$(string.DebugBundleUploadURL_Help)"
|
||||
key="Software\Policies\NetBird"
|
||||
presentation="$(presentation.DebugBundleUploadURL_Pres)">
|
||||
<parentCategory ref="NetBird" />
|
||||
<supportedOn ref="SUPPORTED_NetBird_All" />
|
||||
<elements>
|
||||
<text id="DebugBundleUploadURL_Text" valueName="DebugBundleUploadURL" required="false" />
|
||||
</elements>
|
||||
</policy>
|
||||
|
||||
<policy name="RosenpassEnabled"
|
||||
class="Machine"
|
||||
displayName="$(string.RosenpassEnabled_Name)"
|
||||
|
||||
@@ -31,6 +31,9 @@ const (
|
||||
// Client is a running NetBird client container joined to the combined server.
|
||||
type Client struct {
|
||||
container testcontainers.Container
|
||||
// name is the container hostname the agent reports to management at
|
||||
// registration — the name the peer appears under in the peers API.
|
||||
name string
|
||||
}
|
||||
|
||||
// clientOptions is what the ClientOption values assemble.
|
||||
@@ -99,24 +102,38 @@ func StartClient(ctx context.Context, c *Combined, setupKey string, opts ...Clie
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("start client container: %w", err)
|
||||
}
|
||||
return &Client{container: ctr}, nil
|
||||
return &Client{container: ctr, name: o.name}, nil
|
||||
}
|
||||
|
||||
// Hostname returns the container hostname the agent reports to management —
|
||||
// the name the registered peer appears under in the peers API.
|
||||
func (cl *Client) Hostname() string {
|
||||
return cl.name
|
||||
}
|
||||
|
||||
// Restart bounces the client connection (netbird down/up) so it pulls a fresh
|
||||
// network map — the documented workaround for a freshly-joined client not yet
|
||||
// seeing a synthesized agent-network service.
|
||||
func (cl *Client) Restart(ctx context.Context) error {
|
||||
return cl.Up(ctx)
|
||||
}
|
||||
|
||||
// Up re-runs `netbird up` inside the client with the given extra flags (e.g.
|
||||
// "--allow-remote-jobs"), bouncing the connection first so the new config is
|
||||
// picked up and re-synced to management. Used to toggle peer options that ride
|
||||
// on the login/sync request without recreating the container.
|
||||
func (cl *Client) Up(ctx context.Context, extraArgs ...string) error {
|
||||
if _, _, err := cl.container.Exec(ctx, []string{"netbird", "down"}, tcexec.Multiplexed()); err != nil {
|
||||
return fmt.Errorf("netbird down: %w", err)
|
||||
}
|
||||
time.Sleep(2 * time.Second)
|
||||
code, reader, err := cl.container.Exec(ctx, []string{"netbird", "up"}, tcexec.Multiplexed())
|
||||
code, reader, err := cl.container.Exec(ctx, append([]string{"netbird", "up"}, extraArgs...), tcexec.Multiplexed())
|
||||
if err != nil {
|
||||
return fmt.Errorf("netbird up: %w", err)
|
||||
}
|
||||
if code != 0 {
|
||||
out, _ := io.ReadAll(reader)
|
||||
return fmt.Errorf("netbird up exited %d: %s", code, string(out))
|
||||
return fmt.Errorf("netbird up %v exited %d: %s", extraArgs, code, string(out))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
47
e2e/remotejobs/main_test.go
Normal file
47
e2e/remotejobs/main_test.go
Normal file
@@ -0,0 +1,47 @@
|
||||
//go:build e2e
|
||||
|
||||
// Package remotejobs holds the container-based e2e suite for the remote-jobs
|
||||
// opt-in (PR #7153) and the debug-bundle job parameters anonymize_level /
|
||||
// upload_url (PR #7147). A combined server is built and bootstrapped once per
|
||||
// package run (TestMain) and shared via srv; each test registers its own client
|
||||
// and cleans it up.
|
||||
package remotejobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
)
|
||||
|
||||
// srv is the shared combined server for the package, PAT-authenticated by the
|
||||
// time any Test runs.
|
||||
var srv *harness.Combined
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
os.Exit(run(m))
|
||||
}
|
||||
|
||||
func run(m *testing.M) int {
|
||||
// Generous timeout to cover a cold image build on first run.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
var err error
|
||||
srv, err = harness.StartCombined(ctx)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: start combined server: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
defer func() { _ = srv.Terminate(context.Background()) }()
|
||||
|
||||
if _, err := srv.Bootstrap(ctx); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "e2e: bootstrap admin PAT: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
|
||||
return m.Run()
|
||||
}
|
||||
197
e2e/remotejobs/remotejobs_test.go
Normal file
197
e2e/remotejobs/remotejobs_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
//go:build e2e
|
||||
|
||||
package remotejobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/e2e/harness"
|
||||
"github.com/netbirdio/netbird/shared/management/http/api"
|
||||
)
|
||||
|
||||
const (
|
||||
refusedReason = "remote jobs are not enabled on this peer"
|
||||
// testUploadURL is the debug-bundle upload URL the job subtests pass; it only
|
||||
// needs to be a well-formed https URL with a host (see ValidateBundleUploadURL).
|
||||
testUploadURL = "https://uploads.example.com/bundle"
|
||||
)
|
||||
|
||||
// TestRemoteJobsOptInAndBundleParams exercises the two PRs end-to-end against a
|
||||
// live management server and a real client:
|
||||
//
|
||||
// - #7153: the peer's remote-jobs opt-in defaults off, is reported to
|
||||
// management (visible via the peers API as remote_jobs_allowed), and gates
|
||||
// job execution on the client — a streamed job is refused until the peer
|
||||
// opts in with `netbird up --allow-remote-jobs`, after which it runs.
|
||||
// - #7147: the debug-bundle job's anonymize_level is validated (an unknown
|
||||
// value is rejected at creation) and normalized (trimmed + lowercased) in
|
||||
// the stored job the API returns.
|
||||
func TestRemoteJobsOptInAndBundleParams(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// A group for the setup key to auto-assign; peers must land in some group.
|
||||
grp, err := srv.API().Groups.Create(ctx, api.PostApiGroupsJSONRequestBody{Name: "e2e-remotejobs"})
|
||||
require.NoError(t, err, "create group")
|
||||
t.Cleanup(func() { _ = srv.API().Groups.Delete(context.Background(), grp.Id) })
|
||||
|
||||
sk, err := srv.API().SetupKeys.Create(ctx, api.PostApiSetupKeysJSONRequestBody{
|
||||
Name: "e2e-remotejobs",
|
||||
Type: "reusable",
|
||||
ExpiresIn: 86400,
|
||||
UsageLimit: 0,
|
||||
AutoGroups: []string{grp.Id},
|
||||
})
|
||||
require.NoError(t, err, "mint setup key")
|
||||
require.NotEmpty(t, sk.Key, "setup key plaintext")
|
||||
t.Cleanup(func() { _ = srv.API().SetupKeys.Delete(context.Background(), sk.Id) })
|
||||
|
||||
// Start the client with a plain `netbird up` (remote jobs NOT enabled).
|
||||
cl, err := harness.StartClient(ctx, srv, sk.Key)
|
||||
require.NoError(t, err, "start client")
|
||||
t.Cleanup(func() { _ = cl.Terminate(context.Background()) })
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must connect to management")
|
||||
|
||||
peerID := waitForPeer(ctx, t, cl.Hostname())
|
||||
|
||||
t.Run("opt-in flag defaults to false and is reported to management (#7153)", func(t *testing.T) {
|
||||
p, err := srv.API().Peers.Get(ctx, peerID)
|
||||
require.NoError(t, err)
|
||||
allowed := remoteJobsAllowed(p)
|
||||
require.NotNil(t, allowed, "remote_jobs_allowed must be present on the peer API")
|
||||
assert.False(t, *allowed, "a peer that ran plain `netbird up` must default to opt-out")
|
||||
})
|
||||
|
||||
t.Run("anonymize_level is validated and normalized (#7147)", func(t *testing.T) {
|
||||
// Unknown level is rejected at job creation.
|
||||
_, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("bogus", testUploadURL))
|
||||
require.Error(t, err, "an unknown anonymize_level must be rejected")
|
||||
assert.Contains(t, strings.ToLower(err.Error()), "anonymize_level",
|
||||
"the rejection must name the offending field")
|
||||
|
||||
// A messy but valid level is normalized (trimmed + lowercased) in the
|
||||
// stored job the API echoes back.
|
||||
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob(" Strict ", testUploadURL))
|
||||
require.NoError(t, err, "a valid anonymize_level must be accepted")
|
||||
bw, err := job.Workload.AsBundleWorkloadResponse()
|
||||
require.NoError(t, err, "job workload must be a bundle")
|
||||
require.NotNil(t, bw.Parameters.AnonymizeLevel)
|
||||
assert.Equal(t, "strict", *bw.Parameters.AnonymizeLevel,
|
||||
"anonymize_level must be normalized to trimmed lowercase")
|
||||
waitForJobTerminal(ctx, t, peerID, job.Id) // let it settle before the next create
|
||||
})
|
||||
|
||||
t.Run("a job is refused while the peer has not opted in (#7153 enforcement)", func(t *testing.T) {
|
||||
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("default", testUploadURL))
|
||||
require.NoError(t, err, "job creation itself is allowed; enforcement is on the client")
|
||||
final := waitForJobTerminal(ctx, t, peerID, job.Id)
|
||||
assert.Equal(t, api.JobResponseStatusFailed, final.Status, "the client must refuse the job")
|
||||
require.NotNil(t, final.FailedReason)
|
||||
assert.Contains(t, *final.FailedReason, refusedReason,
|
||||
"the failure must be the opt-out refusal, not some other error")
|
||||
})
|
||||
|
||||
t.Run("opting in flips the flag and lets the job run (#7153)", func(t *testing.T) {
|
||||
require.NoError(t, cl.Up(ctx, "--allow-remote-jobs"), "re-run up with --allow-remote-jobs")
|
||||
require.NoError(t, cl.WaitConnected(ctx, 90*time.Second), "client must reconnect")
|
||||
|
||||
// The new opt-in must round-trip to management and surface on the API.
|
||||
require.Eventually(t, func() bool {
|
||||
p, err := srv.API().Peers.Get(ctx, peerID)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
allowed := remoteJobsAllowed(p)
|
||||
return allowed != nil && *allowed
|
||||
}, 60*time.Second, 2*time.Second, "remote_jobs_allowed must become true after opt-in")
|
||||
|
||||
// The same job that was refused before must now be accepted for
|
||||
// execution: whatever its outcome, it must NOT be the opt-out refusal.
|
||||
job, err := srv.API().Peers.Jobs(peerID).Create(ctx, bundleJob("default", testUploadURL))
|
||||
require.NoError(t, err)
|
||||
final := waitForJobTerminal(ctx, t, peerID, job.Id)
|
||||
if final.Status == api.JobResponseStatusFailed && final.FailedReason != nil {
|
||||
assert.NotContains(t, *final.FailedReason, refusedReason,
|
||||
"once opted in, the job must not be refused for opt-out; any failure must be for another reason (e.g. upload)")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// remoteJobsAllowed returns the peer's remote-jobs opt-in flag from the API
|
||||
// response (nil if the peer or its local flags are absent).
|
||||
func remoteJobsAllowed(p *api.Peer) *bool {
|
||||
if p == nil || p.LocalFlags == nil {
|
||||
return nil
|
||||
}
|
||||
return p.LocalFlags.RemoteJobsAllowed
|
||||
}
|
||||
|
||||
// bundleJob builds a debug-bundle job request with the given anonymize_level
|
||||
// (omitted when empty) and upload_url (omitted when empty).
|
||||
func bundleJob(anonymizeLevel, uploadURL string) api.JobRequest {
|
||||
params := api.BundleParameters{
|
||||
Anonymize: true,
|
||||
LogFileCount: 1,
|
||||
}
|
||||
if anonymizeLevel != "" {
|
||||
params.AnonymizeLevel = &anonymizeLevel
|
||||
}
|
||||
if uploadURL != "" {
|
||||
params.UploadUrl = &uploadURL
|
||||
}
|
||||
var wl api.WorkloadRequest
|
||||
// FromBundleWorkloadRequest cannot fail for a well-formed value.
|
||||
_ = wl.FromBundleWorkloadRequest(api.BundleWorkloadRequest{
|
||||
Type: api.WorkloadTypeBundle,
|
||||
Parameters: params,
|
||||
})
|
||||
return api.JobRequest{Workload: wl}
|
||||
}
|
||||
|
||||
// waitForPeer polls the peers API until the client that registered under the
|
||||
// given hostname appears and returns its ID. Matching by hostname rather than
|
||||
// taking the first list entry keeps the test correct if the account ever holds
|
||||
// more than one peer (a shared bootstrap account, or a second client added to
|
||||
// the package).
|
||||
func waitForPeer(ctx context.Context, t *testing.T, hostname string) string {
|
||||
t.Helper()
|
||||
var peerID string
|
||||
require.Eventually(t, func() bool {
|
||||
peers, err := srv.API().Peers.List(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, p := range peers {
|
||||
if p.Hostname == hostname {
|
||||
peerID = p.Id
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 60*time.Second, 2*time.Second, "the client peer must register with management")
|
||||
return peerID
|
||||
}
|
||||
|
||||
// waitForJobTerminal polls a job until it leaves the pending state, then returns
|
||||
// the final response.
|
||||
func waitForJobTerminal(ctx context.Context, t *testing.T, peerID, jobID string) *api.JobResponse {
|
||||
t.Helper()
|
||||
var final *api.JobResponse
|
||||
require.Eventually(t, func() bool {
|
||||
j, err := srv.API().Peers.Jobs(peerID).Get(ctx, jobID)
|
||||
if err != nil || j == nil {
|
||||
return false
|
||||
}
|
||||
if j.Status == api.JobResponseStatusPending {
|
||||
return false
|
||||
}
|
||||
final = j
|
||||
return true
|
||||
}, 120*time.Second, 2*time.Second, "job must reach a terminal state")
|
||||
return final
|
||||
}
|
||||
@@ -676,6 +676,7 @@ func extractPeerMeta(ctx context.Context, meta *proto.PeerSystemMeta) nbpeer.Pee
|
||||
RosenpassEnabled: meta.GetFlags().GetRosenpassEnabled(),
|
||||
RosenpassPermissive: meta.GetFlags().GetRosenpassPermissive(),
|
||||
ServerSSHAllowed: meta.GetFlags().GetServerSSHAllowed(),
|
||||
RemoteJobsAllowed: meta.GetFlags().GetRemoteJobsAllowed(),
|
||||
DisableClientRoutes: meta.GetFlags().GetDisableClientRoutes(),
|
||||
DisableServerRoutes: meta.GetFlags().GetDisableServerRoutes(),
|
||||
DisableDNS: meta.GetFlags().GetDisableDNS(),
|
||||
|
||||
@@ -617,6 +617,7 @@ func toSinglePeerResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dnsD
|
||||
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
|
||||
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
|
||||
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
|
||||
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -672,6 +673,7 @@ func toPeerListItemResponse(peer *nbpeer.Peer, groupsInfo []api.GroupMinimum, dn
|
||||
RosenpassEnabled: &peer.Meta.Flags.RosenpassEnabled,
|
||||
RosenpassPermissive: &peer.Meta.Flags.RosenpassPermissive,
|
||||
ServerSshAllowed: &peer.Meta.Flags.ServerSSHAllowed,
|
||||
RemoteJobsAllowed: &peer.Meta.Flags.RemoteJobsAllowed,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +59,21 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
// Bound the background loops these managers start (account request buffer,
|
||||
// telemetry P95 flushers, PAT usage tracker, API rate limiter, proxy service
|
||||
// cleanup, cache janitors, DB connection pools) to the test's lifetime. On
|
||||
// context.Background() they never stop and accumulate across the package,
|
||||
// exhausting DB connections until the suite hits the 20m test timeout.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create metrics: %v", err)
|
||||
}
|
||||
|
||||
peersUpdateManager := update_channel.NewPeersUpdateManager(nil)
|
||||
updMsg := peersUpdateManager.CreateChannel(context.Background(), testing_tools.TestPeerId)
|
||||
updMsg := peersUpdateManager.CreateChannel(ctx, testing_tools.TestPeerId)
|
||||
done := make(chan struct{})
|
||||
if validateUpdate {
|
||||
go func() {
|
||||
@@ -88,8 +96,6 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create cache store: %v", err)
|
||||
@@ -111,6 +117,10 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
t.Fatalf("Failed to create proxy manager: %v", err)
|
||||
}
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
|
||||
// NewProxyServiceServer starts cleanupStaleProxies on a context it derives
|
||||
// from context.Background(), independent of the cancellable ctx above;
|
||||
// Close() cancels it so the goroutine does not outlive the test.
|
||||
t.Cleanup(proxyServiceServer.Close)
|
||||
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
|
||||
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
|
||||
if err != nil {
|
||||
@@ -137,7 +147,7 @@ func BuildApiBlackBoxWithDBState(t testing_tools.TB, sqlFile string, expectedPee
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
@@ -200,13 +210,21 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
}
|
||||
t.Cleanup(cleanup)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
// Bound the background loops these managers start (account request buffer,
|
||||
// telemetry P95 flushers, PAT usage tracker, API rate limiter, proxy service
|
||||
// cleanup, cache janitors, DB connection pools) to the test's lifetime. On
|
||||
// context.Background() they never stop and accumulate across the package,
|
||||
// exhausting DB connections until the suite hits the 20m test timeout.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create metrics: %v", err)
|
||||
}
|
||||
|
||||
peersUpdateManager := update_channel.NewPeersUpdateManager(nil)
|
||||
updMsg := peersUpdateManager.CreateChannel(context.Background(), testing_tools.TestPeerId)
|
||||
updMsg := peersUpdateManager.CreateChannel(ctx, testing_tools.TestPeerId)
|
||||
|
||||
geoMock := &geolocation.Mock{}
|
||||
validatorMock := server.MockIntegratedValidator{}
|
||||
@@ -218,8 +236,6 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
|
||||
jobManager := job.NewJobManager(nil, store, peersManager)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cacheStore, err := nbcache.NewStore(ctx, 100*time.Millisecond, 300*time.Millisecond, 100)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create cache store: %v", err)
|
||||
@@ -241,6 +257,10 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
t.Fatalf("Failed to create proxy manager: %v", err)
|
||||
}
|
||||
proxyServiceServer := nbgrpc.NewProxyServiceServer(accessLogsManager, proxyTokenStore, pkceverifierStore, nbgrpc.ProxyOIDCConfig{}, peersManager, userManager, nil, proxyMgr, nil)
|
||||
// NewProxyServiceServer starts cleanupStaleProxies on a context it derives
|
||||
// from context.Background(), independent of the cancellable ctx above;
|
||||
// Close() cancels it so the goroutine does not outlive the test.
|
||||
t.Cleanup(proxyServiceServer.Close)
|
||||
domainManager := manager.NewManager(store, proxyMgr, permissionsManager, am)
|
||||
serviceProxyController, err := proxymanager.NewGRPCController(proxyServiceServer, noopMeter)
|
||||
if err != nil {
|
||||
@@ -267,7 +287,7 @@ func BuildApiBlackBoxWithDBStateAndPeerChannel(t testing_tools.TB, sqlFile strin
|
||||
zoneRecordsManager := recordsManager.NewManager(store, am, permissionsManager)
|
||||
|
||||
apiRouter := mux.NewRouter().PathPrefix("/api").Subrouter()
|
||||
apiHandler, err := http2.NewAPIHandler(context.Background(), apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
apiHandler, err := http2.NewAPIHandler(ctx, apiRouter, am, networksManager, resourcesManager, routersManager, groupsManager, geoMock, authManagerMock, metrics, permissionsManager, settingsManager, customZonesManager, zoneRecordsManager, networkMapController, nil, serviceManager, nil, nil, nil, nil, nil, nil, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create API handler: %v", err)
|
||||
}
|
||||
|
||||
@@ -142,6 +142,7 @@ type Flags struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed bool
|
||||
RemoteJobsAllowed bool
|
||||
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -573,6 +574,7 @@ func (f Flags) isEqual(other Flags) bool {
|
||||
return f.RosenpassEnabled == other.RosenpassEnabled &&
|
||||
f.RosenpassPermissive == other.RosenpassPermissive &&
|
||||
f.ServerSSHAllowed == other.ServerSSHAllowed &&
|
||||
f.RemoteJobsAllowed == other.RemoteJobsAllowed &&
|
||||
f.DisableClientRoutes == other.DisableClientRoutes &&
|
||||
f.DisableServerRoutes == other.DisableServerRoutes &&
|
||||
f.DisableDNS == other.DisableDNS &&
|
||||
|
||||
@@ -573,7 +573,7 @@ func TestSqlStore_SavePeer(t *testing.T) {
|
||||
|
||||
numOfFields, err := populateFields.PopulateAll(reflectedMetadata)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 32, numOfFields)
|
||||
assert.Equal(t, 33, numOfFields)
|
||||
|
||||
// save status of non-existing peer
|
||||
peer := &nbpeer.Peer{
|
||||
|
||||
@@ -1039,6 +1039,7 @@ func infoToMetaData(info *system.Info) *proto.PeerSystemMeta {
|
||||
RosenpassEnabled: info.RosenpassEnabled,
|
||||
RosenpassPermissive: info.RosenpassPermissive,
|
||||
ServerSSHAllowed: info.ServerSSHAllowed,
|
||||
RemoteJobsAllowed: info.RemoteJobsAllowed,
|
||||
|
||||
DisableClientRoutes: info.DisableClientRoutes,
|
||||
DisableServerRoutes: info.DisableServerRoutes,
|
||||
|
||||
@@ -984,6 +984,10 @@ components:
|
||||
description: Indicates whether SSH access this peer is allowed or not
|
||||
type: boolean
|
||||
example: true
|
||||
remote_jobs_allowed:
|
||||
description: Indicates whether the peer has opted into management-requested remote jobs (e.g. debug bundles)
|
||||
type: boolean
|
||||
example: true
|
||||
disable_client_routes:
|
||||
description: Indicates whether client routes are disabled on this peer or not
|
||||
type: boolean
|
||||
|
||||
@@ -4363,6 +4363,9 @@ type PeerLocalFlags struct {
|
||||
// LazyConnectionEnabled Indicates whether lazy connection is enabled on this peer
|
||||
LazyConnectionEnabled *bool `json:"lazy_connection_enabled,omitempty"`
|
||||
|
||||
// RemoteJobsAllowed Indicates whether the peer has opted into management-requested remote jobs (e.g. debug bundles)
|
||||
RemoteJobsAllowed *bool `json:"remote_jobs_allowed,omitempty"`
|
||||
|
||||
// RosenpassEnabled Indicates whether Rosenpass is enabled on this peer
|
||||
RosenpassEnabled *bool `json:"rosenpass_enabled,omitempty"`
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -234,6 +234,11 @@ message Flags {
|
||||
bool disableSSHAuth = 15;
|
||||
|
||||
bool disableIPv6 = 16;
|
||||
|
||||
// remoteJobsAllowed mirrors the peer's local opt-in for management-requested
|
||||
// remote jobs (e.g. debug bundles). Reported so the dashboard can surface
|
||||
// peers that have opted out.
|
||||
bool remoteJobsAllowed = 17;
|
||||
}
|
||||
|
||||
// PeerCapability represents a feature the client binary supports.
|
||||
|
||||
Reference in New Issue
Block a user