02 TRUST & STREAM // Pairing sequence & session lifecycle SRC connectorbackend.cpp · connectorsessionmanager.cpp
02.A

Trust is a separate problem from reachability

connectorbackend.cpp:2640

Approving your own machines

Moonlight pairing is a PIN ceremony: the client generates four digits, the host operator types them into a web UI. That is fine once. It is absurd when both machines belong to you, are signed into the same Tailscale account, and one of them is in another room. Connector keeps the ceremony but automates the human out of it — carefully, and only when it can cryptographically-adjacent-prove that both ends are yours.

The gate is canAutoApprovePairForDevice(). It has four hard conditions, and each failure produces a specific, quotable reason string that lands in the pairing status line rather than a generic error.

route Device must carry route == "Tailscale". A manual endpoint is never auto-approved: "This host is not using the Connector tailnet route, so Connector cannot auto-authorize it safely."
local identity Local node must be signed in, online, and have a non-empty UserID from status --json.
shell path Non-interactive shell automation must be supported for the device — because the approval is delivered by running a command on the host.
owner match tailscale whois --json <ip> must return a UserProfile whose ID and LoginName both match the local node's. A mismatch fails loudly: "This host is on Tailscale, but it belongs to a different account."

Only when all four hold does startAutoApprovePairing() begin. It runs on a QtConcurrent thread, retries up to 60 times with a 500 ms sleep — a thirty-second budget — and breaks out early if the failure reason mentions a different account, an unverifiable local Tailscale account, or a missing non-interactive host control path. Those three are permanent failures; retrying them is just noise.

"Same-tailnet clients should auto-trust. PIN approval is only the fallback path."

CommandThisComputer.qml — default pairing status text
02.B

The approval command

connector-linux-hostctl.py:1031

What actually lands on the host is a single subcommand of the staged helper. It validates before it acts:

  • PIN must match ^\d{4}$ exactly
  • client name required, whitespace-collapsed
  • client name capped at 128 characters
  • if the runtime is not responding, start it first and record started_runtime
  • then try each Sunshine candidate path in turn

For each candidate it ensures an apps.json exists, then runs sunshine --approve-pair <pin> <name> with a 10-second timeout and parses the last JSON object in stdout — a small robustness trick against runtimes that log before they answer.

Client name

The name written into the host's trust record is localPairingClientName(): the local Tailscale hostname if known, otherwise QHostInfo::localHostName(), otherwise the literal string "Connector Client".

02.C

Pairing sequence

pairDevice() · startAutoApprovePairing() · CliPair::Launcher

Two independent conversations run at once. The vendored Moonlight pairing launcher talks TLS to the host's Sunshine HTTP service and waits for the PIN to be accepted. Meanwhile Connector's own thread repeatedly SSHes in and types the PIN on the host's behalf. Whichever finishes first, the launcher's success signal is the only thing that flips the trust state.

Connector pairing sequence A sequence diagram with six lifelines: the cockpit, ConnectorBackend, the vendored CliPair launcher, the Tailscale CLI, an SSH-driven approval thread, and the remote host. It shows PIN generation, the whois identity check, the concurrent pair request and auto-approval loop, and the queued connect that follows success. CommandDeck FIX HOST / CONNECT ConnectorBackend pairDevice(row) CliPair::Launcher vendored Moonlight tailscale CLI whois --json approval thread QtConcurrent · ssh remote host Sunshine + hostctl 1 · guards: not isSelf · online · !hostCheckPending · hostReachable · no pair launcher already running pairDevice(row) generatePin() four digits, local RNG whois --json <tailscale ip> timeout 5000 ms UserProfile.ID · UserProfile.LoginName compare against m_localTailscaleUserId / m_localTailscaleLoginName → autoApproveEligible 3 · TWO CONVERSATIONS RUN CONCURRENTLY FROM HERE execute(m_pairComputerManager) Moonlight pair request → Sunshine HTTPS signal pairing(pcName, pin) startAutoApprovePairing(device, pin) LOOP · attempt 0 … 59, sleep 500 ms ssh → hostctl approve-pair <pin> <client> host: start runtime if not responding host: sunshine --approve-pair, 10 s timeout break early on permanent failures approve pair accepted → client cert stored in host trust list signal success 4 · m_pairingStatus = "Trusted access is ready." → cleanupPairLauncher() → refresh() if a connect was queued behind this pairing: QTimer::singleShot(1200 ms) → continuePendingConnectIfReady()
Figure 02.1 — pairing sequence, including the auto-approval side channel The vendored launcher is authoritative: Connector never marks a device trusted on the strength of the SSH approval alone. The SSH loop only removes the human from the far end of a ceremony that still completes normally.
The queued connect

Pressing CONNECT on a reachable but untrusted host does not error. connectToDevice() stores m_pendingConnectDeviceId and m_pendingConnectAppName, calls pairDevice(), and lets the pairing success handler resume the connection 1.2 seconds later. If pairing fails, the pending connect is cleared rather than left dangling.

02.D

Session lifecycle

connectorsessionmanager.cpp · connectorstream_main.cpp

