Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ xcuserdata/
# Dev harness artifacts (generated keys, sshd runtime state)
Tools/dev-sshd/state/

# Vendored libtailscale static archives (~27 MB each) — rebuild with
# Tools/build-libtailscale.sh; the header/modulemap stay tracked
Vendor/libtailscale/lib/

local-plan/

# fastlane — generated reports and local secrets (see fastlane/SETUP.md)
Expand Down
9 changes: 9 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,15 @@ vars to drive the real SSH→PTY→tmux→SwiftTerm path headlessly:
agent:cc 'Select login method:' Enter` — cat echoes it) into the
harness's fake Claude pane and the rail tip renders without a genuinely
locked keychain.
- `MULTIPLEX_SEED_TAILSCALE=1` — flips the seeded host to Connect via
Tailscale (mosh off), for headless checks of the tailscale connect seam.
- `MULTIPLEX_TAILSCALE_FAKE_DIAL=1` — tailscale dials become plain TCP
sockets (no tsnet node), so the loopback-relay → Citadel path can be
proven end-to-end against the harness sshd without a tailnet.
- `MULTIPLEX_TAILSCALE_AUTHKEY=tskey-…` — overrides the stored Tailscale
auth key (never persisted), for driving a real embedded-node login
headlessly; tsnet's own log lines land in the unified log under
category `tailscale` (debug level — `log stream`).
- `MULTIPLEX_APP_LOCK=1|held` — starts this launch behind the app-lock veil
regardless of the persisted setting (never persisted, like
`MULTIPLEX_PRO_LOCKED`). `1` keeps the real authenticator — the veil's
Expand Down
5 changes: 5 additions & 0 deletions Multiplex/Models/Host.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ struct Host: Identifiable, Codable, Hashable {
/// The credentials above still authenticate the SSH bootstrap that
/// launches `mosh-server`; deck probing stays on SSH either way.
var useMosh: Bool = false
/// Reach this host's SSH endpoint through the app's embedded userspace
/// Tailscale node. The node is unavailable on visionOS and cannot carry
/// mosh's datagram transport.
var useTailscale: Bool = false
/// Absolute path to `mosh-server` when it isn't on the exec PATH.
var moshServerPath: String?
/// UDP port or range ("60000:61000") handed to `mosh-server -p`.
Expand Down Expand Up @@ -80,6 +84,7 @@ extension Host {
username = try container.decode(String.self, forKey: .username)
authMethod = try container.decodeIfPresent(AuthMethod.self, forKey: .authMethod) ?? .password
useMosh = try container.decodeIfPresent(Bool.self, forKey: .useMosh) ?? false
useTailscale = try container.decodeIfPresent(Bool.self, forKey: .useTailscale) ?? false
moshServerPath = try container.decodeIfPresent(String.self, forKey: .moshServerPath)
moshPorts = try container.decodeIfPresent(String.self, forKey: .moshPorts)
workingDirs = try container.decodeIfPresent([String].self, forKey: .workingDirs) ?? []
Expand Down
38 changes: 38 additions & 0 deletions Multiplex/Models/TailscaleDialAddress.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import Foundation

enum TailscaleDialAddress {
static func format(hostname: String, port: Int) -> String {
let formattedHostname: String
if hostname.hasPrefix("[") && hostname.hasSuffix("]") {
formattedHostname = hostname
} else if hostname.contains(":") {
formattedHostname = "[\(hostname)]"
} else {
formattedHostname = hostname
}
return "\(formattedHostname):\(port)"
}
}

enum TailscaleNodeHostname {
static func format(deviceName: String) -> String {
var sanitized = ""
var needsSeparator = false

for scalar in deviceName.lowercased().unicodeScalars {
let isLowercaseLetter = scalar.value >= 97 && scalar.value <= 122
let isDigit = scalar.value >= 48 && scalar.value <= 57
if isLowercaseLetter || isDigit {
if needsSeparator, !sanitized.isEmpty {
sanitized.append("-")
}
sanitized.unicodeScalars.append(scalar)
needsSeparator = false
} else if !sanitized.isEmpty {
needsSeparator = true
}
}

return sanitized.isEmpty ? "multiplex" : "multiplex-\(sanitized)"
}
}
7 changes: 7 additions & 0 deletions Multiplex/Services/HostStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,13 @@ final class HostStore {
// Absent mosh keys leave the host's current setting alone, so a
// hand-trimmed seed doesn't silently flip transports.
if let useMosh = seed.useMosh { host.useMosh = useMosh }
// Headless tailscale-seam checks flip the seeded host without
// touching the shared seed.json (pairs with
// MULTIPLEX_TAILSCALE_FAKE_DIAL).
if ProcessInfo.processInfo.environment["MULTIPLEX_SEED_TAILSCALE"] == "1" {
host.useTailscale = true
host.useMosh = false
}
if let path = seed.moshServerPath { host.moshServerPath = path }
if let ports = seed.moshPorts { host.moshPorts = ports }
// Optional so existing seeds leave the host's dirs alone; used by
Expand Down
2 changes: 2 additions & 0 deletions Multiplex/Services/HostTest.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ enum HostTest {
return "That passphrase didn't unlock the private key. Try again."
case .unsupportedKey:
return "The private key couldn't be read. Paste an OpenSSH ed25519 or RSA key, including its BEGIN/END lines."
case .tailscaleUnavailable:
return ssh.userMessage(host: host)
case .connectFailed(let detail):
return connectFailureMessage(detail, host: host)
case .notConnected:
Expand Down
4 changes: 3 additions & 1 deletion Multiplex/Services/KeychainStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@ import Security

/// Minimal Keychain wrapper. Every item is written as *synchronizable*, so
/// iCloud Keychain carries it to the user's other devices — end-to-end
/// encrypted, no entitlement or CloudKit container required. Two item
/// encrypted, no entitlement or CloudKit container required. Three item
/// families share the same primitives:
/// - per-host secrets (password / private key / passphrase)
/// - the app-wide Tailscale auth key, under a fixed namespace UUID
/// - mirrored host records: the non-secret `Host` JSON, one item per host,
/// which is how the host list itself crosses devices
///
Expand All @@ -20,6 +21,7 @@ enum KeychainStore {
case password
case privateKey
case keyPassphrase
case tailscaleAuthKey
}

private static func account(_ hostID: UUID, _ kind: Kind) -> String {
Expand Down
6 changes: 6 additions & 0 deletions Multiplex/Services/Mosh/MoshBootstrap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ import Foundation

enum MoshBootstrapError: Error {
case dnsFailure
case tailscaleIncompatible
case sshFailed(String)
case serverFailed(String)

func userMessage(host: Host) -> String {
switch self {
case .dnsFailure:
"Couldn't resolve \(host.hostname)."
case .tailscaleIncompatible:
"mosh can't run over the embedded Tailscale connection — turn one of them off."
case .sshFailed(let detail):
"Couldn't reach \(host.name) to start mosh (\(detail))."
case .serverFailed(let detail):
Expand Down Expand Up @@ -138,6 +141,9 @@ enum MoshBootstrap {
// MARK: - The bootstrap itself

static func start(host: Host, secrets: HostSecrets, remoteCommand: String?) async throws -> Target {
guard !(host.useTailscale && host.useMosh) else {
throw MoshBootstrapError.tailscaleIncompatible
}
let addresses = resolve(host.hostname)
guard !addresses.isEmpty else { throw MoshBootstrapError.dnsFailure }

Expand Down
45 changes: 43 additions & 2 deletions Multiplex/Services/SSHConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,14 +158,17 @@ enum SSHConnectionError: Error {
case keyPassphraseRequired
case incorrectKeyPassphrase
case unsupportedKey
case tailscaleUnavailable
case connectFailed(String)
case notConnected

var keyPassphraseReason: SSHKeyPassphraseChallenge.Reason? {
switch self {
case .keyPassphraseRequired: .required
case .incorrectKeyPassphrase: .incorrect
case .missingCredentials, .unsupportedKey, .connectFailed, .notConnected: nil
case .missingCredentials, .unsupportedKey, .tailscaleUnavailable,
.connectFailed, .notConnected:
nil
}
}

Expand All @@ -179,6 +182,8 @@ enum SSHConnectionError: Error {
"The passphrase didn't unlock the private key for \(host.name). Try again."
case .unsupportedKey:
"The private key for \(host.name) couldn't be read. Paste an OpenSSH ed25519 or RSA key."
case .tailscaleUnavailable:
"Tailscale connections aren't available on this device (Vision Pro)."
case .connectFailed(let detail):
"Couldn't reach \(host.name) (\(detail))."
case .notConnected:
Expand Down Expand Up @@ -270,11 +275,47 @@ actor SSHConnection {
task = inFlight
generation = connectGeneration
} else {
#if !canImport(CLibTailscale)
if host.useTailscale {
throw SSHConnectionError.tailscaleUnavailable
}
#endif
let method = try Self.makeAuthenticationMethod(host: host, secrets: secrets)
connectGeneration &+= 1
generation = connectGeneration
task = Task {
try await SSHClient.connect(
if host.useTailscale {
#if canImport(CLibTailscale)
let descriptor = try await TailscaleTunnel.shared.dial(
hostname: host.hostname,
port: host.port
)
// Citadel's channel-injection overload asserts
// inEventLoop in its synchronous prefix, so the dialed
// fd is spliced through a one-shot localhost relay and
// Citadel dials it via its ordinary bootstrap. The
// relay tears itself down when either side closes, so
// the client's own close() is its lifetime owner.
let relay = TailscaleLoopbackRelay()
let relayPort = try relay.start(spliceTo: descriptor)
do {
return try await SSHClient.connect(
host: "127.0.0.1",
port: Int(relayPort),
authenticationMethod: method,
hostKeyValidator: .acceptAnything(),
reconnect: .never
)
} catch {
relay.close()
throw error
}
#else
throw SSHConnectionError.tailscaleUnavailable
#endif
}

return try await SSHClient.connect(
host: host.hostname,
port: host.port,
authenticationMethod: method,
Expand Down
41 changes: 41 additions & 0 deletions Multiplex/Services/Tailscale/TailscaleForceLogin.c
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// tsnet only consumes a configured auth key inside its
// StartLoginInteractive branch, and reaches that branch when the backend
// state is NeedsLogin OR the TSNET_FORCE_LOGIN env knob is set. A fresh
// state store reads as NoState at that check, so a first-ever login with
// an auth key is silently skipped ("Authkey is set; but state is NoState.
// Ignoring authkey." — tsnet v1.94.1, tsnet.go:763-770, observed on
// device 2026-07-23).
//
// The knob must be in the environment BEFORE the Go runtime captures it,
// which happens in the libtailscale archive's own load-time constructor —
// a setenv from Swift is too late. This constructor is compiled into the
// app's object files, which the linker places ahead of OTHER_LDFLAGS
// libraries, so its mod_init_func entry runs first.
//
// The knob is set ONLY while the tsnet state file does not exist: forcing
// it unconditionally would make every tailscale_up on an already-enrolled
// node re-run interactive login (env values are frozen for the process
// lifetime once Go captures them). On visionOS the file compiles and the
// state path never exists, but nothing links or reads the knob — inert.
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

__attribute__((constructor))
static void multiplex_tsnet_force_login(void) {
const char *home = getenv("HOME");
if (home == NULL) {
return;
}
char path[1024];
int written = snprintf(
path, sizeof path,
"%s/Library/Application Support/tailscale-node/tailscaled.state",
home);
if (written <= 0 || (size_t)written >= sizeof path) {
return;
}
if (access(path, F_OK) != 0) {
setenv("TSNET_FORCE_LOGIN", "1", 1);
}
}
Loading