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 tailscale-rs static archives (~17-44 MB each) — rebuild with
# Tools/build-tailscale-rs.sh; the header/modulemap/patch stay tracked
Vendor/tailscale-rs/lib/

local-plan/

# fastlane — generated reports and local secrets (see fastlane/SETUP.md)
Expand Down
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ 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; tailscale-rs logs to stderr (Xcode console) via `RUST_LOG`.
- `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 (tailscale-rs backend). Works on all three platforms;
/// for v1 it 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
75 changes: 75 additions & 0 deletions Multiplex/Models/TailscaleRSDialAddress.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import Foundation

/// Pure classification of a Host's address for the tailscale-rs dial path.
/// tailscale-rs has no MagicDNS: a literal 100.x/IPv6 is parsed straight to
/// a sockaddr, while a hostname must be resolved to a peer IP via
/// `ts_peer_ipv4_addr` before dialing. This type decides which, and
/// normalizes the string the C layer parses — the actual `ts_*` calls stay
/// in the actor, so this stays module-free and unit-testable.
enum TailscaleRSDialAddress {
enum Target: Equatable {
/// A literal address `ts_parse_ip` can consume directly.
case literalIP(String)
/// A tailnet peer name to resolve via `ts_peer_ipv4_addr`. Any
/// surrounding brackets are stripped — peer lookup wants the bare
/// name.
case peerName(String)
}

static func classify(hostname: String) -> Target {
let trimmed = hostname.trimmingCharacters(in: .whitespaces)
let unbracketed: String
if trimmed.hasPrefix("["), trimmed.hasSuffix("]"), trimmed.count >= 2 {
unbracketed = String(trimmed.dropFirst().dropLast())
} else {
unbracketed = trimmed
}

if isIPv4(unbracketed) || isIPv6(unbracketed) {
return .literalIP(unbracketed)
}
return .peerName(unbracketed)
}

static func isIPv4(_ s: String) -> Bool {
let parts = s.split(separator: ".", omittingEmptySubsequences: false)
guard parts.count == 4 else { return false }
return parts.allSatisfy { part in
part.count >= 1 && part.count <= 3
&& part.allSatisfy(\.isNumber)
&& (Int(part).map { $0 >= 0 && $0 <= 255 } ?? false)
}
}

static func isIPv6(_ s: String) -> Bool {
// Loose but sufficient to tell a v6 literal from a hostname: hex
// groups and at least one colon, no characters a DNS/tailnet name
// would carry. `ts_parse_ip` is the real validator downstream.
guard s.contains(":") else { return false }
let allowed = CharacterSet(charactersIn: "0123456789abcdefABCDEF:.")
return s.unicodeScalars.allSatisfy { allowed.contains($0) }
}
}

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
18 changes: 18 additions & 0 deletions Multiplex/Services/KeychainStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,15 @@ enum KeychainStore {
case password
case privateKey
case keyPassphrase
/// The app-wide Tailscale auth key (tailscale-rs backend), stored
/// under a fixed namespace UUID rather than a real host.
case tailscaleAuthKey
/// The app-generated tailnet node identity: 96 bytes
/// (node ‖ machine ‖ network-lock, 32 each). tailscale-rs takes the
/// key state as an input and never exports it, so the app owns and
/// persists it here — this is why no plaintext state directory is
/// needed. Under the same fixed namespace UUID as the auth key.
case tailscaleKeyState
}

private static func account(_ hostID: UUID, _ kind: Kind) -> String {
Expand All @@ -41,6 +50,15 @@ enum KeychainStore {
deleteItem(service: secretService, account: account(hostID, kind))
}

/// Binary-secret accessors for the tailnet node identity (not UTF-8).
static func setData(_ value: Data, for hostID: UUID, kind: Kind) {
setItem(value, service: secretService, account: account(hostID, kind))
}

static func getData(for hostID: UUID, kind: Kind) -> Data? {
getItem(service: secretService, account: account(hostID, kind))
}

static func delete(for hostID: UUID) {
for kind in [Kind.password, .privateKey, .keyPassphrase] {
deleteItem(service: secretService, account: account(hostID, kind))
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:
"The Tailscale backend isn't available in this build."
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(CTailscaleRS)
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(CTailscaleRS)
let remote = try await TailscaleTunnel.shared.dial(
hostname: host.hostname,
port: host.port
)
// Citadel's channel-injection overload asserts
// inEventLoop in its synchronous prefix, so the tailnet
// connection 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: remote)
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
Loading