NETransparentProxyManager: saving isOnDemandEnabled = true disables On-Demand on other proxies

On macOS, multiple NETransparentProxyManager (NEAppProxyProvider) configurations from different vendors can be enabled (isEnabled = true) and run simultaneously.

However, we discovered that when one NETransparentProxyManager configuration calls saveToPreferencesWithCompletionHandler: with isOnDemandEnabled = true, /usr/libexec/nehelper (NEConfigurationManager) silently modifies any other enabled NETransparentProxyManager configuration in /Library/Preferences/com.apple.networkextension.plist and flips its OnDemandEnabled to false (and nesessionmanager logs Resetting VPN On Demand and marks the other session not-kernel-activated).

Unlike NEVPNManager.isEnabled (which documents mutual exclusion for personal VPNs), NETransparentProxyManager is designed so multiple transparent proxies can coexist simultaneously, and neither NETransparentProxyManager.h nor NEVPNManager.h documents that isOnDemandEnabled is mutually exclusive across coexisting transparent proxy configurations.

Root Cause Observed in NetworkExtension.framework

When inspecting -[NEConfigurationManager saveConfigurationToDisk:updateSCPreferences:currentSignature:userUUID:notifyNow:isUpgrade:], when iterating over existing NEConfiguration objects of the same grade:

  • If [[newConfig VPN] isEnabled] && [[otherConfig VPN] isEnabled] && [[newConfig VPN] isOnDemandEnabled] && [[otherConfig VPN] isOnDemandEnabled], NEConfigurationManager invokes:
    +[NEConfigurationManager disableConfiguration:otherConfig onDemandOnly:YES]
  • disableConfiguration:onDemandOnly:YES calls [[otherConfig VPN] setOnDemandEnabled:NO], even when both configurations are NETransparentProxyManager (TunnelType == 2).
Real-World Impact

When two enterprise security products that both use NETransparentProxyManager are installed on the same Mac:

  1. Both configure isEnabled = true, onDemandRules = [NEOnDemandRuleConnect()], and isOnDemandEnabled = true.
  2. Whenever Proxy A saves its preferences, nehelper silently flips Proxy B's isOnDemandEnabled to false (and vice versa).
  3. After a reboot or network transition where Proxy B's initial startVPNTunnel fails while Wi-Fi is still associating and Proxy B relies on VPN On Demand (NEOnDemandRuleConnect) to start its NEAppProxyProvider once network connectivity comes up, macOS never starts Proxy B because its isOnDemandEnabled flag was cleared.
Minimal Reproducer (Swift)

Compile this minimal CLI inside an app bundle signed with the com.apple.developer.networking.networkextension = ["app-proxy-provider"] entitlement:

import Foundation
import NetworkExtension

let args = CommandLine.arguments
let name = args.count > 1 ? args[1] : "PocProxyA"
let action = args.count > 2 ? args[2] : "status"

DispatchQueue.main.async {
    NETransparentProxyManager.loadAllFromPreferences { managers, error in
        if let error = error {
            print("[\(name)] loadAllFromPreferences error: \(error)")
            exit(1)
        }
        let all = managers ?? []
        let existing = all.first(where: { $0.localizedDescription == name })

        if action == "status" {
            if let mgr = existing {
                print("[\(name)] -> isEnabled=\(mgr.isEnabled), isOnDemandEnabled=\(mgr.isOnDemandEnabled), rulesCount=\(mgr.onDemandRules?.count ?? 0)")
            } else {
                print("[\(name)] -> not found")
            }
            exit(0)
        }

        if action == "remove" {
            guard let mgr = existing else { exit(0) }
            mgr.removeFromPreferences { err in
                print("[\(name)] removed (err=\(String(describing: err)))")
                exit(0)
            }
            return
        }

        // action == "save"
        let mgr = existing ?? NETransparentProxyManager()
        let proto = (mgr.protocolConfiguration as? NETunnelProviderProtocol) ?? NETunnelProviderProtocol()
        proto.providerBundleIdentifier = "com.example.poc.extension"
        proto.serverAddress = "127.0.0.1"
        mgr.protocolConfiguration = proto
        mgr.localizedDescription = name
        mgr.isEnabled = true
        mgr.onDemandRules = [NEOnDemandRuleConnect()]
        mgr.isOnDemandEnabled = true

        print("[\(name)] Saving NETransparentProxyManager with isEnabled=true, isOnDemandEnabled=true...")
        mgr.saveToPreferences { err in
            if let err = err {
                print("[\(name)] saveToPreferences error: \(err)")
                exit(1)
            }
            print("[\(name)] saveToPreferences succeeded! isEnabled=\(mgr.isEnabled), isOnDemandEnabled=\(mgr.isOnDemandEnabled)")
            exit(0)
        }
    }
}

RunLoop.main.run()
Reproduction Output

Running save on PocProxyA, then save on PocProxyB, and checking status on both:

=== Step 1: PocProxyA saves isOnDemandEnabled = true ===
[PocProxyA] Saving NETransparentProxyManager with isEnabled=true, isOnDemandEnabled=true...
[PocProxyA] saveToPreferences succeeded! isEnabled=true, isOnDemandEnabled=true
[PocProxyA] -> isEnabled=true, isOnDemandEnabled=true, rulesCount=1

=== Step 2: PocProxyB saves isOnDemandEnabled = true ===
[PocProxyB] Saving NETransparentProxyManager with isEnabled=true, isOnDemandEnabled=true...
[PocProxyB] saveToPreferences succeeded! isEnabled=true, isOnDemandEnabled=true

=== Step 3: Check BOTH configurations after PocProxyB saved ===
[PocProxyA] -> isEnabled=true, isOnDemandEnabled=false, rulesCount=1
[PocProxyB] -> isEnabled=true, isOnDemandEnabled=true, rulesCount=1

=== Step 4: PocProxyA saves isOnDemandEnabled = true again ===
[PocProxyA] Saving NETransparentProxyManager with isEnabled=true, isOnDemandEnabled=true...
[PocProxyA] saveToPreferences succeeded! isEnabled=true, isOnDemandEnabled=true

=== Step 5: Check BOTH configurations after PocProxyA saved ===
[PocProxyA] -> isEnabled=true, isOnDemandEnabled=true, rulesCount=1
[PocProxyB] -> isEnabled=true, isOnDemandEnabled=false, rulesCount=1
Questions
  1. Is disableConfiguration:onDemandOnly:YES stripping isOnDemandEnabled across coexisting NETransparentProxyManager (TunnelType = 2) configurations a bug in NEConfigurationManager, or an intentional limitation?
  2. Is there a recommended way for two coexisting NETransparentProxyManager configurations from different vendors to both use On-Demand without disabling each other's isOnDemandEnabled flag on saveToPreferences?

Filed in Feedback Assistant as FB25463155.