diff --git a/NetBird.xcodeproj/project.pbxproj b/NetBird.xcodeproj/project.pbxproj index e68b73a..3551d09 100644 --- a/NetBird.xcodeproj/project.pbxproj +++ b/NetBird.xcodeproj/project.pbxproj @@ -155,6 +155,7 @@ 71DD928A21BE4943BB0FEC1D /* iOSNetworksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7A1CFF65CC44187912007EC /* iOSNetworksView.swift */; }; 94F739DA3E076313908BA6DF /* GlobalConstantsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3E054D83063E440DAD0C52FA /* GlobalConstantsTests.swift */; }; AA0011012F22001100000001 /* MDMBridgeIntegrationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0011002F22001100000001 /* MDMBridgeIntegrationTests.swift */; }; + AA0012012F22001200000001 /* RemoteJobsPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0012002F22001200000001 /* RemoteJobsPolicyTests.swift */; }; AA0010012F22001000000001 /* MDMRestrictionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0010002F22001000000001 /* MDMRestrictionsTests.swift */; }; 962925F1DAA24D40B98D395B /* iOSPeersView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42873052F9544A89B1408339 /* iOSPeersView.swift */; }; 978FC4702EEDF167002D0EB8 /* AppLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 978FC46F2EEDF167002D0EB8 /* AppLogger.swift */; }; @@ -305,6 +306,7 @@ 2F8A4B98A775451786C5DDF8 /* iOSSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSSettingsView.swift; sourceTree = ""; }; 3E054D83063E440DAD0C52FA /* GlobalConstantsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlobalConstantsTests.swift; sourceTree = ""; }; AA0011002F22001100000001 /* MDMBridgeIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDMBridgeIntegrationTests.swift; sourceTree = ""; }; + AA0012002F22001200000001 /* RemoteJobsPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteJobsPolicyTests.swift; sourceTree = ""; }; AA0010002F22001000000001 /* MDMRestrictionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDMRestrictionsTests.swift; sourceTree = ""; }; 42873052F9544A89B1408339 /* iOSPeersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSPeersView.swift; sourceTree = ""; }; 441C5AEE2EDF0DAE0055EEFC /* NetBird TV.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "NetBird TV.app"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -750,6 +752,7 @@ children = ( 3E054D83063E440DAD0C52FA /* GlobalConstantsTests.swift */, AA0011002F22001100000001 /* MDMBridgeIntegrationTests.swift */, + AA0012002F22001200000001 /* RemoteJobsPolicyTests.swift */, AA0010002F22001000000001 /* MDMRestrictionsTests.swift */, 8AA7193B3AE82DF185EDEB1B /* AppLoggerTests.swift */, 53CB9305A9DC6CAD1895495A /* SharedUserDefaultsTests.swift */, @@ -1288,6 +1291,7 @@ files = ( 94F739DA3E076313908BA6DF /* GlobalConstantsTests.swift in Sources */, AA0011012F22001100000001 /* MDMBridgeIntegrationTests.swift in Sources */, + AA0012012F22001200000001 /* RemoteJobsPolicyTests.swift in Sources */, AA0010012F22001000000001 /* MDMRestrictionsTests.swift in Sources */, 9CC0E000AE3F165CA72FD465 /* AppLoggerTests.swift in Sources */, 1C9E4E97130030CE0D6C8F59 /* SharedUserDefaultsTests.swift in Sources */, diff --git a/NetBird/Source/App/ViewModels/MainViewModel.swift b/NetBird/Source/App/ViewModels/MainViewModel.swift index 4f7037e..0c78605 100644 --- a/NetBird/Source/App/ViewModels/MainViewModel.swift +++ b/NetBird/Source/App/ViewModels/MainViewModel.swift @@ -134,6 +134,7 @@ class ViewModel: ObservableObject { @Published var forceRelayConnection = true @Published var showForceRelayAlert = false @Published var disableIPv6 = false + @Published var remoteJobsAllowed = false @Published var connectOnDemand = false @Published var showOnDemandAlert = false @Published var showOnDemandConflictAlert = false @@ -1156,6 +1157,39 @@ class ViewModel: ObservableObject { self.disableIPv6 = configProvider.disableIPv6 } + /// Whether the policy owns the remote-jobs toggle, by managing it + /// directly or by forbidding settings edits at all. Mirrors the lock the + /// iOS and tvOS toggles apply, so the backstop cannot disagree with the + /// control the user sees. + private var remoteJobsForbiddenByPolicy: Bool { + mdmRestrictions.mdm.remoteJobsAllowed || mdmRestrictions.features.disableUpdateSettings + } + + func setRemoteJobsAllowed(allowed: Bool) { + // The snapshot can be up to a poll behind a policy pushed from + // another process, and on tvOS commit() always reports success, so + // the enforced value has to be re-read before it is trusted. + refreshMDMRestrictions() + guard !remoteJobsForbiddenByPolicy else { + AppLogger.shared.log("MDM: refusing to change remote debug bundles while the setting is managed") + self.remoteJobsAllowed = configProvider.remoteJobsAllowed + settingsRejectedMessage = "This setting is managed by your organization and cannot be changed." + showSettingsRejectedAlert = true + return + } + let previous = self.remoteJobsAllowed + self.remoteJobsAllowed = allowed + configProvider.remoteJobsAllowed = allowed + if !commitSettings() { + self.remoteJobsAllowed = previous + configProvider.remoteJobsAllowed = previous + } + } + + func loadRemoteJobsSettings() { + self.remoteJobsAllowed = configProvider.remoteJobsAllowed + } + func setForcedRelayConnection(isEnabled: Bool) { let userDefaults = UserDefaults(suiteName: GlobalConstants.userPreferencesSuiteName) userDefaults?.set(isEnabled, forKey: GlobalConstants.keyForceRelayConnection) diff --git a/NetBird/Source/App/Views/TV/TVSettingsView.swift b/NetBird/Source/App/Views/TV/TVSettingsView.swift index c89cd3b..f3d59fd 100644 --- a/NetBird/Source/App/Views/TV/TVSettingsView.swift +++ b/NetBird/Source/App/Views/TV/TVSettingsView.swift @@ -34,6 +34,10 @@ struct TVSettingsView: View { viewModel.mdmRestrictions.mdm.rosenpassPermissive || editingDisabled } + private var remoteJobsLocked: Bool { + viewModel.mdmRestrictions.mdm.remoteJobsAllowed || editingDisabled + } + /// tvOS rows carry their explanation in the subtitle - there is no footer /// to put it in, and a row that is merely dimmed gives the user no reason. private func subtitle(_ text: String, managed: Bool) -> String { @@ -163,6 +167,22 @@ struct TVSettingsView: View { ) } + TVSettingsSection(title: "Troubleshooting") { + TVSettingsToggleRow( + icon: "doc.zipper", + title: "Remote Debug Bundles", + subtitle: subtitle("Let your administrator request a debug bundle from this device", + managed: remoteJobsLocked), + isOn: Binding( + get: { viewModel.remoteJobsAllowed }, + set: { newValue in + viewModel.setRemoteJobsAllowed(allowed: newValue) + } + ), + isDisabled: remoteJobsLocked + ) + } + TVSettingsSection(title: "Info") { TVSettingsRow( icon: "qrcode.viewfinder", @@ -202,6 +222,7 @@ struct TVSettingsView: View { viewModel.loadRosenpassSettings() viewModel.loadPreSharedKey() viewModel.loadIPv6Settings() + viewModel.loadRemoteJobsSettings() } .sheet(isPresented: $showDocsQRCode) { TVQRCodeSheet( diff --git a/NetBird/Source/App/Views/iOS/TroubleshootView.swift b/NetBird/Source/App/Views/iOS/TroubleshootView.swift index 7568975..d459341 100644 --- a/NetBird/Source/App/Views/iOS/TroubleshootView.swift +++ b/NetBird/Source/App/Views/iOS/TroubleshootView.swift @@ -13,6 +13,11 @@ struct TroubleshootView: View { @State private var uploadKey = "" @State private var showCopiedAlert = false + private var remoteJobsLocked: Bool { + viewModel.mdmRestrictions.mdm.remoteJobsAllowed + || viewModel.mdmRestrictions.features.disableUpdateSettings + } + var body: some View { Form { Section(header: Text("Logging")) { @@ -20,15 +25,36 @@ struct TroubleshootView: View { .toggleStyle(SwitchToggleStyle(tint: .accentColor)) } - Section(header: Text("Debug Bundle"), footer: Text("Sensitive data includes IP addresses, domain names, and private keys.")) { + Section { Toggle("Anonymize sensitive data", isOn: $viewModel.anonymizeDebugBundle) .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + Toggle("Allow remote debug bundles", isOn: Binding( + get: { viewModel.remoteJobsAllowed }, + set: { newValue in + viewModel.setRemoteJobsAllowed(allowed: newValue) + } + )) + .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .mdmLocked(remoteJobsLocked) + bundleActionContent + } header: { + Text("Debug Bundle") + } footer: { + if remoteJobsLocked { + MDMManagedFooter() + } else { + Text("Sensitive data includes IP addresses, domain names, and private keys. Allowing remote debug bundles lets your administrator request one from this device through the management server; this takes effect on the next connection.") + } } } .navigationTitle("Troubleshoot") .navigationBarTitleDisplayMode(.inline) + .onAppear { + viewModel.refreshMDMRestrictions() + viewModel.loadRemoteJobsSettings() + } .alert(isPresented: $viewModel.showLogLevelChangedAlert) { Alert( title: Text("Changing Log Level"), diff --git a/NetBirdTests/MDMRestrictionsTests.swift b/NetBirdTests/MDMRestrictionsTests.swift index 4edb70f..941a22a 100644 --- a/NetBirdTests/MDMRestrictionsTests.swift +++ b/NetBirdTests/MDMRestrictionsTests.swift @@ -30,6 +30,7 @@ final class MDMRestrictionsTests: XCTestCase { "disableMetricsCollection": false, "splitTunnelMode": false, "splitTunnelApps": false, + "allowRemoteJobs": true, "disableAdvancedView": false }, "features": { @@ -50,11 +51,19 @@ final class MDMRestrictionsTests: XCTestCase { XCTAssertTrue(r.mdm.disableAutoConnect) XCTAssertEqual(r.mdm.disableAdvancedView, false) XCTAssertFalse(r.mdm.hidesAdvancedView) + XCTAssertTrue(r.mdm.remoteJobsAllowed) XCTAssertTrue(r.features.disableProfiles) XCTAssertFalse(r.features.disableNetworks) XCTAssertFalse(r.features.disableUpdateSettings) } + func testRemoteJobsAllowedDefaultsToUnmanaged() { + XCTAssertFalse(MDMRestrictions.decode(#"{"mdm":{}}"#).mdm.remoteJobsAllowed) + XCTAssertFalse(MDMRestrictions.empty.mdm.remoteJobsAllowed) + XCTAssertTrue(MDMRestrictions.decode(#"{"mdm":{"allowRemoteJobs":true}}"#).mdm.remoteJobsAllowed) + XCTAssertFalse(MDMRestrictions.decode(#"{"mdm":{"allowRemoteJobs":false}}"#).mdm.remoteJobsAllowed) + } + /// `allowServerSSH` is tri-state: absent and explicit null both mean /// "not managed", and must not collapse into `false`. func testAllowServerSSHTriState() { diff --git a/NetBirdTests/RemoteJobsPolicyTests.swift b/NetBirdTests/RemoteJobsPolicyTests.swift new file mode 100644 index 0000000..d4d531f --- /dev/null +++ b/NetBirdTests/RemoteJobsPolicyTests.swift @@ -0,0 +1,120 @@ +// +// RemoteJobsPolicyTests.swift +// NetBirdTests +// +// Covers the backstop on the remote debug bundle toggle: a policy pushed +// from another process must be enforced by the very next write, not from +// the next poll onwards. Runs on iOS and tvOS - on tvOS commit() always +// reports success, so the pre-commit guard is the only thing standing +// between a managed device and an enabled remote job. +// + +import XCTest +@testable import NetBird + +final class RemoteJobsPolicyTests: XCTestCase { + + private let key = MDMPolicyFetcher.managedConfigKey + private var saved: [String: Any]? + + private var policyForcedByLaunchArgument: Bool { + UserDefaults.standard + .volatileDomain(forName: UserDefaults.argumentDomain)[key] != nil + } + + override func setUpWithError() throws { + try super.setUpWithError() + try XCTSkipIf( + policyForcedByLaunchArgument, + "A policy is pinned by the scheme's launch arguments " + + "(-\(MDMPolicyFetcher.managedConfigKey)). Uncheck it in " + + "Edit Scheme > Run > Arguments to run these tests." + ) + saved = UserDefaults.standard.dictionary(forKey: key) + UserDefaults.standard.removeObject(forKey: key) + } + + override func tearDown() { + if let saved = saved { + UserDefaults.standard.set(saved, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + super.tearDown() + } + + private func push(_ policy: [String: Any]) { + UserDefaults.standard.set(policy, forKey: key) + UserDefaults.standard.synchronize() + } + + /// A ViewModel holding the unmanaged snapshot the app starts with, which + /// is what a policy arriving mid-session has to override. + @MainActor + private func makeViewModelWithStaleSnapshot() -> ViewModel { + let viewModel = ViewModel() + viewModel.mdmRestrictions = .empty + XCTAssertFalse(viewModel.mdmRestrictions.mdm.remoteJobsAllowed) + XCTAssertFalse(viewModel.mdmRestrictions.features.disableUpdateSettings) + return viewModel + } + + @MainActor + func testAllowRemoteJobsPolicyIsEnforcedBeforeTheNextPoll() throws { + let viewModel = makeViewModelWithStaleSnapshot() + push(["allowRemoteJobs": false]) + try XCTSkipUnless( + MDMRestrictions.current().mdm.remoteJobsAllowed, + "the Go policy loader did not render allowRemoteJobs as managed" + ) + + viewModel.setRemoteJobsAllowed(allowed: true) + + XCTAssertTrue(viewModel.showSettingsRejectedAlert) + XCTAssertEqual( + viewModel.settingsRejectedMessage, + "This setting is managed by your organization and cannot be changed." + ) + XCTAssertTrue(viewModel.mdmRestrictions.mdm.remoteJobsAllowed) + } + + /// disableUpdateSettings locks the toggle in both UIs, so the setter has + /// to refuse on it too - otherwise the lock is cosmetic. + @MainActor + func testDisableUpdateSettingsPolicyIsEnforcedBeforeTheNextPoll() throws { + let viewModel = makeViewModelWithStaleSnapshot() + push(["disableUpdateSettings": true]) + try XCTSkipUnless( + MDMRestrictions.current().features.disableUpdateSettings, + "the Go policy loader did not render disableUpdateSettings as managed" + ) + + viewModel.setRemoteJobsAllowed(allowed: true) + + XCTAssertTrue(viewModel.showSettingsRejectedAlert) + XCTAssertEqual( + viewModel.settingsRejectedMessage, + "This setting is managed by your organization and cannot be changed." + ) + XCTAssertTrue(viewModel.mdmRestrictions.features.disableUpdateSettings) + } + + /// The refresh must not turn an unmanaged device read-only. + @MainActor + func testUnmanagedDeviceStillAcceptsTheChange() throws { + let viewModel = makeViewModelWithStaleSnapshot() + try XCTSkipUnless( + MDMRestrictions.current() == .empty, + "the environment reports a policy for an unmanaged device" + ) + + viewModel.loadRemoteJobsSettings() + let original = viewModel.remoteJobsAllowed + defer { viewModel.setRemoteJobsAllowed(allowed: original) } + + viewModel.setRemoteJobsAllowed(allowed: true) + + XCTAssertFalse(viewModel.showSettingsRejectedAlert) + XCTAssertTrue(viewModel.remoteJobsAllowed) + } +} diff --git a/NetbirdKit/ConfigurationProvider.swift b/NetbirdKit/ConfigurationProvider.swift index 8c37dad..5ec0adb 100644 --- a/NetbirdKit/ConfigurationProvider.swift +++ b/NetbirdKit/ConfigurationProvider.swift @@ -28,6 +28,11 @@ protocol ConfigurationProvider { /// Whether IPv6 overlay addressing is disabled var disableIPv6: Bool { get set } + // MARK: - Remote Jobs + + /// Whether management may run remote jobs (debug bundle requests) on this peer + var remoteJobsAllowed: Bool { get set } + // MARK: - Pre-Shared Key /// Stages a new pre-shared key; an empty string clears it. Write-only by @@ -118,6 +123,23 @@ final class iOSConfigurationProvider: ConfigurationProvider { } } + // MARK: - Remote Jobs + + var remoteJobsAllowed: Bool { + get { + var result = ObjCBool(false) + do { + try preferences.getRemoteJobsAllowed(&result) + } catch { + print("ConfigurationProvider: Failed to read remoteJobsAllowed - \(error)") + } + return result.boolValue + } + set { + preferences.setRemoteJobsAllowed(newValue) + } + } + // MARK: - Pre-Shared Key func setPreSharedKey(_ key: String) { @@ -235,6 +257,13 @@ final class tvOSConfigurationProvider: ConfigurationProvider { } } + // MARK: - Remote Jobs + + var remoteJobsAllowed: Bool { + get { extractJSONBool(field: "RemoteJobsAllowed") ?? false } + set { updateJSONField(field: "RemoteJobsAllowed", value: newValue) } + } + // MARK: - Pre-Shared Key func setPreSharedKey(_ key: String) { diff --git a/NetbirdKit/MDMRestrictions.swift b/NetbirdKit/MDMRestrictions.swift index d6b166f..a749eb9 100644 --- a/NetbirdKit/MDMRestrictions.swift +++ b/NetbirdKit/MDMRestrictions.swift @@ -34,6 +34,7 @@ struct MDMRestrictions: Equatable { var disableMetricsCollection: Bool = false var splitTunnelMode: Bool = false var splitTunnelApps: Bool = false + var remoteJobsAllowed: Bool = false /// Tri-state, like `allowServerSSH`: nil means the key is not managed, /// and an explicit false means the section is allowed - only true /// hides it. @@ -87,6 +88,7 @@ extension MDMRestrictions.Fields: Decodable { allowServerSSH, disableAutoConnect, disableAutostart, blockInbound, disableMetricsCollection, splitTunnelMode, splitTunnelApps, disableAdvancedView + case remoteJobsAllowed = "allowRemoteJobs" } init(from decoder: Decoder) throws { @@ -109,6 +111,7 @@ extension MDMRestrictions.Fields: Decodable { disableMetricsCollection = try flag(.disableMetricsCollection) splitTunnelMode = try flag(.splitTunnelMode) splitTunnelApps = try flag(.splitTunnelApps) + remoteJobsAllowed = try flag(.remoteJobsAllowed) // Tri-state: absent and JSON null both mean "not managed". disableAdvancedView = try c.decodeIfPresent(Bool.self, forKey: .disableAdvancedView) } diff --git a/netbird-core b/netbird-core index 27991aa..9615d2a 160000 --- a/netbird-core +++ b/netbird-core @@ -1 +1 @@ -Subproject commit 27991aab984e5aa8fc19a307b13ea4ed0f1dc6e4 +Subproject commit 9615d2ab162e7badee5c8b4e84048ce49e1db6e7