feat(session): --lock-on-disconnect + always-on auto-unlock on reconnect - #181
Conversation
TODO.md - In flight: review state of @antonmos's open PRs. #183's blocker is resolved in c8712e9 with one follow-up requested (the connection reset also fires on every reactivation; the latch survives a reset when no modifier is held; a pre-existing phantom drag after a mid-drag disconnect, latent on main too). #182 asked for a per-step deadline, with a ship-now fallback, and collides with the mic branch on divergence (24). #181 and #184 not yet reviewed. Links issue #186. - Upstreaming watch: a dated update superseding the stale "zero open PRs" note. Divergence (23) is upstream via #1476 + #1913 (ConnectionPolicy) with its traps; IronRDP#1969 blocks de-vendoring (22)/(23) and upstreaming (18) and ships in 0.14.0 unless fixed; #1483 closed; overlapping upstream work to evaluate at the next bump (#1951/#1953/#1954 multitransport stack, ironrdp-rdpeai #1645 + #1946 for the mic divergence). vendor/ironrdp-server/CLAUDE.md - (18): upstream status — #1484 green-lit by a peer, held patch 392 commits stale, and blocked on #1969 because upstream Preempt takes the handler for the race. Notes the --fork-workers scope boundary no longer applies. - (12): the #1951 -> #1953 -> #1954 stack as a possible partial de-vendor path, and how it differs from this divergence. Docs only.
|
Thanks for this, and sorry it sat so long. Verified before writing: Things I checked specifically and found right:
Request: split the
|
|
Split done: the 🤖 Addressed by Claude Code |
Makes a headless macrdp Mac lock itself when the last RDP client leaves,
and unlock itself when a client comes back — so a remote-only Mac isn't
left sitting unlocked between sessions, without stranding the operator.
--lock-on-disconnect (opt-in, config LOCK_ON_DISCONNECT=1)
Locks the local session on a genuine last-client-disconnect, hooked into
spawn_primary_overlay_watcher's existing disconnect edge (same seam as
--restore-windows-on-disconnect). SessionTracker also fires on
blank-recovery self-heals, whose drop-then-ARC-reconnect can take ~12-15s
— far longer than the watcher's 2.5s REACTIVATION_GRACE can distinguish
from a real disconnect — so this adds its own cancellable safety buffer
(default 22.5s, ~25s total; MACRDP_LOCK_ON_DISCONNECT_DELAY_MS) and skips
the lock if the session comes back. A heuristic, documented as such.
Mechanism note: the widely-cited `CGSession -suspend` trick does NOT work
on macOS 26 — Menu Extras/User.menu no longer exists. Uses
`open ScreenSaverEngine.app`, which is only a true password-required lock
if the account's screenLock delay is "immediate" (not checked or changed;
modifying security settings is out of scope).
Auto-unlock on reconnect (always-on, no flag; MACRDP_AUTO_UNLOCK=0 opts out)
Types the account password into the lock screen when a client connects
while locked — any lock, any cause. Reuses the same credential PAM already
validated at startup and that the connecting client proved via CredSSP, so
no fresh keychain read; skipped entirely under --skip-auth. Legitimate
mechanism, not a workaround: TN2150 scopes SecureEventInput to blocking
*observation* (event taps), not synthetic injection, and this is what
Apple's own Screen Sharing/ARD do to unlock a screensaver-locked Mac.
Two non-obvious behaviours drove the implementation, both live-diagnosed:
1. A bulk CGEventKeyboardSetUnicodeString fill leaves a secure password
field INERT — the text displays but the field's text-change
bookkeeping never fires, so it ignores every subsequent Return,
including a real one from the physical keyboard. No settle delay or
Return-retry can fix it. Fixed by typing real per-character keycode
events, which needed a reverse UCKeyTranslate lookup:
KeyboardLayout::current() + reverse_map() in keyboard_layout.rs.
2. The lock screen consumes the first keystroke as a wake/focus event
(password arrived one character short). Fixed with a bare Shift tap —
a pure modifier can't insert text or submit. (Using Return as that
wake submits an empty password and shakes the field.)
Every character is resolved to a keystroke BEFORE typing anything, so an
unmappable character aborts the attempt rather than submitting a partial
password. Failures are capped at 2 consecutive attempts per lock (compile-
time asserted below macOS's 3-free-attempt PAM throttle) and then alert
loudly — sound + best-effort notification + error log — rather than
silently retrying into a lockout.
Also fixes: --shield-primary never engaged on a lid-closed MacBook
ShieldedPrimary::install treated "no physical panel in any state" as an
error. A closed lid removes the built-in panel from the display list
entirely — a step beyond the online-but-inactive case its existing
single-panel fallback handles — so every connect failed with "no physical
display to shield". Since the overlay watcher's error arm only warns and
the RDP session works regardless, this was silently disabling the shield,
--restore-windows-on-disconnect, AND the connect-time window gather (all
live inside the same Ok(ovr) branch) with no user-visible symptom. Now
treated as success: nothing is displaying a desktop, so there is nothing
to shield, and install degrades to a no-op shield.
Live-verified end-to-end on macOS 26.5 (lid-closed MacBook Air,
--shield-primary): lock on disconnect, unlock on reconnect, and the shield
path engaging for the first time on this hardware. 211 tests, clippy clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Reviewer asked for the lid-closed ShieldedPrimary::install fix to land on its own, since it is unrelated to lock/unlock. It now lives in clintcan#187; drop it (code + known-quirks note) from this branch so the two PRs don't conflict. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Addresses the two blocking review comments on clintcan#181. 1. The Return-retry loop defeated its own attempt cap. AUTO_UNLOCK_ MAX_CONSECUTIVE_FAILURES=2 (asserted < 3 to stay under macOS's PAM free-attempts margin) capped CALLS to attempt_auto_unlock, but each call internally retried Return up to 3 times, and the loop can't tell "ignored" from "submitted and rejected" apart — both leave the screen locked. Two reconnect attempts could submit the password against PAM up to 6 times against a promise of fewer than 3. Fixed by tracking the budget where it matters: AUTO_UNLOCK_ SUBMISSIONS counts real Return presses, shared across every call for the current lock cycle (not per-call), capped at AUTO_UNLOCK_MAX_SUBMISSIONS=2 via a small pure, unit-tested try_reserve_submission() helper. The budget (and the "gave up" alert latch) resets when the screen is observed unlocked, so a fresh lock cycle gets a fresh allowance. 2. Auto-unlock is now opt-in (--auto-unlock / config AUTO_UNLOCK=1), replacing the always-on-with-an-env-escape-hatch default. The effect lands on the physical machine — once it fires, anyone standing at that Mac has a live desktop, undoing a lock they may have set deliberately — so it should not be a quiet default, matching the rest of the project's opt-in-and-byte-identical-when- off convention. Also taken from the review's non-blocking list: - Caps Lock guard: reverse_map() always resolves assuming Caps is off, and whether a posted CGEvent honors the Mac's hardware Caps Lock state was flagged as unconfirmed (no rig to verify against here). Rather than guess, read the hardware state via CGEventSourceFlagsState (declared locally; core-graphics doesn't wrap it) and skip the attempt while Caps Lock is on — no submission spent, next reconnect tries again for free. - Startup warning when `sysadminctl -screenLock status` isn't Immediately (or the check itself fails), since --lock-on-disconnect's name promises a lock that ScreenSaverEngine.app won't actually enforce otherwise, and the failure was previously invisible. - Marked --lock-on-disconnect and --auto-unlock EXPERIMENTAL in the docs, alongside USB/camera redirection. - Rebased onto v0.9.7 (the branch predated it, per the review). 211 -> 212 tests (net: -2 auto_unlock_enabled tests, +1 flag-parsing test, +2 submission-budget tests). cargo clippy --all-targets -D warnings and cargo fmt --check both clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
b0660a0 to
1092ad9
Compare
|
Both blocking items fixed in 1092ad9, plus the rebase and the three non-blocking asks I could act on without live hardware: 1. Submission budget, not call budget. 2. Non-blocking, taken:
🤖 Generated with Claude Code |
…ng (#187) ShieldedPrimary::install treated "no physical panel in any state" as an error. A closed lid removes the built-in panel from the display list entirely — CGGetActiveDisplayList AND CGGetOnlineDisplayList both return only the virtual display — a step beyond the online-but-inactive case the existing single-panel fallback handles. So every connect failed with "no physical display to shield". The overlay watcher's error arm only warns and the RDP session works regardless, so this silently disabled the shield, --restore-windows-on-disconnect, AND the connect-time window gather (all inside the same Ok(ovr) branch) with no user-visible symptom. With the lid closed nothing is displaying a desktop, so there is nothing to shield: treat it as success and let install degrade to a no-op shield. Empty targets flows cleanly through every downstream step — broke_mirror is empty (mirror-break/displace skipped), the SHOW ack's need is 0, the arrangement tx is skipped under the default keep_physical_main, and Drop already handles empty lists. Live-verified on macOS 26.5 (lid-closed MacBook Air): the first connect after deploying logged "RDP client connected" with shielded_count=0. Split out of #181 at review request. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks — verified both blockers on Two small nits before merge:
After that, the last thing is one live lock/unlock pass. You're right that nobody has run it since these changes, so I'll do it on my side. |
Like --restore-windows-on-disconnect and --lock-on-disconnect, it rides the headless session watcher, so without detach/capture/shield it silently did nothing. Fold it into the existing shared warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Nit 1 fixed in 6c52d6f: 🤖 Addressed by Claude Code |
…eset bug Addresses nit 2 from the clintcan#181 review. screen_is_locked() collapsed a lookup failure to false ("not locked"). That was fine for deciding whether to attempt an unlock (a false negative there just skips an attempt, safe) but became wrong once the submission-budget reset started keying off the same bare bool: an intermittently-failing private-API lookup would read as "confirmed unlocked" on every failure and reset AUTO_UNLOCK_SUBMISSIONS each time, silently defeating the cap AUTO_UNLOCK_MAX_SUBMISSIONS exists to enforce. Fixed by making the tri-state explicit: None = lookup failed (genuinely unknown), Some(true)/Some(false) = a confirmed read. Both attempt_auto_unlock call sites now match on it: - the top-of-function check only resets the budget on a confirmed Some(false), and treats None as "skip this attempt without touching the budget or the alert latch" (AutoUnlockOutcome::SkippedUnsafe) - the in-loop poll after each Return only counts a confirmed Some(false) as success, treating None the same as "still locked" so it can't falsely short-circuit the retry loop Still fails toward the safe direction throughout: every ambiguous case resolves to "don't act," never "assume unlocked." 212 tests pass, clippy -D warnings and fmt --check clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Nit 2 fixed in 8458508: `screen_is_locked()` now returns `Option` instead of a bare `bool`. `None` = the private-API lookup itself failed (genuinely unknown), `Some(true)`/`Some(false)` = a confirmed read. Both call sites in `attempt_auto_unlock` now match on it:
So a flaky lookup can no longer launder its way into an unlimited retry budget. Still fails toward the safe direction throughout (every ambiguous case → "don't act," never "assume unlocked"). 212 tests pass, clippy `-D warnings` and `fmt --check` clean. Ready for your live lock/unlock pass whenever convenient — no behavior change expected there since this only touches the failure-path accounting, not the happy path. 🤖 Generated with Claude Code |
|
Ran the live pass on a Mac mini (M1, macOS 26.5) over ZeroTier with nobody physically at it: main (incl. #187) +
Suggested fixes:
Happy to re-run it on the mini once those are in. |
…n lock Two bugs found in the reviewer's live pass on a Mac mini (M1, macOS 26.5) over ZeroTier: lock-on-disconnect fired reliably and the typed password worked 3/3, but detection of the successful unlock was flaky and a false give-up escalated into a remote lockout only an agent restart could clear. 1. RETURN_ATTEMPT_BUDGET (400ms) was too tight a window for screen_is_locked() to observe a real unlock on that link/hardware. With AUTO_UNLOCK_MAX_SUBMISSIONS=2 that's ~800ms total before giving up; when the unlock hadn't been observed by then, the loop spent its second submission on a Return that could land on the already-unlocked desktop and then falsely reported BudgetExhausted. Raised to 3s per Return, per the reviewer's suggestion, so a slower link/lookup has real room to register the flip before the next submission is spent. 2. The submission budget only reset on an OBSERVED Some(false) from inside attempt_auto_unlock, never on the act of locking. So after a false give-up, the real screen state was unlocked but nothing was polling it to notice — the budget stayed pinned at AUTO_UNLOCK_MAX_SUBMISSIONS, and the *next* lock-on-disconnect cycle started pre-exhausted: the reconnect after that refused to even attempt an unlock, indefinitely. Fixed per the reviewer's suggested root-cause fix: reset AUTO_UNLOCK_SUBMISSIONS/ AUTO_UNLOCK_GAVE_UP immediately before lock_session() in the lock_on_disconnect closure — that's always a point where the screen is known to be unlocked, so every lock cycle this project initiates is guaranteed a fresh budget regardless of how the previous cycle's detection turned out. 212 tests pass, clippy -D warnings and fmt --check clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Both issues from the Mac mini pass fixed in 5753ac8, following your suggested fixes directly: 1. Widened the detection window. 2. Reset the budget at lock time, not just on observed unlock. The
🤖 Generated with Claude Code |
|
Thanks — both look right: 3 s per Return and the reset-before-lock close what I saw on the mini. 212 pass, clippy/fmt clean. One tightening on the reset: it assumes the screen is unlocked at that point, but nothing checks it. If it's already locked when lock-on-disconnect fires, the same lock gets a fresh budget. Realistic case: the Mac's password is changed while macrdp keeps running. RDP still accepts the old password, auto-unlock types the old one, and every disconnect/reconnect then burns 2 more attempts against a lock that never clears — exactly the macOS escalation the cap exists to prevent. Gating the reset on After that I'll re-run the pass on the mini. |
…read Caught in review: the reset added in 5753ac8 zeroed AUTO_UNLOCK_SUBMISSIONS/AUTO_UNLOCK_GAVE_UP unconditionally right before lock_session(), assuming the screen was unlocked at that point without checking. That assumption breaks in a realistic case: the account password changes while macrdp keeps running. RDP auth still accepts the cached, now-stale password via NLA, so attempt_auto_unlock keeps typing that same wrong password and genuinely fails every time — a real rejection, not a detection miss, which the submission cap is supposed to stop retrying. Without the gate, every disconnect/reconnect cycle would hand that permanently- failing attempt a fresh 2-submission budget, burning real PAM submissions against a lock that will never clear — the same escalating-lockout risk the cap exists to prevent, just spread across cycles. Fixed: the reset now only fires when virtual_display::screen_is_locked() == Some(false) — a confirmed unlocked read right before lock_session() is called. Some(true) (still locked) or None (lookup failed) leave the budget/latch alone, so a persistently-wrong password stays given-up instead of getting a free retry allowance on every cycle. 212 tests pass, clippy -D warnings and fmt --check clean. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Good catch — fixed in f68113a. The reset before `lock_session()` is now gated on `virtual_display::screen_is_locked() == Some(false)`, so it only fires on a confirmed unlocked read. `Some(true)` (still locked, e.g. your stale-password scenario) or `None` (lookup failed) leave the budget and the give-up latch alone — a persistently-wrong password now stays given-up instead of getting a fresh 2-submission allowance every disconnect/reconnect cycle. `cargo test` 212 passed, clippy `-D warnings` and `fmt --check` clean. Ready whenever you want to re-run the mini. 🤖 Generated with Claude Code |
|
Final live pass on the mini with main + One small follow-up for a separate PR (not blocking): the lock can fire mid-handshake. A connection only counts as "back" once it's fully connected, which takes about 10 s here, so a reconnect started 12–22 s after disconnecting gets locked underneath it. With |
|
Great to hear — thanks for running all of these down on real hardware, that's exactly the kind of testing this feature needed. Filed the mid-handshake lock follow-up as a separate tracked task, to be picked up as its own PR against main once this one's merged, per your note that it's non-blocking. 🤖 Generated with Claude Code |
…#188) Adds a "Lock while away (experimental)" section to the controller's Display tab for the two #181 settings, LOCK_ON_DISCONNECT and AUTO_UNLOCK. Both only run inside the headless session watcher, so the toggles are disabled under "Keep local screen on", and normalize() clears them when no headless mode is active — the same parent-resets-child rule the virtual display and UDP tunnel already follow — so a stale ON can't silently re-arm when a mode is picked later. The two are exposed as a pair rather than lock alone: turning the lock on without auto-unlock shows an orange warning, because a remote-only Mac can't be reached once it locks (reproduced on a real remote mini during #181's live test). The caption states the two things a user can't see from the toggle: the lock is only real when "Require password after screen saver begins" is Immediately, and auto-unlock leaves the physical Mac usable by anyone at it.
A feature release for headless, remote-only Macs plus a --shield-primary fix. Everything new is opt-in; the default runtime path is unchanged. - #181 (@antonmos): --lock-on-disconnect locks the Mac ~25 s after the last client leaves; --auto-unlock types the PAM-validated password into the lock screen on reconnect, with a shared per-lock budget of 2 real submissions. Both experimental; live-verified on a Mac mini. - #188: GUI controller toggles for both (Display tab, "Lock while away"). - #187 (@antonmos): --shield-primary engages on a lid-closed MacBook instead of erroring, which had silently disabled the shield, restore-windows and the connect-time window gather. - Repo hardening: CODEOWNERS + code-owner review, fork-PR CI approval for all outside collaborators, enforced SHA pinning. Known issue: a reconnect ~12-22 s after leaving can be locked mid-handshake; self-corrects with --auto-unlock, strands a remote user without it. Pre-tag gates: fmt clean (stable + nightly), clippy -D warnings clean, 212 tests passing. Docs: release-history, README status, CLAUDE.md status.
…ing (#190) A session only counts as live once FULLY connected (~10 s over ZeroTier), so a client that reconnected 12-22 s after leaving was locked underneath its own handshake (#181 follow-up; invisible with --auto-unlock, strands a remote user without it). src/lock_activity.rs wraps the connection handler — only with --lock-on-disconnect, so the default path is unchanged — to timestamp accepted connections and successful CredSSP authentications. At expiry the pending lock holds while there is activity since the disconnect: an authenticated reconnect within 30 s, or an accepted connection within 10 s (the timer can end mid-CredSSP). Each hold is capped at 30 s past the normal firing time, so an unauthenticated peer can only delay the lock, never prevent it. Activity from before the disconnect is ignored, and a later disconnect supersedes an earlier pending lock (LOCK_GENERATION). 8 new unit tests (the policy, the cap, pre-disconnect activity, the wrapper preserving the inner handler's verdict).
|
Heads-up on the mid-handshake lock follow-up you flagged in your final review: I picked it up and it's merged as #190, so no need to spend time on it. It holds the pending lock while there's been connection activity since the disconnect: an authenticated reconnect within 30 s, or an accepted connection within 10 s, since the timer can end mid-CredSSP. The total hold is capped at 30 s past the normal firing time, so an unauthenticated peer can only delay the lock, never prevent it. I used timestamps rather than a per-connection counter because preemption candidates can get Live-tested with a ~5.5 s timer, so a normal reconnect lands mid-handshake. The lock held and was then skipped once the session came up, and the negative control still locked on time. mstsc's spare pre-auth connection used ~7.7 s of the 10 s pre-auth window before the real one arrived. If you see that margin get tight on your ZeroTier setup, I'd like to know. Thanks again for catching it. It'll ship in the next release (just after v0.9.9). |
Makes a headless macrdp Mac lock itself when the last RDP client leaves and unlock itself when a client comes back — so a remote-only Mac isn't left sitting unlocked between sessions, without stranding the operator outside it.
Default runtime path is unchanged when the new flag is off; auto-unlock only ever acts on a session that is already locked.
--lock-on-disconnect(opt-in, configLOCK_ON_DISCONNECT=1)Locks the local session on a genuine last-client-disconnect, hooked into
spawn_primary_overlay_watcher's existing disconnect edge — the same seam--restore-windows-on-disconnectuses.The subtlety worth reviewing:
SessionTrackeralso fires on blank-recovery self-heals, and its drop-then-ARC-reconnect can legitimately take ~12–15s — far longer than the watcher's 2.5sREACTIVATION_GRACEcan distinguish from a real disconnect. So this adds its own cancellable safety buffer (default 22.5s, ~25s total,MACRDP_LOCK_ON_DISCONNECT_DELAY_MS) and skips the lock if the session comes back. It's a heuristic timer, documented as such — same posture as the auth_guard lockout and the blank-recovery detector.Mechanism note: the widely-cited
CGSession -suspendtrick does not work on macOS 26 —Menu Extras/User.menuno longer exists (the first deployed build silently failed every lock). Usesopen ScreenSaverEngine.appinstead, which is only a true password-required lock if the account's screenLock delay is "immediate". macrdp deliberately does not check or change that setting.Auto-unlock on reconnect (always-on,
MACRDP_AUTO_UNLOCK=0/AUTO_UNLOCK=0opts out)Types the account password into the lock screen when a client connects while locked — any lock, any cause, not just one this feature set. That is a deliberate security-posture choice worth an explicit look from a reviewer: an authenticated RDP connection can undo a lock someone set for an unrelated reason.
Reuses the credential PAM already validated at startup and that the connecting client independently proved via CredSSP — so no fresh keychain read, and it's skipped entirely under
--skip-auth(where nothing was PAM-validated).The mechanism is legitimate rather than a workaround: Apple's TN2150 scopes
SecureEventInputto blocking observation (event taps / keyloggers), not synthetic injection, and this is the same thing Apple's own Screen Sharing / ARD do to unlock a screensaver-locked Mac remotely. It also resolves an apparent contradiction in our own docs — the "posting to the login window is blocked" note refers to the pre-sessionloginwindow.app, where no Accessibility grant exists yet, not the in-session screensaver lock this targets.Two non-obvious behaviours drove the implementation (both live-diagnosed)
CGEventKeyboardSetUnicodeStringfill leaves a secure password field INERT. The text displays, but the field's text-change bookkeeping never fires, so it ignores every subsequent Return — including a real Return from the physical keyboard. No settle delay or Return-retry can fix this. Fixed by typing real per-character keycode events, which required a reverseUCKeyTranslatelookup:KeyboardLayout::current()+reverse_map()inkeyboard_layout.rs.Safety properties
error!log — rather than silently retrying into a lockout.Also fixes:
--shield-primarynever engaged on a lid-closed MacBookShieldedPrimary::installtreated "no physical panel in any state" as an error. A closed lid removes the built-in panel from the display list entirely — a step beyond the online-but-inactive case the existing single-panel fallback handles — so every connect failed withno physical display to shield.Because the overlay watcher's error arm only warns and the RDP session works regardless, this was silently disabling the shield,
--restore-windows-on-disconnect, AND the connect-time window gather (all live inside the sameOk(ovr)branch) with no user-visible symptom. Now treated as success: nothing is displaying a desktop, so there is nothing to shield, andinstalldegrades to a no-op shield. Verified by inspection that emptytargetsflows cleanly through the mirror-break, SHOW-ack, arrangement, andDroppaths.Testing
cargo test211 passing,cargo clippy --all-targets -- -D warningsclean.reverse_map_round_trips_password_characters(asserts letters/digits/symbols/space map on the US layout, and that every entry translates back to the character it's filed under — a wrong entry would mean typing a wrong password), plus auto-unlock env-parsing and lock-delay parsing tests.--shield-primary: lock fires on disconnect, unlock succeeds on reconnect, and the shield path engages for the first time on this hardware.Notes for the reviewer
screen_is_locked()uses the undocumentedCGSessionCopyCurrentDictionary()/CGSSessionScreenIsLocked. It's quarantined invirtual_display/private_api.rsalongside the existing CGS/SkyLight private calls, fails toward "not locked" (the safe direction), and the doc comment names thecom.apple.screenIsLockeddistributed-notification fallback if Apple ever removes it. GivenCGSessionitself vanished in macOS 26, this risk is real and called out deliberately.docs/known-quirks.mdcarry the full five-round failure history and explicit "do not simplify this back" warnings.screen_is_locked()check and the keystrokes, someone could unlock locally, and the password would land on the desktop instead. Mitigated by checking as late as possible, not eliminated.🤖 Generated with Claude Code