diff --git a/client/internal/profilemanager/config.go b/client/internal/profilemanager/config.go index 953b3564a..8bc619146 100644 --- a/client/internal/profilemanager/config.go +++ b/client/internal/profilemanager/config.go @@ -735,6 +735,8 @@ func (config *Config) applyMDMPolicy(policy *mdm.Policy) { } applyBool(mdm.KeyAllowServerSSH, func(v bool) { bv := v; config.ServerSSHAllowed = &bv }) + applyBool(mdm.KeyAllowServerVNC, func(v bool) { bv := v; config.ServerVNCAllowed = &bv }) + applyBool(mdm.KeyDisableVNCApproval, func(v bool) { bv := v; config.DisableVNCApproval = &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 }) diff --git a/client/internal/profilemanager/config_mdm_test.go b/client/internal/profilemanager/config_mdm_test.go index c6a688ab2..eef495559 100644 --- a/client/internal/profilemanager/config_mdm_test.go +++ b/client/internal/profilemanager/config_mdm_test.go @@ -130,6 +130,36 @@ func TestApply_MDMBoolKeysOverrideOnDiskValue(t *testing.T) { assert.True(t, cfg.Policy().HasKey(mdm.KeyRosenpassEnabled)) } +func TestApply_MDMVNCKeys(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "config.json") + + // Seed without MDM: VNC off, approval prompt on. + withMDMPolicy(t, mdm.NewPolicy(nil)) + _, err := UpdateOrCreateConfig(ConfigInput{ + ConfigPath: tmp, + ServerVNCAllowed: boolPtr(false), + DisableVNCApproval: boolPtr(false), + }) + require.NoError(t, err) + + // MDM enforces VNC on and disables the approval prompt. + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyAllowServerVNC: true, + mdm.KeyDisableVNCApproval: true, + })) + + cfg, err := UpdateOrCreateConfig(ConfigInput{ConfigPath: tmp}) + require.NoError(t, err) + require.NotNil(t, cfg) + + require.NotNil(t, cfg.ServerVNCAllowed) + assert.True(t, *cfg.ServerVNCAllowed, "MDM override should flip on-disk false to true") + require.NotNil(t, cfg.DisableVNCApproval) + assert.True(t, *cfg.DisableVNCApproval) + assert.True(t, cfg.Policy().HasKey(mdm.KeyAllowServerVNC)) + assert.True(t, cfg.Policy().HasKey(mdm.KeyDisableVNCApproval)) +} + func TestApply_MDMLazyConnection(t *testing.T) { cases := []struct { name string diff --git a/client/mdm/canonical_loaders.go b/client/mdm/canonical_loaders.go index cb9af9ccb..6c06d5f94 100644 --- a/client/mdm/canonical_loaders.go +++ b/client/mdm/canonical_loaders.go @@ -21,6 +21,8 @@ var allKeys = []string{ KeyBlockInbound, KeyDisableMetricsCollection, KeyAllowServerSSH, + KeyAllowServerVNC, + KeyDisableVNCApproval, KeyDisableAutoConnect, KeyPreSharedKey, KeyRosenpassEnabled, diff --git a/client/mdm/policy.go b/client/mdm/policy.go index b76c70a75..e9b2faa0a 100644 --- a/client/mdm/policy.go +++ b/client/mdm/policy.go @@ -20,10 +20,10 @@ import ( // names (lowerCamelCase) so the daemon can map a Policy key directly to a // configuration field. const ( - KeyManagementURL = "managementURL" - KeyDisableUpdateSettings = "disableUpdateSettings" - KeyDisableProfiles = "disableProfiles" - KeyDisableNetworks = "disableNetworks" + KeyManagementURL = "managementURL" + KeyDisableUpdateSettings = "disableUpdateSettings" + KeyDisableProfiles = "disableProfiles" + KeyDisableNetworks = "disableNetworks" // KeyDisableAdvancedView gates the advanced-view section in the // upcoming UI revision. UI-only: NOT stored on Config, not // applied by applyMDMPolicy, not rejectable via SetConfig. The @@ -36,6 +36,8 @@ const ( KeyBlockInbound = "blockInbound" KeyDisableMetricsCollection = "disableMetricsCollection" KeyAllowServerSSH = "allowServerSSH" + KeyAllowServerVNC = "allowServerVNC" + KeyDisableVNCApproval = "disableVNCApproval" KeyDisableAutoConnect = "disableAutoConnect" KeyPreSharedKey = "preSharedKey" KeyRosenpassEnabled = "rosenpassEnabled" diff --git a/client/server/mdm.go b/client/server/mdm.go index 9836c6bea..b82aa8712 100644 --- a/client/server/mdm.go +++ b/client/server/mdm.go @@ -297,6 +297,8 @@ 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.KeyAllowServerVNC, msg.ServerVNCAllowed), + conflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval), conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), @@ -332,6 +334,8 @@ func setConfigRequestHasConfigOverrides(msg *proto.SetConfigRequest) bool { msg.Mtu != nil || msg.DisableAutoConnect != nil || msg.ServerSSHAllowed != nil || + msg.ServerVNCAllowed != nil || + msg.DisableVNCApproval != nil || msg.NetworkMonitor != nil || msg.DisableClientRoutes != nil || msg.DisableServerRoutes != nil || @@ -370,6 +374,8 @@ func loginRequestHasConfigOverrides(msg *proto.LoginRequest) bool { msg.WireguardPort != nil || msg.DisableAutoConnect != nil || msg.ServerSSHAllowed != nil || + msg.ServerVNCAllowed != nil || + msg.DisableVNCApproval != nil || msg.RosenpassPermissive != nil || len(msg.ExtraIFaceBlacklist) > 0 || msg.NetworkMonitor != nil || @@ -418,6 +424,8 @@ 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.KeyAllowServerVNC, msg.ServerVNCAllowed), + conflictBool(mdm.KeyDisableVNCApproval, msg.DisableVNCApproval), conflictBool(mdm.KeyDisableClientRoutes, msg.DisableClientRoutes), conflictBool(mdm.KeyDisableServerRoutes, msg.DisableServerRoutes), conflictBool(mdm.KeyBlockInbound, msg.BlockInbound), diff --git a/client/server/setconfig_mdm_test.go b/client/server/setconfig_mdm_test.go index 9baf16136..420ec70b8 100644 --- a/client/server/setconfig_mdm_test.go +++ b/client/server/setconfig_mdm_test.go @@ -105,6 +105,30 @@ func TestSetConfig_MDMReject_SingleField(t *testing.T) { assert.Equal(t, []string{mdm.KeyManagementURL}, v.GetFields()) } +func TestSetConfig_MDMReject_VNCFields(t *testing.T) { + withMDMPolicy(t, mdm.NewPolicy(map[string]any{ + mdm.KeyAllowServerVNC: true, + mdm.KeyDisableVNCApproval: false, + })) + + s, ctx, profName, username, _ := setupServerWithProfile(t) + + vncAllowed := false + disableApproval := true + _, err := s.SetConfig(ctx, &proto.SetConfigRequest{ + ProfileName: profName, + Username: username, + ServerVNCAllowed: &vncAllowed, + DisableVNCApproval: &disableApproval, + }) + + v := extractViolation(t, err) + assert.ElementsMatch(t, []string{ + mdm.KeyAllowServerVNC, + mdm.KeyDisableVNCApproval, + }, v.GetFields()) +} + func TestSetConfig_MDMReject_MultipleFields(t *testing.T) { withMDMPolicy(t, mdm.NewPolicy(map[string]any{ mdm.KeyManagementURL: "https://mdm.example.com:443", diff --git a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx index b28e05fb0..5d639f725 100644 --- a/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsNavigation.tsx @@ -21,6 +21,7 @@ export const SettingsNavigation = () => { const { updateAvailable } = useClientVersion(); const { mdm, features } = useRestrictions(); const showSsh = mdm.allowServerSSH ?? !features.disableUpdateSettings; + const showVnc = mdm.allowServerVNC ?? !features.disableUpdateSettings; const aboutAdornment = updateAvailable ? ( @@ -64,7 +65,7 @@ export const SettingsNavigation = () => { title={t("settings.tabs.ssh")} /> )} - {!features.disableUpdateSettings && ( + {showVnc && ( { [Tab.Security]: editable, [Tab.Profiles]: !features.disableProfiles, [Tab.SSH]: mdm.allowServerSSH ?? editable, - [Tab.VNC]: editable, + [Tab.VNC]: mdm.allowServerVNC ?? editable, [Tab.Advanced]: editable, [Tab.Troubleshooting]: true, [Tab.About]: true, }; return (Object.keys(visibility) as Tab[]).filter((t) => visibility[t]); - }, [features.disableUpdateSettings, features.disableProfiles, mdm.allowServerSSH]); + }, [ + features.disableUpdateSettings, + features.disableProfiles, + mdm.allowServerSSH, + mdm.allowServerVNC, + ]); const defaultTab = visibleTabs[0]; const [active, setActive] = useState(() => navState?.tab ?? defaultTab); diff --git a/client/ui/frontend/src/modules/settings/SettingsVNC.tsx b/client/ui/frontend/src/modules/settings/SettingsVNC.tsx index 1da4a2c26..78a6f3ad4 100644 --- a/client/ui/frontend/src/modules/settings/SettingsVNC.tsx +++ b/client/ui/frontend/src/modules/settings/SettingsVNC.tsx @@ -2,11 +2,14 @@ import { useTranslation } from "react-i18next"; import FancyToggleSwitch from "@/components/switches/FancyToggleSwitch"; import { SectionGroup } from "@/modules/settings/SettingsSection.tsx"; import { useSettings } from "@/contexts/SettingsContext.tsx"; +import { useRestrictions } from "@/contexts/RestrictionsContext.tsx"; export function SettingsVNC() { const { t } = useTranslation(); const { config, setField } = useSettings(); + const { mdm } = useRestrictions(); const isVNCServerEnabled = config.serverVncAllowed; + const vncServerManaged = mdm.allowServerVNC != null; return ( <> @@ -16,17 +19,23 @@ export function SettingsVNC() { onChange={(v) => setField("serverVncAllowed", v)} label={t("settings.vnc.server.label")} helpText={t("settings.vnc.server.help")} + disabled={vncServerManaged} /> - - setField("disableVncApproval", !v)} - label={t("settings.vnc.approval.label")} - helpText={t("settings.vnc.approval.help")} - /> - + {!mdm.disableVNCApproval && ( + + setField("disableVncApproval", !v)} + label={t("settings.vnc.approval.label")} + helpText={t("settings.vnc.approval.help")} + /> + + )} ); } diff --git a/client/ui/services/settings.go b/client/ui/services/settings.go index 9e1be7d4a..7d1be3f28 100644 --- a/client/ui/services/settings.go +++ b/client/ui/services/settings.go @@ -19,6 +19,8 @@ type MDMFields struct { DisableClientRoutes bool `json:"disableClientRoutes"` DisableServerRoutes bool `json:"disableServerRoutes"` AllowServerSSH *bool `json:"allowServerSSH"` + AllowServerVNC *bool `json:"allowServerVNC"` + DisableVNCApproval bool `json:"disableVNCApproval"` DisableAutoConnect bool `json:"disableAutoConnect"` BlockInbound bool `json:"blockInbound"` DisableMetricsCollection bool `json:"disableMetricsCollection"` @@ -261,4 +263,8 @@ func applyMDMRestrictions(mdm *MDMFields, cfgResp *proto.GetConfigResponse) { allowed := cfgResp.GetServerSSHAllowed() mdm.AllowServerSSH = &allowed } + if _, ok := set["allowServerVNC"]; ok { + allowed := cfgResp.GetServerVNCAllowed() + mdm.AllowServerVNC = &allowed + } } diff --git a/client/ui/services/settings_mdm_test.go b/client/ui/services/settings_mdm_test.go new file mode 100644 index 000000000..8821a2279 --- /dev/null +++ b/client/ui/services/settings_mdm_test.go @@ -0,0 +1,44 @@ +//go:build !android && !ios && !freebsd && !js + +package services + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netbirdio/netbird/client/proto" +) + +func TestApplyMDMRestrictions_VNCFields(t *testing.T) { + t.Run("unmanaged leaves fields at zero", func(t *testing.T) { + var mdm MDMFields + applyMDMRestrictions(&mdm, &proto.GetConfigResponse{}) + assert.Nil(t, mdm.AllowServerVNC) + assert.False(t, mdm.DisableVNCApproval) + }) + + t.Run("managed surfaces enforced values", func(t *testing.T) { + var mdm MDMFields + applyMDMRestrictions(&mdm, &proto.GetConfigResponse{ + MDMManagedFields: []string{"allowServerVNC", "disableVNCApproval"}, + ServerVNCAllowed: true, + DisableVNCApproval: true, + }) + require.NotNil(t, mdm.AllowServerVNC) + assert.True(t, *mdm.AllowServerVNC, "AllowServerVNC should carry the enforced value") + assert.True(t, mdm.DisableVNCApproval, "DisableVNCApproval should be flagged managed") + }) + + t.Run("managed VNC disallowed surfaces false", func(t *testing.T) { + var mdm MDMFields + applyMDMRestrictions(&mdm, &proto.GetConfigResponse{ + MDMManagedFields: []string{"allowServerVNC"}, + ServerVNCAllowed: false, + }) + require.NotNil(t, mdm.AllowServerVNC) + assert.False(t, *mdm.AllowServerVNC) + assert.False(t, mdm.DisableVNCApproval, "unmanaged approval stays zero") + }) +} diff --git a/docs/io.netbird.client.plist b/docs/io.netbird.client.plist index 800ecead1..79fb4b2fc 100644 --- a/docs/io.netbird.client.plist +++ b/docs/io.netbird.client.plist @@ -63,6 +63,12 @@