curtain/Sources/Curtain/DisconnectClient.swift
Aric Camarata 8c19e960d2 Detection root-cause fix + audit batch: netstat path, UDP activator, settings coherence, refactor, docs
Detection: netstat lives at /usr/sbin/netstat, not /usr/bin — the hardcoded wrong
path silently killed the ESTABLISHED-TCP activator (root cause of the failed live
test). Fixed and live-verified. Added peered-UDP activator (5900-5902) for
High-Performance sessions, per-signal transition logging, unconditional error
logging for dead probe helpers, and probe v2 with full CGSession dictionary
diffing. 7 new parser tests (32 total).

Fixes from a full audit + adversarial review: idle source setting honored
(default now Remote session activity), cover scope reduced to a coherent
two-mode model with legacy migration (per-display toggle was inverted in
onlyMarked and dead in all), curtain test no longer schedules a teardown over a
live session, specific-display password box placement gets a real picker,
refuse-to-arm enforced, activation notification posts a real banner, menu
password gate bypassed when the event tap is dead, shared single-decoder aerial
player with stale-task guard and async playability check, password buffer zeroed
on successful unlock and Esc, XPC interruption/invalidation handlers, modern
Accessibility settings URL, launchPath modernized, codesign failures now abort
release.sh, monotonic CFBundleVersion, install.sh temp cleanup, dead
armDisarmHotkey setting removed.

Refactor: Curtain.swift and PreferencesWindow.swift split into focused files
(largest now 479 lines). Wiki, README, and contributing docs updated to match.
Build clean at 0 warnings, 32/32 tests pass.
2026-06-09 20:36:30 -04:00

353 lines
16 KiB
Swift