On macOS and Linux, shouldUseStreamProcess() returns true unconditionally: the stream always runs in a separate connectorstream process. The two escape hatches are environment-only — CONNECTOR_STREAM_CHILD (already inside the helper) and CONNECTOR_EMBEDDED_STREAM=1. The in-process path still exists and is still compiled; it is simply not the default, because a decoder crash should not take the cockpit with it.

Stream helper lifecycle and stage labels A state machine showing the connectorstream helper process from spawn to exit, with the real stage label strings, the 45 second connect timeout, the 2 second idle guard, and the JSON command and event channels on stdin and stdout. PARENT — ConnectorApp or connectord ConnectorSessionManager startSession(...) shouldUseStreamProcess() → true tryCleanupStaleStreamProcess() ++m_streamProcessGeneration generation guards every async callback against a superseded session ARGUMENT VECTOR --host --device-name --app --quality --display-mode --width --height --absolute-mouse 0|1 --performance-overlay 0|1 --microphone-passthrough 0|1 defaults: Desktop, 1080p, windowed, 1920×1080 INJECTED ENVIRONMENT CONNECTOR_STREAM_CHILD=1 CONNECTOR_EMBEDDED_MOONLIGHT=1 CONNECTOR_SAFE_STREAM_WINDOW=1 CONNECTOR_SAFE_STREAM_MOUSE= absolute-position | relative-delta CONNECTOR_MIC_PORT=48200 only when passthrough is on CHILD — connectorstream, stage labels are the literal setState() strings SPAWNED "Launching Desktop..." SEARCHING "Finding workstation..." PREPARING "Preparing Desktop..." CONNECTING "Connecting to workstation..." ACTIVE "Connected to workstation" APP ALREADY RUNNING ON HOST "Closing Steam on the host..." → quitRunningApp() → retry CONNECT TIMEOUT — armStreamProcessConnectTimeout() 45 000 ms → terminate(), then kill() 2 500 ms later CONTROL CHANNELS — one JSON object per line, both directions stdin → child (handleCommandLine) {"command":"disconnect"} {"command":"release_mouse"} {"command":"toggle_fullscreen"} {"command":"toggle_stats"} {"command":"toggle_mouse"} {"command":"switch_display","display_index":1} stdout → parent (printEvent) {"event":"state","connecting":…,"active":…, "stage":"…","error":"…"} {"event":"started","stage":…} {"event":"ended","stage":…} {"event":"error","error":…} read line-buffered into m_streamProcessBuffer EXIT sessionEnded → QCoreApplication::quit(), queued idle guard: a 1 Hz timer quits the helper if it has been neither connecting nor active for 2 000 ms — a stream helper with no stream is a leak
Figure 02.2 — the connectorstream helper, spawn to exit Every string in the state row is a literal from setState() calls in connectorsessionmanager.cpp; the cockpit's status line and the session.state RPC both surface the same value. The parent never parses the child's window — it parses the child's stdout.
02.E

Driving the helper directly

connectorstream --help

Because the helper is an ordinary QCommandLineParser program that speaks JSON on both pipes, it can be exercised without the cockpit at all. This is the actual option set declared in connectorstream_main.cpp.

connectorstream — direct invocation
$ build/connector-app/connectorstream \
    --host 100.64.0.12 \
    --device-name workstation \
    --app Desktop \
    --quality 1080p \
    --display-mode windowed \
    --width 1920 --height 1080 \
    --absolute-mouse 0 \
    --performance-overlay 0 \
    --microphone-passthrough 1
{"active":false,"connecting":true,"error":"","event":"state","stage":"Finding workstation..."}
{"active":false,"connecting":true,"error":"","event":"state","stage":"Preparing Desktop..."}
{"active":false,"connecting":true,"error":"","event":"state","stage":"Connecting to workstation..."}
{"error":"","event":"started","stage":"Connected to workstation"}
# stdin accepts one command object per line
{"command":"switch_display","display_index":1}
{"command":"toggle_stats"}
{"command":"disconnect"}
{"error":"","event":"ended","stage":"Session ended"}

Exit codes: 2 when the host argument is missing or the session refuses to start, 3 from connectorpair on a pairing timeout. Both helpers print a machine-readable error event before returning.

02.F

Mouse routing contract

connectorcontrolcontracts.cpp

One of only two behaviours in the codebase extracted into a pure function with its own unit test. It exists because absolute desktop mouse input is wrong on a specific, identifiable configuration.

evaluateSessionMouseRouting() — decision table
InputResult
not a desktop sessionpass the preference through unchanged
preference is relativeRelative — "direct desktop mouse mode is disabled in settings"
Linux host, > 1 remote displayRelative — forced
otherwiseAbsolute — "direct desktop mouse mode"
Why the override exists

Verbatim from the source: "Connector is using relative mouse mode because Sunshine can misroute absolute desktop mouse input on Linux multi-display hosts." Rather than shipping a confusing setting, Connector detects the condition, overrides the user's preference, and explains itself in the session detail line.

The companion contract, evaluateDefaultMonitorRequest(), is equally blunt about scope: "Monitor defaults are only configurable on Linux hosts in v1." Both are covered by the connector_control_contracts ctest suite.

02.G

Continue