Files
ShadowsocksX-NG/docs/adr/0020-privileged-system-proxy-writer.md

7.1 KiB

Delegate privileged system-proxy writes to a LaunchDaemon

Status: accepted; lifecycle trigger and apply-scope clauses are partially superseded by ADR-0022. Its passive observation, explicit repair, and health-driven suspension policy was implemented in commit 4a0a72c for Issue #73. Privileged execution, registration, typed values, and unconditional all-location cleanup remain in force.

Context

macOS system proxy configuration is device-wide, while each account has an independent 2.0 system proxy intent. Multiple 2.0 instances therefore write the same configuration; the latest serialized request wins. The existing per-user Agent continues to own only the local tunnel runtime.

System proxy writes require privileged SystemConfiguration access. The GUI must also make the domain decisions: whether proxying is intended, whether the runtime is healthy, which network changes should trigger application, and what complete values should be applied. The privileged process should execute that intent without reconstructing it or deciding when cleanup is appropriate.

Decision

The lifecycle paragraphs below preserve the original ADR-0020 decision. For when to apply or clear, repair scope, and approval/retry behavior, use ADR-0022; its exceptions replace the original “only on-to-off” cleanup restriction and automatic network/launch reapplication.

Package a root LaunchDaemon with the app and register it through SMAppService.daemon. The GUI registers the service when an enabled intent first needs it: on the first explicit system-proxy enable, and again from launch reconciliation if the registration was lost (for example after reinstalling the app). It presents the macOS Login Items approval path when needed. The daemon is activated on demand and remains registered when the agent switch or the system proxy switch is off. The existing per-user proxy Agent remains separate. The GUI does not fall back to direct Authorization Services writes.

The LaunchDaemon property list references the helper executable with the BundleProgram key. A relative path inside ProgramArguments is resolved against the standard PATH, not the bundle, so launchd can never spawn the program and the job enters an EX_CONFIG retry loop; BundleProgram is the only bundle-relative form launchd supports for SMAppService-submitted jobs. launchd also keeps the job definition it imported at registration time: when the property list content changes across app updates, the GUI compares a content fingerprint of the plist against the one recorded at registration, unregisters the service, waits out the launchd minimum-runtime throttle window, and registers again. Re-registering immediately after unregistering silently keeps the stale definition because launchd removes the throttled job asynchronously. Approval is bound to the bundle signature, so this refresh never prompts again.

The XPC listener accepts a client whose code-signing identifier matches the 2.0 bundle identifier. It does not require a particular Team ID or user ID. This permits open-source builds signed by other teams and requests from 2.0 instances in other accounts. The helper processes requests serially; the last accepted request determines the device-wide configuration.

The GUI sends a closed typed apply configuration containing explicit values for SOCKS, HTTP, and HTTPS enablement and endpoints; PAC disabled with its URL and JavaScript values removed; auto-discovery disabled; the simple-hostname exclusion value; and the complete exceptions list. HTTPS is explicitly supplied even when it shares the HTTP endpoint. The GUI owns the simple-hostname default, which is on. The exceptions list is never optional and never means “preserve the current list.” The helper maps the supplied values to SystemConfiguration mechanically and does not fill in product defaults.

Apply targets every service in the active network location. It changes only keys represented by the typed configuration and preserves other Proxies keys. Cleanup is a separate typed command: it removes the complete Proxies dictionary for every service in every network location, without checking the source. There is no endpoint signature, snapshot, ownership marker, or restore path. Any endpoint-signature files left by an older build are ignored, not read, migrated, or removed. Users coordinate 2.0 with other proxy apps and handle any configurations they do not want removed.

Only an on-to-off transition of the system-proxy intent may request cleanup. A direct system-proxy switch-off causes this transition. Turning off the Agent switch also turns off system-proxy intent if it was on, and that intent transition causes cleanup; if system-proxy intent was already off, turning off the Agent does not clean system settings. When the Agent action changes both intents, persist both as off, request cleanup, then stop the local listeners. Starting the Agent later does not turn system-proxy intent back on. Neither switch being off at GUI launch, an automatic health or exit-gate change, an invalid target, nor a passive network event requests cleanup. A clear attempt watches location, service, and proxy-configuration changes and rescans until that attempt finishes; observation then stops, including if the request fails. If the daemon cannot execute an explicit clear, the GUI reports the failure and does not retry it in the background. No additional per-toggle confirmation or explanation of all network locations is shown.

If daemon approval or availability is missing, an enabled system proxy intent remains pending, the GUI presents the approval path, and current system settings remain untouched. A clear failure is not deferred. On app update, the GUI uses the existing SMAppService registration state and surfaces approval again if required. Removing the app does not clear system proxy settings; users must turn the system proxy off before removal or handle remaining settings manually.

The GUI retains the current network policy: when enabled and its health and exit gates pass, it applies to the active location; location, service, and app-visible network-path changes trigger reapplication, while proxy-configuration-only changes do not. GUI launch performs one reconciliation before observation starts. A gate closure leaves existing system settings in place; recovery can reapply the enabled intent. Replacing automatic observation and reapplication with a passive notice and user-triggered repair remains a separate decision.

Consequences

Unconditional cleanup can remove another app's values across network locations. The user is responsible for coordinating proxy apps. The switch itself remains a direct action without an added confirmation step or an adjacent explanation about network locations.

This proposal supersedes ADR-0019's endpoint-signature matching and persistence, startup cleanup, and health-gate cleanup. It retains ADR-0019's current enabled-state network observation, reapplication, and finite cleanup-attempt observation. If accepted, implementation must distinguish an explicit switch-off command from launch-time runtime reconciliation so an already-off preference never triggers cleanup.