01 TAILNET RAIL // Discovery & readiness SRC connectorbackend.cpp · devicemodel.h
01.A

Discovery has exactly one source

connectorbackend.cpp:4124

The tailnet is the device list

Connector does not run mDNS discovery, does not maintain a server registry, and does not ask you to type IP addresses. It runs one subprocess — tailscale status --json — and treats the resulting Self and Peer objects as the complete population of machines it is allowed to think about.

If the Tailscale CLI cannot be found at all, refreshDevices() short-circuits: it clears local tailnet state, empties the device model, and sets the activity line to "Tailscale CLI not found. Device discovery is unavailable." There is no fallback discovery path. That is a design choice, not an omission — the tailnet is also the security boundary.

connector-app/src/connectorbackend.cpp refreshDevices() lines 4124–4146
4124void ConnectorBackend::refreshDevices() {
4125  ++m_deviceRefreshGeneration;
4126  m_deviceConnectivityRetryCounts.clear();
4127  if (m_tailscalePath.isEmpty()) {
4128    resetLocalTailscaleState();
4129    m_deviceModel.setDevices({});
4130    refreshThisDevice();
4131    setActivityMessage("Tailscale CLI not found. Device discovery is unavailable.");
4132    emit commandPathsChanged();
4133    return;
4134  }
       /* ... kill any in-flight refresh ... */
4142  m_refreshProcess->setProgram(m_tailscalePath);
4143  m_refreshProcess->setArguments({"status", "--json"});
4144  m_refreshProcess->start();
4145  setActivityMessage("Refreshing devices...");
4146}
The m_deviceRefreshGeneration counter is the concurrency guard. Every async probe launched during a refresh captures the generation it belongs to and silently drops its result if the generation has moved on. That is how the model avoids applying stale probe answers from a previous tailnet snapshot.
01.B

What the CLI lookup accepts

shared/src/commandpaths.cpp

CommandPaths::tailscale() returns the first candidate that exists and is executable, checking absolute paths directly and everything else through QStandardPaths::findExecutable.

  1. 1 /Applications/Tailscale.app/Contents/MacOS/Tailscale macOS only
  2. 2 tailscale on PATH
  3. 3 Tailscale on PATH

The same helper resolves the Sunshine binary and config path, which is where the macOS bundle's embedded host runtime is found: Helpers/ConnectorHost.app/Contents/MacOS/Sunshine inside the app bundle, falling back to ~/.local/bin/sunshine and /usr/local/bin/sunshine on Linux.

01.C

Tailnet topology as Connector sees it

applyTailscaleStatus()

Every node in the tailnet becomes a DeviceRecord, but they are not equal. The Self node is marked isSelf and is deliberately excluded from streaming and pairing — "Connector does not prepare remote trusted access or stream into the current machine in the locked v1 scope." Peers get a route of "Tailscale"; manually added endpoints carry manual = true and render as MANUAL in the rail.

Connector's view of a tailnet A control node running Connector sits at the centre of a WireGuard mesh. Four peer machines are shown with their Tailscale IPs and node IDs. Each peer is annotated with which of the four readiness probes apply to it, and the self node is marked as excluded from streaming. TAILNET — ONE ACCOUNT, WIREGUARD MESH, NO INBOUND PORTS CONTROL NODE — Self record.route = "This device" record.isSelf = true Connector.app · connectord · connector-mcp excluded from pair + connect by design linux-host / oldlinux 100.64.0.11 node nZ55Q5PqHN11CNTRL host runtime answering shell automation ready workstation / linux-host 100.64.0.12 node nbYE3LgXU411CNTRL host runtime answering no trust record yet zflow 100.64.0.13 node n5XYmDgJcq11CNTRL SSH ready, runtime silent candidate for auto host start ubuntu-wdc 100.64.0.14 node nRkdN9tjZd11CNTRL Online = false all probes skipped GET :47989/serverinfo + ssh probe GET :47989/serverinfo + ssh probe ssh probe only peer present, no traffic PER-PEER PROBE SET 1 · Online flag straight from status --json 2 · HTTP GET :47989/serverinfo, 2500 ms 3 · non-interactive shell probe, 4000 ms 4 · stored Moonlight host record lookup retried up to 3× per refresh generation Peer names, addresses and node ids above are the resolver hints committed in scripts/connector-mcp.py lines 128–136.
Figure 01.1 — tailnet topology and per-peer probes Peers are not polled uniformly. An offline peer costs nothing; an online peer with a silent host runtime still gets a shell probe, because SSH is exactly how Connector plans to fix it. The four probes populate four independent booleans on the device record, and only their conjunction produces a READY light.

"Do not treat Tailscale reachability as proof that remote desktop streaming is ready. SSH readiness and Connector streaming readiness are separate."

CONNECTOR_MCP_CONTEXT.md — When to use this MCP
01.D

From JSON to device record to lamp

devicemodel.h · CommandDeck.qml

The pipeline below is the whole of device state. Twenty-seven roles come out of DeviceModel::roleNames(); the QML rail reads about a dozen of them, and the RPC surface serialises all of them verbatim through QJsonObject::fromVariantMap.