import Foundation
import AppKit
import ServiceManagement
import Security
import CurtainShared
/// Purpose: App-side controller for the optional privileged disconnect helper, with
/// two install paths so it works on BOTH a notarized build and a local
/// ad-hoc/unsigned build.
/// 1. SMAppService.daemon the future, notarized path (XPC to the daemon).
/// 2. A sudoers helper script the fallback for ad-hoc/local installs,
/// where SMAppService.daemon().register() refuses to run.
/// When the feature is on it installs the `System.disconnectHandler` closure
/// that ends the active remote Screen Sharing session via whichever path is
/// actually available.
/// Inputs: `Settings.disconnectFeatureEnabled`, the toggle from settings/onboarding.
/// Outputs: Daemon registration OR sudoers helper install/removal; a set/cleared
/// `System.disconnectHandler`.
/// Constraints: @MainActor (touches SMAppService + app state). The handler closure
/// runs on a background queue (System invokes it off-main), and the
/// sudo/XPC work happens there too never on the main actor. Install and
/// removal are idempotent per OS-admin-prompt-hygiene: state-check before
/// prompting, never loop a denied prompt.
///
/// Why two paths: SMAppService.daemon requires a Developer ID signature + a registered
/// LaunchDaemon, which an ad-hoc build does not have, so `register()` throws and the
/// disconnect would silently no-op. The sudoers fallback grants the one privileged
/// action we need (killing the root-owned Screen Sharing processes) via a single
/// NOPASSWD rule scoped to the CURRENT USER only not the whole admin group so the
/// blast radius is one fixed script for one account. The sudoers path is for ad-hoc /
/// local installs ONLY; the notarized build uses SMAppService.daemon.
/// SPORT: MASTER-DISCONNECT
@MainActor
final class DisconnectClient {
static let shared = DisconnectClient()
private init() {}
private var daemon: SMAppService {
SMAppService.daemon(plistName: CurtainHelperInfo.daemonPlistName)
}
// Fallback sudoers helper paths (ad-hoc/local installs only).
private static let helperScriptPath = "/usr/local/bin/curtain-endsession"
private static let sudoersFilePath = "/etc/sudoers.d/curtain-endsession"
/// True when disconnect will actually work: either the SMAppService daemon is
/// registered, or the sudoers helper script is installed and executable. The UI
/// reads this so it can tell the user whether enabling did anything.
var isHelperAvailable: Bool {
daemon.status == .enabled || isSudoersHelperInstalled
}
private var isSudoersHelperInstalled: Bool {
FileManager.default.isExecutableFile(atPath: Self.helperScriptPath)
}
// MARK: - Sudoers-is-dev-only rule
//
// The sudoers fallback writes a NOPASSWD rule into /etc/sudoers.d. That is an
// acceptable convenience for a developer running a locally-built, ad-hoc/unsigned
// app on their own machine but it must NEVER ship inside a notarized public
// build, where it would be a privilege-escalation footgun. The hard rule:
//
// - A properly-signed build (real Developer-ID / Team ID, not ad-hoc) uses the
// SMAppService.daemon path ONLY. If that fails it reports the failure and stops.
// - The sudoers fallback is reachable ONLY when isProperlySigned() == false, i.e.
// an ad-hoc / dev / unsigned build that genuinely cannot register a daemon.
//
// isProperlySigned() inspects the running app's own code signature. An ad-hoc
// signature carries no Team ID and sets the adhoc flag; a real Developer-ID
// signature carries a Team ID and clears that flag. We treat "has a Team ID and is
// not ad-hoc" as properly signed.
/// True only for a real (Developer-ID / non-ad-hoc) signature on the running app.
/// Used to gate the sudoers fallback so a notarized public build can never install
/// a sudoers rule. Fails closed (returns true blocks the fallback) only when the
/// signing info is unreadable AND a Team ID is present; an unreadable signature with
/// no Team ID is treated as unsigned/ad-hoc so local dev still works.
static func isProperlySigned() -> Bool {
var codeRef: SecCode?
guard SecCodeCopySelf([], &codeRef) == errSecSuccess, let code = codeRef else {
return false
}
var staticRef: SecStaticCode?
guard SecCodeCopyStaticCode(code, [], &staticRef) == errSecSuccess,
let staticCode = staticRef else {
return false
}
var infoRef: CFDictionary?
guard SecCodeCopySigningInformation(staticCode,
SecCSFlags(rawValue: kSecCSSigningInformation),
&infoRef) == errSecSuccess,
let info = infoRef as? [String: Any] else {
return false
}
// An ad-hoc signature sets the adhoc bit in the code-signing flags.
if let flags = info[kSecCodeInfoFlags as String] as? UInt32 {
let adhocFlag: UInt32 = 0x2 // kSecCodeSignatureAdhoc
if flags & adhocFlag != 0 { return false }
}
// A real Developer-ID signature carries a Team ID; ad-hoc signatures do not.
guard let teamID = info[kSecCodeInfoTeamIdentifier as String] as? String,
!teamID.isEmpty else {
return false
}
return true
}
/// Turn the feature on or off, picking the install path that works on this build.
/// On: try SMAppService first (notarized path); fall back to the sudoers helper if
/// it throws or doesn't end up enabled (ad-hoc path). Off: tear down whichever is
/// present. Idempotent throughout.
func setEnabled(_ on: Bool) {
if on {
var daemonOK = false
do {
if daemon.status != .enabled {
try daemon.register()
NSLog("Curtain: disconnect helper registered via SMAppService")
}
daemonOK = (daemon.status == .enabled)
} catch {
NSLog("Curtain: SMAppService daemon register failed (%@) — using sudoers fallback",
String(describing: error))
}
if !daemonOK {
// Sudoers fallback is dev-only: reachable solely on an ad-hoc/unsigned
// build. A properly-signed build that still couldn't register the daemon
// must NOT touch sudoers report instead.
if Self.isProperlySigned() {
NSLog("Curtain: SMAppService daemon unavailable on a signed build — not installing sudoers fallback")
Notifier.post(title: "Curtain",
body: "The disconnect helper could not be installed. Try reinstalling Curtain or check System Settings > Login Items.",
throttleKey: "disconnect-helper",
throttleSeconds: 60)
} else {
installSudoersHelper()
}
}
} else {
do {
if daemon.status == .enabled {
try daemon.unregister()
NSLog("Curtain: disconnect helper unregistered")
}
} catch {
NSLog("Curtain: SMAppService daemon unregister failed: %@", String(describing: error))
}
removeSudoersHelper()
System.disconnectHandler = nil
}
syncWithSettings()
}
/// Reconcile the live `System.disconnectHandler` with the persisted setting and the
/// path that is actually available: privileged XPC if the daemon is enabled, else
/// the sudoers helper if installed, else nothing.
func syncWithSettings() {
guard Settings.disconnectFeatureEnabled else {
System.disconnectHandler = nil
return
}
if daemon.status == .enabled {
System.disconnectHandler = { Self.callHelper() }
} else if isSudoersHelperInstalled {
System.disconnectHandler = { Self.runSudoEndSession() }
} else {
// Helper not available: never leave the handler nil, or disconnect
// requests vanish with no feedback. Tell the user how to fix it instead.
System.disconnectHandler = { Self.notifyHelperNeeded() }
}
}
/// Disconnect was requested but no privileged helper is installed. Surface a
/// throttled user notification (at most once per ~60s, handled by Notifier) so the
/// user knows the disconnect did nothing and where to enable it. Called off-main
/// via the handler.
static func notifyHelperNeeded() {
Log.event("disconnect requested but helper not enabled")
let body = "To disconnect the remote session, turn on the disconnect helper in Settings > Disconnect."
Notifier.post(title: "Curtain",
body: body,
throttleKey: "disconnect-helper",
throttleSeconds: 60)
NSLog("Curtain: disconnect requested but helper not enabled — %@", body)
}
// MARK: - SMAppService (notarized) path
/// Open a one-shot privileged XPC connection, ask the helper to disconnect, and
/// tear the connection down. Runs on the background queue System dispatches to.
///
/// Handlers are installed BEFORE resume() so no race can deliver an interruption
/// or invalidation event to an unregistered handler. A once-flag guards against
/// the semaphore being over-signaled if both handlers fire on a broken connection
/// (over-signaling DispatchSemaphore is safe; this is defensive hygiene).
private static func callHelper() {
Log.event("disconnect via XPC daemon")
let conn = NSXPCConnection(machServiceName: CurtainHelperInfo.machServiceName,
options: .privileged)
conn.remoteObjectInterface = NSXPCInterface(with: CurtainDisconnectXPC.self)
let done = DispatchSemaphore(value: 0)
// nonisolated(unsafe) is not needed here the once flag is only accessed
// from the XPC queue that serialises these two callbacks.
var signaled = false
let signalOnce = {
if !signaled { signaled = true; done.signal() }
}
// Interruption means the helper crashed or the connection dropped mid-call.
conn.interruptionHandler = {
NSLog("Curtain: disconnect XPC connection interrupted")
signalOnce()
}
// Invalidation fires on any terminal failure (bad service name, rejected, etc.).
conn.invalidationHandler = {
NSLog("Curtain: disconnect XPC connection invalidated")
signalOnce()
}
conn.resume()
// Guard the cast: if the proxy is nil or doesn't conform, there is nothing
// to call and the timeout would burn 5 s for no reason.
guard let proxy = conn.remoteObjectProxyWithErrorHandler({ error in
NSLog("Curtain: disconnect XPC error: %@", String(describing: error))
signalOnce()
}) as? CurtainDisconnectXPC else {
NSLog("Curtain: disconnect XPC proxy cast failed — invalidating")
conn.invalidate()
return
}
proxy.endScreenSharingSession { ok in
Log.event("disconnect result: \(ok ? "matched" : "no match")")
NSLog("Curtain: helper ended session: %@", ok ? "matched" : "no match")
signalOnce()
}
_ = done.wait(timeout: .now() + 5)
conn.invalidate()
}
// MARK: - Sudoers (ad-hoc/local) fallback path
/// Install the privileged helper script + a NOPASSWD sudoers rule scoped to the
/// current user, via a SINGLE admin prompt. Idempotent: if both files already
/// exist and the sudoers entry validates, skip the prompt entirely.
private func installSudoersHelper() {
if isSudoersHelperInstalled
&& FileManager.default.fileExists(atPath: Self.sudoersFilePath) {
NSLog("Curtain: sudoers disconnect helper already installed — skipping prompt")
syncWithSettings()
return
}
let user = NSUserName()
// launchd respawns the listener, so Screen Sharing stays available afterward.
let script = """
#!/bin/bash
pkill -f ScreenSharingSubscriber
pkill -x screensharingd
pkill -f "RemoteManagement.*[Ss]creen"
exit 0
"""
let sudoersLine = "\(user) ALL=(root) NOPASSWD: \(Self.helperScriptPath)"
// base64 the script so quoting/newlines survive the osascript shell hop intact.
let scriptB64 = Data(script.utf8).base64EncodedString()
let sudoersB64 = Data(sudoersLine.utf8).base64EncodedString()
let install = """
/bin/mkdir -p /usr/local/bin && \
printf '%s' '\(scriptB64)' | /usr/bin/base64 -D -o '\(Self.helperScriptPath)' && \
/bin/chmod 755 '\(Self.helperScriptPath)' && \
printf '%s' '\(sudoersB64)' | /usr/bin/base64 -D -o '\(Self.sudoersFilePath)' && \
/bin/chmod 440 '\(Self.sudoersFilePath)' && \
/usr/sbin/visudo -cf '\(Self.sudoersFilePath)'
"""
if runAdminShell(install) {
NSLog("Curtain: sudoers disconnect helper installed for user %@", user)
} else {
NSLog("Curtain: sudoers disconnect helper install failed")
}
syncWithSettings()
}
/// Remove both helper files via one admin prompt only when at least one exists.
private func removeSudoersHelper() {
let fm = FileManager.default
guard fm.fileExists(atPath: Self.helperScriptPath)
|| fm.fileExists(atPath: Self.sudoersFilePath) else { return }
let remove = "/bin/rm -f '\(Self.helperScriptPath)' '\(Self.sudoersFilePath)'"
if runAdminShell(remove) {
NSLog("Curtain: sudoers disconnect helper removed")
} else {
NSLog("Curtain: sudoers disconnect helper removal failed")
}
}
/// Run a shell command once with administrator privileges (one OS password prompt).
/// Returns true on exit status 0. Never loops on a denied prompt.
private func runAdminShell(_ command: String) -> Bool {
let escaped = command
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "\"", with: "\\\"")
let osa = "do shell script \"\(escaped)\" with administrator privileges"
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
task.arguments = ["-e", osa]
do {
try task.run()
task.waitUntilExit()
return task.terminationStatus == 0
} catch {
NSLog("Curtain: admin shell launch failed: %@", String(describing: error))
return false
}
}
/// Invoke the installed sudoers helper with `sudo -n` (no prompt the NOPASSWD
/// rule covers it) on a background queue, with a timeout, never blocking main.
/// Called off-main via `System.disconnectHandler`.
static func runSudoEndSession() {
Log.event("disconnect via sudo helper")
DispatchQueue.global(qos: .userInitiated).async {
let task = Process()
task.executableURL = URL(fileURLWithPath: "/usr/bin/sudo")
task.arguments = ["-n", helperScriptPath]
do {
try task.run()
// Bounded wait: poll for exit so a hung process can't pin the queue.
let waitDeadline = Date().addingTimeInterval(5)
while task.isRunning && Date() < waitDeadline {
usleep(50_000)
}
if task.isRunning {
task.terminate()
NSLog("Curtain: sudo end-session timed out — terminated")
} else {
NSLog("Curtain: sudo end-session exited %d", task.terminationStatus)
}
} catch {
NSLog("Curtain: sudo end-session launch failed: %@", String(describing: error))
}
}
}
}