mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-01 20:41:28 +02:00
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.
336 lines
13 KiB
Go
336 lines
13 KiB
Go
package server
|
|
|
|
import (
|
|
"os/user"
|
|
"path/filepath"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/protobuf/types/known/durationpb"
|
|
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
)
|
|
|
|
// TestSetConfig_AllFieldsSaved ensures that all fields in SetConfigRequest are properly saved to the config.
|
|
// This test uses reflection to detect when new fields are added but not handled in SetConfig.
|
|
func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
|
tempDir := t.TempDir()
|
|
origDefaultProfileDir := profilemanager.DefaultConfigPathDir
|
|
origDefaultConfigPath := profilemanager.DefaultConfigPath
|
|
origActiveProfileStatePath := profilemanager.ActiveProfileStatePath
|
|
profilemanager.ConfigDirOverride = tempDir
|
|
profilemanager.DefaultConfigPathDir = tempDir
|
|
profilemanager.ActiveProfileStatePath = tempDir + "/active_profile.json"
|
|
profilemanager.DefaultConfigPath = filepath.Join(tempDir, "default.json")
|
|
t.Cleanup(func() {
|
|
profilemanager.DefaultConfigPathDir = origDefaultProfileDir
|
|
profilemanager.ActiveProfileStatePath = origActiveProfileStatePath
|
|
profilemanager.DefaultConfigPath = origDefaultConfigPath
|
|
profilemanager.ConfigDirOverride = ""
|
|
})
|
|
|
|
currUser, err := user.Current()
|
|
require.NoError(t, err)
|
|
|
|
profName := "test-profile"
|
|
|
|
ic := profilemanager.ConfigInput{
|
|
ConfigPath: filepath.Join(tempDir, profName+".json"),
|
|
ManagementURL: "https://api.netbird.io:443",
|
|
}
|
|
_, err = profilemanager.UpdateOrCreateConfig(ic)
|
|
require.NoError(t, err)
|
|
|
|
pm := profilemanager.ServiceManager{}
|
|
err = pm.SetActiveProfileState(&profilemanager.ActiveProfileState{
|
|
ID: profilemanager.ID(profName),
|
|
Username: currUser.Username,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// The privileged-change gate reads the caller's kernel identity from the
|
|
// context, which a real caller gets from the daemon's transport credentials.
|
|
// This test drives the handler directly, so it stands in for a root caller;
|
|
// without an identity the gate would (correctly) refuse the SSH fields.
|
|
ctx := privilegedTestCtx()
|
|
s := New(ctx, "console", "", false, false, false, false)
|
|
|
|
rosenpassEnabled := true
|
|
rosenpassPermissive := true
|
|
serverSSHAllowed := true
|
|
remoteJobsAllowed := true
|
|
interfaceName := "utun100"
|
|
wireguardPort := int64(51820)
|
|
preSharedKey := "test-psk"
|
|
disableAutoConnect := true
|
|
networkMonitor := true
|
|
disableClientRoutes := true
|
|
disableServerRoutes := true
|
|
disableDNS := true
|
|
disableFirewall := true
|
|
blockLANAccess := true
|
|
disableNotifications := true
|
|
blockInbound := true
|
|
disableIPv6 := true
|
|
mtu := int64(1280)
|
|
sshJWTCacheTTL := int32(300)
|
|
enableLocalMetrics := true
|
|
localMetricsAddress := "127.0.0.1:9292"
|
|
|
|
req := &proto.SetConfigRequest{
|
|
ProfileName: profName,
|
|
Username: currUser.Username,
|
|
ManagementUrl: "https://new-api.netbird.io:443",
|
|
AdminURL: "https://new-admin.netbird.io",
|
|
RosenpassEnabled: &rosenpassEnabled,
|
|
RosenpassPermissive: &rosenpassPermissive,
|
|
ServerSSHAllowed: &serverSSHAllowed,
|
|
RemoteJobsAllowed: &remoteJobsAllowed,
|
|
InterfaceName: &interfaceName,
|
|
WireguardPort: &wireguardPort,
|
|
OptionalPreSharedKey: &preSharedKey,
|
|
DisableAutoConnect: &disableAutoConnect,
|
|
NetworkMonitor: &networkMonitor,
|
|
DisableClientRoutes: &disableClientRoutes,
|
|
DisableServerRoutes: &disableServerRoutes,
|
|
DisableDns: &disableDNS,
|
|
DisableFirewall: &disableFirewall,
|
|
BlockLanAccess: &blockLANAccess,
|
|
DisableNotifications: &disableNotifications,
|
|
BlockInbound: &blockInbound,
|
|
DisableIpv6: &disableIPv6,
|
|
NatExternalIPs: []string{"1.2.3.4", "5.6.7.8"},
|
|
CleanNATExternalIPs: false,
|
|
CustomDNSAddress: []byte("1.1.1.1:53"),
|
|
ExtraIFaceBlacklist: []string{"eth1", "eth2"},
|
|
DnsLabels: []string{"label1", "label2"},
|
|
CleanDNSLabels: false,
|
|
DnsRouteInterval: durationpb.New(2 * time.Minute),
|
|
Mtu: &mtu,
|
|
SshJWTCacheTTL: &sshJWTCacheTTL,
|
|
EnableLocalMetrics: &enableLocalMetrics,
|
|
LocalMetricsAddress: &localMetricsAddress,
|
|
}
|
|
|
|
_, err = s.SetConfig(ctx, req)
|
|
require.NoError(t, err)
|
|
|
|
profState := profilemanager.ActiveProfileState{
|
|
ID: profilemanager.ID(profName),
|
|
Username: currUser.Username,
|
|
}
|
|
cfgPath, err := profState.FilePath()
|
|
require.NoError(t, err)
|
|
|
|
cfg, err := profilemanager.GetConfig(cfgPath)
|
|
require.NoError(t, err)
|
|
|
|
require.Equal(t, "https://new-api.netbird.io:443", cfg.ManagementURL.String())
|
|
require.Equal(t, "https://new-admin.netbird.io:443", cfg.AdminURL.String())
|
|
require.Equal(t, rosenpassEnabled, cfg.RosenpassEnabled)
|
|
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)
|
|
require.Equal(t, disableAutoConnect, cfg.DisableAutoConnect)
|
|
require.NotNil(t, cfg.NetworkMonitor)
|
|
require.Equal(t, networkMonitor, *cfg.NetworkMonitor)
|
|
require.Equal(t, disableClientRoutes, cfg.DisableClientRoutes)
|
|
require.Equal(t, disableServerRoutes, cfg.DisableServerRoutes)
|
|
require.Equal(t, disableDNS, cfg.DisableDNS)
|
|
require.Equal(t, disableFirewall, cfg.DisableFirewall)
|
|
require.Equal(t, blockLANAccess, cfg.BlockLANAccess)
|
|
require.NotNil(t, cfg.DisableNotifications)
|
|
require.Equal(t, disableNotifications, *cfg.DisableNotifications)
|
|
require.Equal(t, blockInbound, cfg.BlockInbound)
|
|
require.Equal(t, disableIPv6, cfg.DisableIPv6)
|
|
require.Equal(t, []string{"1.2.3.4", "5.6.7.8"}, cfg.NATExternalIPs)
|
|
require.Equal(t, "1.1.1.1:53", cfg.CustomDNSAddress)
|
|
// IFaceBlackList contains defaults + extras
|
|
require.Contains(t, cfg.IFaceBlackList, "eth1")
|
|
require.Contains(t, cfg.IFaceBlackList, "eth2")
|
|
require.Equal(t, []string{"label1", "label2"}, cfg.DNSLabels.ToPunycodeList())
|
|
require.Equal(t, 2*time.Minute, cfg.DNSRouteInterval)
|
|
require.Equal(t, uint16(mtu), cfg.MTU)
|
|
require.NotNil(t, cfg.SSHJWTCacheTTL)
|
|
require.Equal(t, int(sshJWTCacheTTL), *cfg.SSHJWTCacheTTL)
|
|
require.Equal(t, enableLocalMetrics, cfg.LocalMetricsEnabled)
|
|
require.Equal(t, localMetricsAddress, cfg.LocalMetricsAddress)
|
|
|
|
verifyAllFieldsCovered(t, req)
|
|
}
|
|
|
|
// verifyAllFieldsCovered uses reflection to ensure we're testing all fields in SetConfigRequest.
|
|
// If a new field is added to SetConfigRequest, this function will fail the test,
|
|
// forcing the developer to update both the SetConfig handler and this test.
|
|
func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
|
|
t.Helper()
|
|
|
|
metadataFields := map[string]bool{
|
|
"state": true, // protobuf internal
|
|
"sizeCache": true, // protobuf internal
|
|
"unknownFields": true, // protobuf internal
|
|
"Username": true, // metadata
|
|
"ProfileName": true, // metadata
|
|
"CleanNATExternalIPs": true, // control flag for clearing
|
|
"CleanDNSLabels": true, // control flag for clearing
|
|
"LazyConnectionEnabled": true, // deprecated: proto field retained for compat, no longer applied
|
|
}
|
|
|
|
expectedFields := map[string]bool{
|
|
"ManagementUrl": true,
|
|
"AdminURL": true,
|
|
"RosenpassEnabled": true,
|
|
"RosenpassPermissive": true,
|
|
"ServerSSHAllowed": true,
|
|
"RemoteJobsAllowed": true,
|
|
"InterfaceName": true,
|
|
"WireguardPort": true,
|
|
"OptionalPreSharedKey": true,
|
|
"DisableAutoConnect": true,
|
|
"NetworkMonitor": true,
|
|
"DisableClientRoutes": true,
|
|
"DisableServerRoutes": true,
|
|
"DisableDns": true,
|
|
"DisableFirewall": true,
|
|
"BlockLanAccess": true,
|
|
"DisableNotifications": true,
|
|
"BlockInbound": true,
|
|
"DisableIpv6": true,
|
|
"NatExternalIPs": true,
|
|
"CustomDNSAddress": true,
|
|
"ExtraIFaceBlacklist": true,
|
|
"DnsLabels": true,
|
|
"DnsRouteInterval": true,
|
|
"Mtu": true,
|
|
"EnableSSHRoot": true,
|
|
"EnableSSHSFTP": true,
|
|
"EnableSSHLocalPortForwarding": true,
|
|
"EnableSSHRemotePortForwarding": true,
|
|
"DisableSSHAuth": true,
|
|
"SshJWTCacheTTL": true,
|
|
"EnableLocalMetrics": true,
|
|
"LocalMetricsAddress": true,
|
|
}
|
|
|
|
val := reflect.ValueOf(req).Elem()
|
|
typ := val.Type()
|
|
|
|
var unexpectedFields []string
|
|
for i := 0; i < val.NumField(); i++ {
|
|
field := typ.Field(i)
|
|
fieldName := field.Name
|
|
|
|
if metadataFields[fieldName] {
|
|
continue
|
|
}
|
|
|
|
if !expectedFields[fieldName] {
|
|
unexpectedFields = append(unexpectedFields, fieldName)
|
|
}
|
|
}
|
|
|
|
if len(unexpectedFields) > 0 {
|
|
t.Fatalf("New field(s) detected in SetConfigRequest: %v", unexpectedFields)
|
|
}
|
|
}
|
|
|
|
// TestCLIFlags_MappedToSetConfig ensures all CLI flags that modify config are properly mapped to SetConfigRequest.
|
|
// This test catches bugs where a new CLI flag is added but not wired to the SetConfigRequest in setupSetConfigReq.
|
|
func TestCLIFlags_MappedToSetConfig(t *testing.T) {
|
|
// Map of CLI flag names to their corresponding SetConfigRequest field names.
|
|
// This map must be updated when adding new config-related CLI flags.
|
|
flagToField := map[string]string{
|
|
"management-url": "ManagementUrl",
|
|
"admin-url": "AdminURL",
|
|
"enable-rosenpass": "RosenpassEnabled",
|
|
"rosenpass-permissive": "RosenpassPermissive",
|
|
"allow-server-ssh": "ServerSSHAllowed",
|
|
"allow-remote-jobs": "RemoteJobsAllowed",
|
|
"interface-name": "InterfaceName",
|
|
"wireguard-port": "WireguardPort",
|
|
"preshared-key": "OptionalPreSharedKey",
|
|
"disable-auto-connect": "DisableAutoConnect",
|
|
"network-monitor": "NetworkMonitor",
|
|
"disable-client-routes": "DisableClientRoutes",
|
|
"disable-server-routes": "DisableServerRoutes",
|
|
"disable-dns": "DisableDns",
|
|
"disable-firewall": "DisableFirewall",
|
|
"block-lan-access": "BlockLanAccess",
|
|
"block-inbound": "BlockInbound",
|
|
"disable-ipv6": "DisableIpv6",
|
|
"external-ip-map": "NatExternalIPs",
|
|
"dns-resolver-address": "CustomDNSAddress",
|
|
"extra-iface-blacklist": "ExtraIFaceBlacklist",
|
|
"extra-dns-labels": "DnsLabels",
|
|
"dns-router-interval": "DnsRouteInterval",
|
|
"mtu": "Mtu",
|
|
"enable-ssh-root": "EnableSSHRoot",
|
|
"enable-ssh-sftp": "EnableSSHSFTP",
|
|
"enable-ssh-local-port-forwarding": "EnableSSHLocalPortForwarding",
|
|
"enable-ssh-remote-port-forwarding": "EnableSSHRemotePortForwarding",
|
|
"disable-ssh-auth": "DisableSSHAuth",
|
|
"ssh-jwt-cache-ttl": "SshJWTCacheTTL",
|
|
"enable-local-metrics": "EnableLocalMetrics",
|
|
"local-metrics-address": "LocalMetricsAddress",
|
|
}
|
|
|
|
// SetConfigRequest fields that don't have CLI flags (settable only via UI or other means).
|
|
fieldsWithoutCLIFlags := map[string]bool{
|
|
"DisableNotifications": true, // Only settable via UI
|
|
"LazyConnectionEnabled": true, // deprecated: no longer settable (managed by server + NB_LAZY_CONN)
|
|
}
|
|
|
|
// Get all SetConfigRequest fields to verify our map is complete.
|
|
req := &proto.SetConfigRequest{}
|
|
val := reflect.ValueOf(req).Elem()
|
|
typ := val.Type()
|
|
|
|
var unmappedFields []string
|
|
for i := 0; i < val.NumField(); i++ {
|
|
field := typ.Field(i)
|
|
fieldName := field.Name
|
|
|
|
// Skip protobuf internal fields and metadata fields.
|
|
if fieldName == "state" || fieldName == "sizeCache" || fieldName == "unknownFields" {
|
|
continue
|
|
}
|
|
if fieldName == "Username" || fieldName == "ProfileName" {
|
|
continue
|
|
}
|
|
if fieldName == "CleanNATExternalIPs" || fieldName == "CleanDNSLabels" {
|
|
continue
|
|
}
|
|
|
|
// Check if this field is either mapped to a CLI flag or explicitly documented as having no CLI flag.
|
|
mappedToCLI := false
|
|
for _, mappedField := range flagToField {
|
|
if mappedField == fieldName {
|
|
mappedToCLI = true
|
|
break
|
|
}
|
|
}
|
|
|
|
hasNoCLIFlag := fieldsWithoutCLIFlags[fieldName]
|
|
|
|
if !mappedToCLI && !hasNoCLIFlag {
|
|
unmappedFields = append(unmappedFields, fieldName)
|
|
}
|
|
}
|
|
|
|
if len(unmappedFields) > 0 {
|
|
t.Fatalf("SetConfigRequest field(s) not documented: %v\n"+
|
|
"Either add the CLI flag to flagToField map, or if there's no CLI flag for this field, "+
|
|
"add it to fieldsWithoutCLIFlags map with a comment explaining why.", unmappedFields)
|
|
}
|
|
|
|
t.Log("All SetConfigRequest fields are properly documented")
|
|
}
|