Discovery and readiness pipeline Tailscale status JSON fields flow into DeviceRecord fields, which are then updated by four asynchronous probes, and finally reduced by the visualState function into one of five lamp colours. STAGE 1 — RAW STATUS JSON tailscale status --json BackendState AuthURL Self.ID · HostName · OS Self.TailscaleIPs[0] Self.DNSName · UserID Self.Online Peer{...}.ID · HostName Peer{...}.TailscaleIPs Peer{...}.Online · OS User{userId}.LoginName STAGE 2 — DEVICERECORD, SYNCHRONOUS FIELDS DeviceRecord id · name · os ip · dnsName route "Tailscale" | "This device" online · isSelf · manual favorite sshMode · sshModeDefault sshModeExplicit · sshUser sshTarget · sshAvailable defaultApp = "Desktop" alias override from m_deviceAliases STAGE 3 — ASYNCHRONOUS PROBES QNetworkAccessManager GET http://<addr>:47989/serverinfo transferTimeout 2500 ms → hostReachable, hostCheckPending QtConcurrent probeShellAutomationSync(device, 4000) → sshReady, sshStatusText hasStoredMoonlightHostForDevice(device) reads the imported Moonlight hosts array → clientTrusted advancedReady = hostReachable && sshReady STAGE 4 — REDUCTION IN visualState(deviceValue), CommandDeck.qml !deviceValue.name → "idle" #8c9b94 · nothing selected in the rail !deviceValue.online → "blocked" #ff7c7c · present in the tailnet but not up hostReachable && clientTrusted && advancedReady → "ready" #42e59a · the only state where CONNECT is enabled sshReady && !hostReachable → "ssh" #6cd9ff · you can fix it from here without leaving your chair hostReachable || hostCheckPending || sshAvailable → "setup" #ffd58c · a path exists, something is still missing CONNECT GATE — CommandDeck.qml canConnect hasDevice && device.online && device.hostReachable && !device.isSelf && backend && !backend.sessionActive && !backend.sessionConnecting && !backend.connectionAttemptBusy
Figure 01.2 — the readiness pipeline, top to bottom Note the asymmetry in stage 4: clientTrusted is required for the green lamp but not for the CONNECT button. That is deliberate — pressing CONNECT on an untrusted-but-reachable host starts pairing first and queues the connection behind it, which is the sequence documented on the next page.
01.E

Reading the rail from a shell

connectorctl devices list

The device model is not a private structure. Every field is serialised straight onto the control socket, so the CLI view and the QML view are literally the same data.

connectorctl — device record
$ build/connector-app/connectorctl devices get nZ55Q5PqHN11CNTRL
{
    "advancedReady": true,
    "apps": [
        {
            "hdr": false,
            "id": 1,
            "name": "Desktop"
        }
    ],
    "clientTrusted": true,
    "defaultApp": "Desktop",
    "deviceId": "nZ55Q5PqHN11CNTRL",
    "dnsName": "linux-host",
    "favorite": false,
    "hostCheckPending": false,
    "hostReachable": true,
    "ip": "100.64.0.11",
    "isSelf": false,
    "manual": false,
    "name": "linux-host",
    "online": true,
    "os": "linux",
    "route": "Tailscale",
    "sshAvailable": true,
    "sshMode": "tailscale",
    "sshModeDefault": "tailscale",
    "sshModeExplicit": true,
    "sshReady": true,
    "sshTarget": "100.64.0.11",
    "sshUser": "ian",
    "state": "Online",
    "statusText": "Ready to connect"
}

Field names above are exactly the twenty-seven role names declared in DeviceModel::roleNames(); the id role is exposed as deviceId, which is why RPC parameters use device_id while the record itself carries deviceId.

01.F

SSH profile resolution

connectordaemonbackend.cpp

Each device carries a shell profile with three modes. auto is not a runtime probe — it resolves to the device's explicit mode if one was set, otherwise to plain ssh.

Modes accepted by devices.set_ssh_profile and ssh.prepare
ModeProgramArgument shape
tailscale Tailscale CLI ssh <user@host> -- sh -lc <cmd>
ssh system ssh hardened option set, then <user@host> <cmd>
auto resolved falls through to the device's explicit mode, else ssh

Hardened option set

  • BatchMode=yes
  • NumberOfPasswordPrompts=0
  • KbdInteractiveAuthentication=no
  • PasswordAuthentication=no
  • PreferredAuthentications=publickey
  • ConnectTimeout=5
  • StrictHostKeyChecking=accept-new
  • UseKeychain=no macOS
  • UserKnownHostsFile=<connector known_hosts>
Non-interactive by construction

Password authentication is disabled outright and prompts are capped at zero. If a key is not already installed, the probe fails fast rather than hanging a background thread on a TTY that does not exist.

01.G

Name resolution for humans and agents

scripts/connector-mcp.py:186

Node ids like nZ55Q5PqHN11CNTRL are correct and unusable. The MCP layer therefore ships a scoring resolver that accepts an id, a hostname, an IP, a DNS name, an SSH target, an SSH user, the leading label of a DNS name, or a parenthesised alias inside a device name — and then breaks ties using live readiness.

score_device() — match tiers and readiness bonuses, scripts/connector-mcp.py lines 186–235
ConditionScoreReason string
Exact device id (checked before scoring)1100exact device id
Raw or normalised equality1000exact match on …
Curated alias hit900alias match via …
Normalised prefix760prefix match on …
Normalised substring680contains match on …
All query words present560word match on …
Bonus · sshReady+25
Bonus · hostReachable+15
Bonus · online+10

Anything within twenty points of the winner counts as a close match. If more than one candidate lands in that band and the caller did not pass allow_ambiguous, the resolver raises rather than guessing — and the exception text lists up to five colliding devices by name and id. An agent that guesses which machine to reboot is worse than an agent that asks.

On the committed alias table

DEVICE_ALIAS_HINTS maps short names like desktop2, miso and zflow onto hostnames, tailnet IPs and node ids. The source comments it honestly: "These are only resolver hints. Connector live device state still wins." A hint can only raise a candidate's score against devices that actually exist in the current devices.list response — it can never manufacture a device.

01.H

Continue