Add a Troubleshoot toggle for remote debug bundle jobs (#216)

* Add a Troubleshoot toggle for remote debug bundle jobs

The client refuses management-requested remote jobs unless
RemoteJobsAllowed is enabled, and the iOS and tvOS apps had no way to
turn it on. Expose the flag through ConfigurationProvider, add an
"Allow remote debug bundles" toggle to the iOS Troubleshoot screen and
a Troubleshooting section in the tvOS settings, and bump netbird-core to
the commit that adds the SDK preference accessors.

* Update submodule

* Route the iOS remote jobs toggle through the view model setter

The Toggle wrote the published property before onChange invoked
setRemoteJobsAllowed, so on a failed commit the rollback restored the
new value instead of the previous one. Use a Binding whose setter calls
the view model directly, matching the tvOS settings view.

* Lock the remote debug bundle toggle when MDM manages it (#224)

* Lock the remote debug bundle toggle when MDM manages it

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Decode the remote jobs snapshot key as allowRemoteJobs

The Go snapshot reports this field under the policy key name,
allowRemoteJobs, not remoteJobsAllowed. The synthesized coding key used the
property name, so decodeIfPresent never found the key and the flag stayed
false — the toggle looked editable under an MDM policy that manages it.

Map the coding key explicitly and keep the property name, which the views
already reference. Bumps netbird-core to pick up the matching Go-side fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Bump submodule

* Use allowRemoteJobs key in MDM restriction test fixtures

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

* Enforce the remote jobs policy before the next poll

setRemoteJobsAllowed validated against the cached MDM snapshot, which is
only refreshed on onAppear or a 30s stale poll. The OS writes managed
configuration from another process, so the in-process change notification
never fires for it and a policy pushed mid-session went unenforced until
the next poll. Refresh the snapshot before validating.

The guard also checked only mdm.remoteJobsAllowed, while both toggles lock
on remoteJobsAllowed || disableUpdateSettings, so the backstop could
disagree with the control the user sees. Extract
remoteJobsForbiddenByPolicy to mirror the lock.

Route the rejection through commitSettings() so an MDM-refused commit
raises the standard alert instead of a silent print. This matters most on
tvOS, where commit() always reports success and the pre-commit guard is
the only backstop.

* Restore the persisted remote jobs setting after the test

testUnmanagedDeviceStillAcceptsTheChange writes through
ConfigurationProvider, which persists into the Go preferences store.
tearDown() only restored the MDM dictionary, so an enabled
remoteJobsAllowed could leak into later tests or a reused test-host
launch. Capture the original value and restore it via defer, which runs
while the device is still unmanaged so the setter is not rejected.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Zoltan Papp
2026-09-23 15:14:26 +02:00
committed by GitHub
co-authored by Claude Opus 5
parent 6116ea921c
commit ecf3b2a82f
9 changed files with 248 additions and 2 deletions
+4
View File
@@ -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 = "<group>"; };
3E054D83063E440DAD0C52FA /* GlobalConstantsTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GlobalConstantsTests.swift; sourceTree = "<group>"; };
AA0011002F22001100000001 /* MDMBridgeIntegrationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDMBridgeIntegrationTests.swift; sourceTree = "<group>"; };
AA0012002F22001200000001 /* RemoteJobsPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemoteJobsPolicyTests.swift; sourceTree = "<group>"; };
AA0010002F22001000000001 /* MDMRestrictionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MDMRestrictionsTests.swift; sourceTree = "<group>"; };
42873052F9544A89B1408339 /* iOSPeersView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = iOSPeersView.swift; sourceTree = "<group>"; };
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 */,
@@ -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)
@@ -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(
@@ -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"),
+9
View File
@@ -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() {
+120
View File
@@ -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)
}
}
+29
View File
@@ -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) {
+3
View File
@@ -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)
}