feat: add Pro Tailscale SSH and SFTP integration

This commit is contained in:
Kaede Akino committed 2026-10-03 03:12:12 +08:00
1 parent f9980baaf8
commit 6eafa29369
85 files changed
+4698 -331

No files matched your search

+4
View File
@@ -8,3 +8,7 @@
path = libghostty-spm
url = https://github.com/AkinoKaede/libghostty-spm.git
branch = termind
[submodule "libtailscale-spm"]
path = libtailscale-spm
url = https://github.com/AkinoKaede/libtailscale-spm.git
branch = main
@@ -44,6 +44,27 @@ struct SSHKeyboardInteractiveQueueTests {
#expect(queue.pending.isEmpty)
}
@Test func tailscaleApprovalWaitsInTheJourneyAndCancelsWithItsConnection() async throws {
let controller = RemoteSessionConnectionController()
var challenge = challenge()
challenge.prompts = []
challenge.isTailscaleApproval = true
challenge.instruction = "Approve at https://login.example/ssh/check"
let host = Host(id: challenge.hostID, name: "Host", host: challenge.hostname)
controller.journey.begin(host: host, route: [host], purpose: .sshSession, username: challenge.username)
let task = Task { try await controller.answerKeyboardInteractive(challenge) }
defer { task.cancel() }
await wait(controller.keyboardAuthentication, count: 1)
for _ in 0..<10 { await Task.yield() }
#expect(controller.keyboardAuthentication.current?.id == challenge.id)
#expect(controller.journey.phase == .waiting)
#expect(challenge.tailscaleApprovalURL?.absoluteString == "https://login.example/ssh/check")
#expect(!controller.journey.plainTextLog.contains(challenge.instruction))
controller.disconnect()
await #expect(throws: CancellationError.self) { try await task.value }
#expect(controller.keyboardAuthentication.pending.isEmpty)
}
@Test func disconnectCancelsTheJourneyWithoutLoggingChallenges() async throws {
let controller = RemoteSessionConnectionController()
let challenge = challenge()
@@ -0,0 +1,598 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
import TermindPersistence
import TermindRemoteInfrastructure
import Testing
import struct TermindDomain.Host
@Suite @MainActor
struct TailscaleIntegrationTests {
@Test func lateMetricsSyncCannotRestoreAnExplicitlyDeletedOrdinaryHost() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let hostID = UUID()
let tombstone = VaultEnvelope(
key: VaultRecordKey(kind: .host, id: hostID),
version: .baseline(writerID: UUID()), isDeleted: true)
try VaultSyncJournal(context: container.mainContext).acceptRemote([tombstone])
try container.mainContext.save()
let metrics = MetricsSyncPersistence(context: container.mainContext)
let payload = MetricsRecordPayload.selection(
SyncedMetricsSelection(selection: MetricsSelection(id: hostID), sortIndex: 0))
let result = try metrics.applyRemote([
MetricsEnvelope(key: payload.key, version: .baseline(writerID: UUID()), payload: payload)
])
#expect(result.snapshot.selections.isEmpty)
#expect(result.needsUpload.contains(payload.key))
#expect(try metrics.journal.envelope(for: payload.key)?.isDeleted == true)
}
@Test func losingProBlocksRetainedDialersWithoutClosingExistingNodes() async throws {
let group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
let store = InMemoryTailscaleLocalStore()
try store.save(TailscaleLocalState(groupID: group.id, profileID: UUID()))
let node = TestTailscaleNode(network: try #require(group.tailscale?.network))
let access = TailscaleTestAccess()
let model = TailscaleModel(
library: library, store: store, factory: { _, _, _, _ in node },
hostname: { "Existing device name" }, hasAccess: { access.allowed })
model.start()
for _ in 0..<100 where !model.runningGroups.contains(group.id) {
try await Task.sleep(for: .milliseconds(10))
}
#expect(model.runningGroups.contains(group.id))
let retained = try model.connection(for: group.id)
access.allowed = false
model.reconcile()
await #expect(throws: TailscaleConfigurationError.self) {
try await retained.open(host: "server.example.ts.net", port: 22, timeout: 1)
}
#expect(await node.closed == false)
await model.shutdown()
#expect(await node.closed)
}
@Test func previousStoreMigratesGroupsWithoutChangingOrdinaryHosts() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Termind.store")
let ordinaryGroup = HostGroup(name: "Existing")
let ordinaryHost = Host(name: "Existing", host: "example.invalid", groupID: ordinaryGroup.id)
try writePreviousStore(url, group: ordinaryGroup, host: ordinaryHost)
for _ in 0..<2 {
let container = try TermindPersistence.makeContainer(url: url)
let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext))
#expect(library.host(ordinaryHost.id)?.host == ordinaryHost.host)
#expect(library.group(ordinaryGroup.id)?.name == ordinaryGroup.name)
#expect(library.group(ordinaryGroup.id)?.tailscale == nil)
}
}
@Test func existingTailnetStoreMigratesWithoutLosingEnrollmentOrPreferences() throws {
let directory = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: directory) }
let url = directory.appendingPathComponent("Termind.store")
var group = try group("example.ts.net")
let network = try #require(group.tailscale?.network)
let hostID = network.hostID(stableNodeID: "n123")
var preferences = TailscaleHostPreferences()
preferences.username = "deploy"
preferences.tags = ["production"]
preferences.colorTag = .purple
group.tailscale?.hostPreferences[hostID] = preferences
let profileID = UUID()
let peer = peer()
try writeTailnetStore(url, group: group, profileID: profileID, peer: peer)
for pass in 0..<2 {
let container = try TermindPersistence.makeContainer(url: url)
let localStore = SwiftDataTailscaleLocalStore(context: container.mainContext)
var state = try #require(localStore.load().first)
#expect(state.groupID == group.id && state.profileID == profileID)
#expect(state.peers == [peer])
#expect(state.hostname == (pass == 0 ? nil : "saved-device"))
let library = HostLibraryModel(persistence: HostPersistence(context: container.mainContext))
#expect(library.group(group.id)?.tailscale?.hostPreferences[hostID] == preferences)
state.hostname = "saved-device"
try localStore.save(state)
}
}
private func writeTailnetStore(_ url: URL, group: HostGroup, profileID: UUID, peer: TailscalePeer) throws {
let schema = Schema(versionedSchema: TermindSchemaV47.self)
let container = try ModelContainer(
for: schema,
configurations: [
ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)
])
container.mainContext.insert(PersistentHostGroup(group))
container.mainContext.insert(
try PersistentTailscaleGroup(id: group.id, configuration: #require(group.tailscale)))
container.mainContext.insert(
TermindSchemaV47TailscaleModels.PersistentTailscaleLocalState(
groupID: group.id, profileID: profileID, peersData: try JSONEncoder().encode([peer])))
try container.mainContext.save()
}
private func writePreviousStore(_ url: URL, group: HostGroup, host: Host) throws {
let schema = Schema(versionedSchema: TermindSchemaV46.self)
let container = try ModelContainer(
for: schema,
configurations: [
ModelConfiguration("Termind", schema: schema, url: url, cloudKitDatabase: .none)
])
container.mainContext.insert(PersistentHostGroup(group))
container.mainContext.insert(PersistentHost(host))
try container.mainContext.save()
}
private func group(_ suffix: String, parentID: UUID? = nil) throws -> HostGroup {
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: suffix)
var group = HostGroup(id: network.groupID, name: suffix, parentID: parentID)
group.tailscale = TailscaleGroupConfiguration(network: network)
group.tailscale?.username = "operator"
return group
}
@Test func canonicalControlIdentitySurvivesNodeAndGroupRenames() throws {
let first = try TailscaleNetwork(controlURL: "https://LOGIN.TAILSCALE.COM:443/", dnsSuffix: "Example.TS.NET.")
let second = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
#expect(first == second)
#expect(first.hostID(stableNodeID: "n123") == second.hostID(stableNodeID: "n123"))
let otherTailnet = try TailscaleNetwork(controlURL: first.controlURL, dnsSuffix: "other.ts.net")
#expect(first.hostID(stableNodeID: "n123") == otherTailnet.hostID(stableNodeID: "n123"))
let headscale = try TailscaleNetwork(controlURL: "https://headscale.example.com", dnsSuffix: first.dnsSuffix)
#expect(first.hostID(stableNodeID: "n123") != headscale.hostID(stableNodeID: "n123"))
#expect(throws: TailscaleConfigurationError.self) {
try TailscaleNetwork(controlURL: "https://user:secret@example.com", dnsSuffix: "example.com")
}
}
@Test func subgroupOverridesNearestParentAndMovingChangesNewConnections() throws {
let parent = try group("parent.ts.net")
var child = HostGroup(name: "Child", parentID: parent.id)
let leaf = HostGroup(name: "Leaf", parentID: child.id)
let host = Host(name: "Manual", host: "server", groupID: leaf.id)
let library = HostLibraryModel(hosts: [host], groups: [parent, child, leaf])
#expect(library.tailscaleGroup(for: host)?.id == parent.id)
child.tailscale = try group("child.ts.net").tailscale
library.upsert(child)
#expect(library.tailscaleGroup(for: host)?.id == child.id)
child.tailscale = nil
child.parentID = nil
library.upsert(child)
#expect(library.tailscaleGroup(for: host) == nil)
child.parentID = parent.id
library.upsert(child)
#expect(throws: TailscaleConfigurationError.self) { try library.tailscaleNetwork(for: host) }
}
@Test func additionalOrdinaryConnectionsFollowTheHostsCurrentGroup() throws {
let group = try group("example.ts.net")
let host = Host(name: "Manual", host: "server", groupID: group.id)
let library = HostLibraryModel(hosts: [host], groups: [group])
let provider = TestTailscaleProvider()
library.tailscaleConnections = provider
var configuration = SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: 22, username: "operator", credentials: [], expectedHostKeys: [],
authenticationReference: SSHAuthenticationReference(username: "operator", permitsNone: true))
configuration.network = try library.tailscaleNetwork(for: host)
let snapshot = library.additionalConnectionSnapshot(from: configuration)
#expect(try library.additionalConnectionRequest(from: snapshot).configuration.network != nil)
library.move(hostIDs: [host.id], into: nil)
#expect(try library.additionalConnectionRequest(from: snapshot).configuration.network == nil)
#expect(try library.tailscaleNetwork(for: host) == nil)
}
@Test func duplicateTailnetIsRejectedIncludingAnExplicitSubgroup() throws {
let parent = try group("example.ts.net")
let library = HostLibraryModel(groups: [parent])
var duplicate = HostGroup(name: "Duplicate", parentID: parent.id)
duplicate.tailscale = parent.tailscale
library.upsert(duplicate)
#expect(library.group(duplicate.id) == nil)
#expect(library.storageError != nil)
}
@Test func discoveredHostsStayLocalAndPreferencesRoundTripThroughGroup() throws {
let container = try TermindPersistence.makeContainer(inMemory: true)
let persistence = HostPersistence(context: container.mainContext)
let library = HostLibraryModel(persistence: persistence)
let group = try group("example.ts.net")
library.upsert(group)
library.replaceTailscalePeers([peer()], groupID: group.id)
let managed = try #require(library.hosts.first)
#expect(managed.host == "server.example.ts.net")
#expect(library.saveTailscaleUsername("root", hostID: managed.id))
library.projectHostDeletion(managed.id)
#expect(library.host(managed.id) != nil)
var forged = managed
forged.host = "attacker.invalid"
#expect(!library.saveHost(forged))
let reloaded = HostLibraryModel(persistence: persistence)
#expect(reloaded.hosts.isEmpty)
reloaded.replaceTailscalePeers([peer(name: "Renamed", online: false)], groupID: group.id)
#expect(reloaded.hosts.first?.id == managed.id)
#expect(reloaded.tailscaleUsername(for: managed) == "root")
#expect(!reloaded.isEligible(managed, for: .keyExport))
#expect(!reloaded.isEligible(managed, for: .jumpHost))
}
@Test func managedPreparationAndExtraConnectionsNeverResolveOrdinaryCredentials() async throws {
let group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
let provider = TestTailscaleProvider()
library.tailscaleConnections = provider
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.hosts.first)
let preparation = try library.prepareSSHConnection(for: host, submissions: [:], proxySubmissions: [:])
#expect(preparation.nodes.first?.identity == nil)
#expect(preparation.nodes.first?.key == nil)
let configuration = try await preparation.resolve()
#expect(configuration.credentials.isEmpty)
#expect(configuration.expectedHostKeys.isEmpty)
#expect(configuration.tailscaleNodeID == "n123")
#expect(configuration.network != nil)
let snapshot = library.additionalConnectionSnapshot(from: configuration)
let extra = try library.additionalConnectionRequest(from: snapshot)
#expect(extra.configuration.credentials.isEmpty)
#expect(extra.configuration.network != nil)
#expect(extra.configuration.tailscaleNodeID == "n123")
provider.allowed = false
#expect(throws: TailscaleConfigurationError.self) { try library.additionalConnectionRequest(from: snapshot) }
}
@Test func tagsAndColorSyncBeforeAnotherDeviceDiscoversTheHost() throws {
let source = try TermindPersistence.makeContainer(inMemory: true)
let sourceLibrary = HostLibraryModel(persistence: HostPersistence(context: source.mainContext))
let group = try group("example.ts.net")
sourceLibrary.upsert(group)
sourceLibrary.replaceTailscalePeers([peer()], groupID: group.id)
var host = try #require(sourceLibrary.hosts.first)
host.tags = ["production", "linux"]
host.colorTag = .blue
#expect(sourceLibrary.saveHost(host))
sourceLibrary.recordRemoteSystemInfo(
RemoteSystemInfo(distributionID: "ubuntu", distributionLikeIDs: ["debian"], kernelName: "Linux"),
for: host.id)
sourceLibrary.replaceTailscalePeers([peer(name: "Refreshed")], groupID: group.id)
#expect(sourceLibrary.host(host.id)?.detectedDistribution?.id == "ubuntu")
var exitGroup = try #require(sourceLibrary.group(group.id))
exitGroup.tailscale?.exitNodeID = "exit-node-stable-id"
sourceLibrary.upsert(exitGroup)
let envelopes = try VaultSyncJournal(context: source.mainContext).pendingEnvelopes()
#expect(!envelopes.contains { $0.key.kind == .host })
let remote = try envelopes.map { try VaultSyncCodec.decode(VaultSyncCodec.encode($0)) }
let destination = try TermindPersistence.makeContainer(inMemory: true)
let destinationStore = HostPersistence(context: destination.mainContext)
_ = try destinationStore.applyRemote(remote)
let destinationLibrary = HostLibraryModel(persistence: destinationStore)
#expect(destinationLibrary.hosts.isEmpty)
destinationLibrary.replaceTailscalePeers([peer(name: "Renamed")], groupID: group.id)
let discovered = try #require(destinationLibrary.host(host.id))
#expect(discovered.tags == ["production", "linux"])
#expect(discovered.colorTag == .blue)
#expect(discovered.detectedDistribution?.id == "ubuntu")
#expect(destinationLibrary.group(group.id)?.tailscale?.exitNodeID == "exit-node-stable-id")
#expect(discovered.name == "Renamed")
destinationLibrary.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "Linux"), for: discovered.id)
#expect(destinationLibrary.host(discovered.id)?.detectedDistribution?.id == "ubuntu")
destinationLibrary.recordRemoteSystemInfo(RemoteSystemInfo(kernelName: "Darwin"), for: discovered.id)
destinationLibrary.replaceTailscalePeers([peer()], groupID: group.id)
#expect(destinationLibrary.host(discovered.id)?.detectedDistribution == nil)
}
@Test func usernamePromptsAndRouteSavingNeverCreateOrdinaryIdentities() async throws {
var group = try group("example.ts.net")
group.tailscale?.username = ""
let library = HostLibraryModel(groups: [group])
let provider = TestTailscaleProvider()
library.tailscaleConnections = provider
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.hosts.first)
let prompt = SSHAuthenticationPromptResolver.host(host, using: library, username: "")
#expect(prompt.usernameOnly)
#expect(prompt.username == "root")
#expect(prompt.selectedIdentityID == nil && prompt.selectedKeyID == nil)
#expect(!prompt.requiresPassphrase)
let submission = SSHAuthenticationSubmission(username: "operator")
_ = try library.saveAuthentication(submission, for: host, selectedIdentity: nil, suppliedPassword: nil)
#expect(library.tailscaleUsername(for: host) == "operator")
#expect(library.identities.isEmpty)
_ = try library.saveConnectionRoute(
SSHConnectionRoute(hosts: [host]), authentication: submission, authenticationHostID: host.id)
#expect(library.identities.isEmpty && library.hosts.count == 1)
let ephemeral = SSHAuthenticationSubmission(username: "another-user", password: "ignored", keyID: UUID())
let preparation = try library.prepareSSHConnection(for: host, submissions: [host.id: ephemeral])
let configuration = try await preparation.resolve()
#expect(configuration.username == "another-user")
#expect(configuration.credentials.isEmpty)
#expect(configuration.tailscaleNodeID == "n123")
#expect(library.tailscaleUsername(for: host) == "operator")
}
@Test func assistantAndExternalUsernameRequestsUseTheTailscalePrompt() async throws {
var group = try group("example.ts.net")
group.tailscale?.username = ""
let library = HostLibraryModel(groups: [group])
let provider = TestTailscaleProvider()
library.tailscaleConnections = provider
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.hosts.first)
let interaction = AgentSSHInteraction(
authenticate: { request in
#expect(request.mode == .tailscaleUsername)
#expect(request.journeyPrompt.usernameOnly)
#expect(request.selectedIdentityID == nil && request.selectedKeyID == nil)
return .submit(AgentSSHAuthenticationAnswer(username: "deploy", save: true))
},
confirmHostKey: { _ in
Issue.record("Managed hosts must not ask for ordinary host-key trust")
return .deny
})
let session = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: library), hostID: host.id, invocationID: UUID(), interaction: interaction
)
let configuration = try await session.configuration()
#expect(configuration.username == "deploy")
#expect(configuration.credentials.isEmpty)
#expect(configuration.tailscaleNodeID == "n123")
#expect(library.tailscaleUsername(for: host) == "deploy")
#expect(library.identities.isEmpty)
let resumed = AgentSSHAuthenticationContext(
resolver: AgentHostResolver(store: library), hostID: host.id, invocationID: UUID(),
interaction: AgentSSHInteraction(
authenticate: { _ in
Issue.record("Saved Tailscale username must bypass the initial prompt")
return .cancelled
}, confirmHostKey: { _ in .deny }))
#expect(try await resumed.configuration().username == "deploy")
}
@Test func olderHostPreferencesDecodeWithoutTagsOrColor() throws {
let data = Data(
#"{"username":"operator","charset":"utf8","sftpInitialPath":"/srv","shellIntegrationMode":"followGlobalSettings","environmentVariables":[]}"#
.utf8)
let preferences = try JSONDecoder().decode(TailscaleHostPreferences.self, from: data)
#expect(preferences.tags.isEmpty)
#expect(preferences.colorTag == .none)
#expect(preferences.username == "operator")
#expect(preferences.sftpInitialPath == "/srv")
}
@Test func nodesWithoutSSHDisappearWithoutLosingSyncedPreferencesOrManualHosts() throws {
let group = try group("example.ts.net")
let manual = Host(name: "Manual", host: "manual.example.ts.net", groupID: group.id)
let library = HostLibraryModel(hosts: [manual], groups: [group])
library.replaceTailscalePeers([peer()], groupID: group.id)
var managed = try #require(library.hosts.first { $0.isTailscaleManaged })
managed.tags = ["keep"]
#expect(library.saveHost(managed))
var disabled = peer()
disabled.sshHostKeys = []
library.replaceTailscalePeers([disabled], groupID: group.id)
#expect(library.host(managed.id) == nil)
#expect(library.host(manual.id) != nil)
library.replaceTailscalePeers([peer(online: false)], groupID: group.id)
#expect(library.host(managed.id)?.tags == ["keep"])
}
@Test func statusRetainsNonSSHPeersForDiagnosticsAndReportsLocalDeviceDetails() throws {
let data = Data(
#"{"BackendState":"Running","CurrentTailnet":{"Name":"Example","MagicDNSSuffix":"example.ts.net"},"Self":{"HostName":"Phone","DNSName":"phone.example.ts.net.","TailscaleIPs":["100.64.0.1","fd7a:115c:a1e0::1"],"OS":"iOS","KeyExpiry":"2027-01-02T03:04:05.123Z"},"Peer":{"disabled":{"ID":"n456","HostName":"desktop","DNSName":"desktop.example.ts.net.","Online":true,"InNetworkMap":true,"ExitNodeOption":true}}}"#
.utf8)
let status = try LibTailscaleNodeSession.decodeStatus(data, controlURL: TailscaleNetwork.defaultControlURL)
#expect(status.peers.count == 1)
#expect(status.peers.first?.supportsSSH == false)
#expect(status.peers.first?.exitNodeOption == true)
let device = try #require(status.device)
#expect(device.networkName == "Example")
#expect(device.dnsName == "phone.example.ts.net")
#expect(device.hostname == "Phone")
#expect(device.addresses == ["100.64.0.1", "fd7a:115c:a1e0::1"])
#expect(device.operatingSystem == "iOS")
#expect(device.keyExpiry != nil)
}
@Test func missingTailscaleMetricsRetainConfigurationUntilExplicitGroupDeletion() throws {
let group = try group("example.ts.net")
var selection = MetricsSelection(id: UUID(), enabled: false)
selection.tailscaleGroupID = group.id
selection.displayName = "Offline node"
let preferences = TailscaleMetricsPreferences(values: [selection])
let library = HostLibraryModel(groups: [group])
let metrics = ServerMetricsModel(
hosts: library, preferences: preferences,
makeSampler: { TailscaleTestSampler() })
metrics.start()
defer { metrics.shutdown() }
#expect(metrics.servers.first?.name == "Offline node")
#expect(metrics.replaceHosts(selectedIDs: [], editableHostIDs: []))
#expect(preferences.values == [selection])
metrics.removeTailscaleGroups([group.id])
#expect(preferences.values.isEmpty)
#expect(metrics.servers.isEmpty)
}
@Test func statusRequiresAnAuthoritativePeerMapAndKeepsOfflineNodes() throws {
let data = Data(
#"{"BackendState":"Running","CurrentTailnet":{"MagicDNSSuffix":"example.ts.net"},"Peer":{"key":{"ID":"n123","HostName":"server","DNSName":"server.example.ts.net.","Online":false,"InNetworkMap":true,"sshHostKeys":["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"]}}}"#
.utf8)
let status = try LibTailscaleNodeSession.decodeStatus(data, controlURL: TailscaleNetwork.defaultControlURL)
#expect(status.running)
#expect(status.peers.count == 1)
#expect(status.peers.first?.online == false)
let incomplete = Data(#"{"BackendState":"Running","CurrentTailnet":{"MagicDNSSuffix":"example.ts.net"}}"#.utf8)
#expect(throws: DecodingError.self) {
try LibTailscaleNodeSession.decodeStatus(incomplete, controlURL: TailscaleNetwork.defaultControlURL)
}
let empty = Data(
#"{"BackendState":"Running","CurrentTailnet":{"MagicDNSSuffix":"example.ts.net"},"Peer":null}"#.utf8)
#expect(try LibTailscaleNodeSession.decodeStatus(empty, controlURL: TailscaleNetwork.defaultControlURL).running)
#expect(LibTailscaleNodeSession.hostKey("ssh-ed25519 invalid") == nil)
#expect(
LibTailscaleNodeSession.hostKey(
"ssh-rsa AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA") == nil)
}
@Test func savedUsernameAppearsInCardsWithoutInventingAnUnsavedLogin() throws {
var group = try group("example.ts.net")
group.tailscale?.username = ""
let library = HostLibraryModel(groups: [group])
library.replaceTailscalePeers([peer()], groupID: group.id)
let host = try #require(library.hosts.first)
#expect(library.savedTailscaleUsername(for: host) == nil)
#expect(library.loginAddress(for: host) == "server.example.ts.net")
#expect(library.saveTailscaleUsername("operator", hostID: host.id))
#expect(library.loginAddress(for: host) == "operator@server.example.ts.net")
#expect(library.identities.isEmpty)
}
@Test func hostnamePersistsLocallyAndEditsReachTheRunningNode() async throws {
let group = try group("example.ts.net")
let library = HostLibraryModel(groups: [group])
let container = try TermindPersistence.makeContainer(inMemory: true)
let store = SwiftDataTailscaleLocalStore(context: container.mainContext)
try store.save(TailscaleLocalState(groupID: group.id, profileID: UUID()))
let node = TestTailscaleNode(network: try #require(group.tailscale?.network))
let model = TailscaleModel(
library: library, store: store, factory: { _, _, _, _ in node },
hostname: { "default-device" }, hasAccess: { true })
model.start()
for _ in 0..<100 where !model.runningGroups.contains(group.id) {
try await Task.sleep(for: .milliseconds(10))
}
#expect(try store.load().first?.hostname == "default-device")
try await model.saveHostname(" custom-device ", groupID: group.id)
#expect(await node.hostname == "custom-device")
#expect(try store.load().first?.hostname == "custom-device")
await model.shutdown()
let restored = TailscaleModel(
library: library, store: store, factory: { _, _, _, _ in node },
hostname: { "renamed-system-device" }, hasAccess: { false })
restored.start()
#expect(restored.savedHostname(groupID: group.id) == "custom-device")
await restored.shutdown()
}
@Test func pingDistinguishesDirectRelayUnknownAndServerFailure() throws {
func decode(_ json: String) throws -> TailscalePingResult {
try LibTailscaleNodeSession.decodePing(Data(json.utf8))
}
let direct = try decode(#"{"LatencySeconds":0.012,"Endpoint":"192.0.2.1:41641"}"#)
#expect(direct.milliseconds == 12)
#expect(direct.route == .direct("192.0.2.1:41641"))
#expect(try decode(#"{"LatencySeconds":0.1,"DERPRegionID":3,"DERPRegionCode":"sin"}"#).route == .derp("sin"))
#expect(
try decode(#"{"LatencySeconds":0.1,"PeerRelay":"100.64.0.1:1:vni:2"}"#).route
== .peerRelay("100.64.0.1:1:vni:2"))
#expect(try decode(#"{"LatencySeconds":0.1}"#).route == .unknown)
#expect(throws: TermindSSHError.self) { try decode(#"{"Err":"peer is offline"}"#) }
}
@Test func syncedExitNodeIsAppliedAndCanBeDisabled() async throws {
var group = try group("example.ts.net")
group.tailscale?.exitNodeID = "exit-one"
let library = HostLibraryModel(groups: [group])
let store = InMemoryTailscaleLocalStore()
try store.save(TailscaleLocalState(groupID: group.id, profileID: UUID(), hostname: "device"))
let exitPeer = TailscalePeer(
stableID: "exit-one", name: "Exit without SSH", dnsName: "exit.example.ts.net",
online: true, sshHostKeys: [], exitNodeOption: true)
let node = TestTailscaleNode(
network: try #require(group.tailscale?.network), peers: [exitPeer, peer()])
let model = TailscaleModel(
library: library, store: store, factory: { _, _, _, _ in node },
hostname: { "device" }, hasAccess: { true })
model.start()
for _ in 0..<100 where !model.runningGroups.contains(group.id) {
try await Task.sleep(for: .milliseconds(10))
}
#expect(await node.selectedExitNode == "exit-one")
#expect(model.exitNodeCandidates(groupID: group.id).map(\.stableID) == ["exit-one"])
#expect(!library.hosts.contains { $0.tailscale?.stableNodeID == "exit-one" })
let retained = try model.connection(for: group.id)
group.tailscale?.exitNodeID = "exit-two"
library.upsert(group)
await #expect(throws: TailscaleConfigurationError.self) {
try await retained.open(host: "example.com", port: 22, timeout: 1)
}
for _ in 0..<400 {
if await node.selectedExitNode == "exit-two" { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(await node.selectedExitNode == "exit-two")
try model.saveExitNode("", groupID: group.id)
for _ in 0..<400 {
if await node.selectedExitNode == nil { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(await node.selectedExitNode == nil)
#expect(library.group(group.id)?.tailscale?.exitNodeID == nil)
await model.shutdown()
}
private func peer(name: String = "Server", online: Bool = true) -> TailscalePeer {
TailscalePeer(
stableID: "n123", name: name, dnsName: "server.example.ts.net", online: online,
sshHostKeys: ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"])
}
}
@MainActor private final class TestTailscaleProvider: TailscaleConnectionProviding {
var allowed = true
func connection(for groupID: UUID) throws -> any SSHNetworkDialing {
guard allowed else { throw TailscaleConfigurationError.requiresPro }
return TestTailscaleDialer()
}
}
private nonisolated struct TestTailscaleDialer: SSHNetworkDialing {
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { throw CancellationError() }
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> { [] }
}
@MainActor private final class TailscaleMetricsPreferences: MetricsConfigurationStoring {
var values: [MetricsSelection]
init(values: [MetricsSelection]) { self.values = values }
func load() throws -> [MetricsSelection] { values }
func save(_ selections: [MetricsSelection]) throws { values = selections }
}
private nonisolated struct TailscaleTestSampler: MetricsSampling {
func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() }
func sample() async throws -> MetricsSample { throw CancellationError() }
func gpu() async throws -> NvidiaSnapshot? { nil }
func disconnect() async {}
}
@MainActor private final class TailscaleTestAccess { var allowed = true }
private actor TestTailscaleNode: TailscaleNodeSession {
let network: TailscaleNetwork
let peers: [TailscalePeer]
var closed = false
var hostname = ""
var selectedExitNode: String?
func setExitNode(_ stableID: String?) async throws { selectedExitNode = stableID }
func setHostname(_ hostname: String) async throws { self.hostname = hostname }
init(network: TailscaleNetwork, peers: [TailscalePeer] = []) {
self.network = network
self.peers = peers
}
func status() async throws -> TailscaleNodeStatus {
TailscaleNodeStatus(running: true, network: network, authURL: nil, peers: peers)
}
func login() async throws {}
func logout() async throws {}
func close() async { closed = true }
func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 { throw CancellationError() }
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> { [] }
}
@@ -0,0 +1,72 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import TermindRemoteInfrastructure
/// Transport behavior shared by macOS and iOS, exercised against a real SSH/SFTP fixture.
@Suite(
.enabled(
if: ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE"] != nil
|| ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE_JSON"] != nil), .serialized
)
nonisolated struct TailscaleSSHWireTests {
@Test func noneAuthenticationSupportsSFTPAndAdditionalConnections() async throws {
let fixture = try Fixture.load()
let network = LoopbackTailnet(port: fixture.port, fingerprint: fixture.hostFingerprint)
for _ in 0..<2 {
var configuration = SSHConnectionConfiguration(
hostID: UUID(), host: "fixture.test.ts.net", port: 22,
username: "none", credentials: [], expectedHostKeys: [])
configuration.network = network
configuration.tailscaleNodeID = "fixture-node"
let connection = SSHConnection()
do {
try await connection.connect(
using: SSHConnectionRequest(
configuration: configuration,
interactionPolicy: .forbidden))
let files = try await connection.openSFTPChannel()
let contents = try await files.readFile(at: "/fixture.txt", maximumBytes: 1024)
#expect(contents == Data("fixture\n".utf8))
#expect(try await files.listDirectory(at: "/").count == 1)
await files.disconnect()
await connection.disconnect()
} catch {
await connection.disconnect()
throw error
}
}
}
private struct Fixture: Decodable {
var port: Int
var hostFingerprint: String
static func load() throws -> Self {
let environment = ProcessInfo.processInfo.environment
// Passing public endpoint metadata through the test runner also works in the app sandbox.
let data: Data
if let json = environment["TERMIND_SSH_FIXTURE_JSON"] {
data = Data(json.utf8)
} else {
let path = try #require(environment["TERMIND_SSH_FIXTURE"])
data = try Data(contentsOf: URL(fileURLWithPath: path))
}
return try JSONDecoder().decode(Self.self, from: data)
}
}
private struct LoopbackTailnet: SSHNetworkDialing {
var port: Int
var fingerprint: String
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 {
#expect(host == "fixture.test.ts.net")
return try SSHSocketConnector.open(host: "127.0.0.1", port: self.port)
}
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> {
#expect(stableNodeID == "fixture-node")
return [SSHHostKeyIdentity(algorithm: .rsa, fingerprint: fingerprint)]
}
}
}
+748
View File
@@ -3805,6 +3805,23 @@
}
}
},
"Auth Key" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "认证密钥"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "驗證金鑰"
}
}
}
},
"Authenticating" : {
"extractionState" : "manual",
"localizations" : {
@@ -7272,6 +7289,23 @@
}
}
},
"Controlplane URL" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "控制平面URL"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "控制平面URL"
}
}
}
},
"Conversation Name" : {
"extractionState" : "manual",
"localizations" : {
@@ -8003,6 +8037,40 @@
}
}
},
"DERP region" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "DERP区域"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "DERP區域"
}
}
}
},
"DERP-relayed connection" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "DERP中继连接"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "DERP中繼連線"
}
}
}
},
"Dark" : {
"extractionState" : "manual",
"localizations" : {
@@ -9437,6 +9505,40 @@
}
}
},
"Direct" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "直连"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "直連"
}
}
}
},
"Direct connection" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "直接连接"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "直接連線"
}
}
}
},
"Direction" : {
"extractionState" : "manual",
"localizations" : {
@@ -10911,6 +11013,23 @@
}
}
},
"Enter a valid HTTPS Controlplane URL." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "请输入有效的HTTPS控制平面URL。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "請輸入有效的HTTPS控制平面URL。"
}
}
}
},
"Enter a valid project and location to configure Vertex AI." : {
"extractionState" : "manual",
"localizations" : {
@@ -11263,6 +11382,23 @@
}
}
},
"Exit Node" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "出口节点"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "出口節點"
}
}
}
},
"Exit code" : {
"extractionState" : "manual",
"localizations" : {
@@ -13600,6 +13736,40 @@
}
}
},
"IPv4" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "IPv4"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "IPv4"
}
}
}
},
"IPv6" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "IPv6"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "IPv6"
}
}
}
},
"Identities" : {
"extractionState" : "manual",
"localizations" : {
@@ -14274,6 +14444,23 @@
}
}
},
"Interactive Login" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "交互式登录"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "互動式登入"
}
}
}
},
"Interactive authentication" : {
"extractionState" : "manual",
"localizations" : {
@@ -14643,6 +14830,23 @@
}
}
},
"Key Expiry" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "密钥到期"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "金鑰到期"
}
}
}
},
"Key Size" : {
"extractionState" : "manual",
"localizations" : {
@@ -14951,6 +15155,23 @@
}
}
},
"Last Seen" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "最后在线"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "最後上線"
}
}
}
},
"Last Workspace" : {
"extractionState" : "manual",
"localizations" : {
@@ -14985,6 +15206,23 @@
}
}
},
"Latency" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "延迟"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "延遲"
}
}
}
},
"Learning Model" : {
"extractionState" : "manual",
"localizations" : {
@@ -15427,6 +15665,23 @@
}
}
},
"Log out" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "退出登录"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "登出"
}
}
}
},
"Logging" : {
"extractionState" : "manual",
"localizations" : {
@@ -15444,6 +15699,23 @@
}
}
},
"Login" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "登录"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "登入"
}
}
}
},
"Logs" : {
"extractionState" : "manual",
"localizations" : {
@@ -15563,6 +15835,23 @@
}
}
},
"MagicDNS" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "MagicDNS"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "MagicDNS"
}
}
}
},
"Manage Models" : {
"extractionState" : "manual",
"localizations" : {
@@ -16486,6 +16775,23 @@
}
}
},
"Network" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "网络"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "網路"
}
}
}
},
"Network Interfaces" : {
"extractionState" : "manual",
"localizations" : {
@@ -17395,6 +17701,23 @@
}
}
},
"No data" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "暂无数据"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "尚無資料"
}
}
}
},
"No description" : {
"comment" : "A placeholder text to display when a skill doesn't have a description.",
"extractionState" : "manual",
@@ -18264,6 +18587,23 @@
}
}
},
"OS" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "操作系统"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "作業系統"
}
}
}
},
"Octal permissions" : {
"extractionState" : "manual",
"localizations" : {
@@ -18332,6 +18672,23 @@
}
}
},
"Offline" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "离线"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "離線"
}
}
}
},
"Onboarding" : {
"extractionState" : "manual",
"localizations" : {
@@ -18471,6 +18828,23 @@
}
}
},
"Online" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "在线"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "線上"
}
}
}
},
"Only SKILL.md was imported. These were not: %@." : {
"comment" : "A warning that a skill was imported but some files were not. The list of files is a comma-separated list.",
"extractionState" : "manual",
@@ -18893,6 +19267,23 @@
}
}
},
"Open login page" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "打开登录页面"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "開啟登入頁面"
}
}
}
},
"Open serial port" : {
"extractionState" : "manual",
"localizations" : {
@@ -19883,6 +20274,40 @@
}
}
},
"Peer Relay" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "节点中继"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "節點中繼"
}
}
}
},
"Peer relay connection" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "节点中继连接"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "節點中繼連線"
}
}
}
},
"Pending" : {
"extractionState" : "manual",
"localizations" : {
@@ -20087,6 +20512,23 @@
}
}
},
"Ping" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ping"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Ping"
}
}
}
},
"Pink" : {
"extractionState" : "manual",
"localizations" : {
@@ -23457,6 +23899,23 @@
}
}
},
"SFTP connection to %@@%@ failed: %@" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "连接到%1$@@%2$@的SFTP失败:%3$@"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "連線至%1$@@%2$@的SFTP失敗:%3$@"
}
}
}
},
"SFTP text transfer mode is not supported." : {
"extractionState" : "manual",
"localizations" : {
@@ -24650,6 +25109,23 @@
}
}
},
"Selected exit node is unavailable" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "所选出口节点不可用"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "所選出口節點無法使用"
}
}
}
},
"Send" : {
"extractionState" : "manual",
"localizations" : {
@@ -25561,6 +26037,23 @@
}
}
},
"Sign in on this device to use this Tailnet." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "请在此设备上登录以使用此Tailnet。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "請在此裝置上登入以使用此Tailnet。"
}
}
}
},
"Sign in to iCloud to sync." : {
"extractionState" : "manual",
"localizations" : {
@@ -26407,6 +26900,23 @@
}
}
},
"Stop Ping" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "停止Ping"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "停止Ping"
}
}
}
},
"Stop Reconnecting" : {
"extractionState" : "manual",
"localizations" : {
@@ -26936,6 +27446,142 @@
}
}
},
"Tailnet" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tailnet"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tailnet"
}
}
}
},
"Tailnet settings are being applied. Try connecting again in a moment." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在应用Tailnet设置,请稍后重新连接。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "正在套用Tailnet設定,請稍後重新連線。"
}
}
}
},
"Tailscale" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tailscale"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tailscale"
}
}
}
},
"Tailscale SSH approval" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tailscale SSH授权"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tailscale SSH授權"
}
}
}
},
"Tailscale SSH approval timed out. Connect again to request approval." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tailscale SSH授权已超时。请重新连接以请求授权。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tailscale SSH授權已逾時。請重新連線以要求授權。"
}
}
}
},
"Tailscale SSH authentication failed." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tailscale SSH认证失败。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "Tailscale SSH認證失敗。"
}
}
}
},
"Tailscale manages this host’s address, authentication, and SSH host keys." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此主机的地址、认证和SSH主机密钥由Tailscale管理。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此主機的位址、驗證和SSH主機金鑰由Tailscale管理。"
}
}
}
},
"Tailscale requires Termind Pro." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用Tailscale需要Termind Pro。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "使用Tailscale需要Termind Pro。"
}
}
}
},
"Taiwan Flag Available" : {
"extractionState" : "manual",
"localizations" : {
@@ -27685,6 +28331,23 @@
}
}
},
"The SSH host key does not match the keys provided by Tailscale." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH主机密钥与Tailscale提供的密钥不匹配。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "SSH主機金鑰與Tailscale提供的金鑰不符。"
}
}
}
},
"The SSH identification could not be read. The server or network path may have reset the connection." : {
"extractionState" : "manual",
"localizations" : {
@@ -29849,6 +30512,40 @@
}
}
},
"This Tailnet already belongs to another group." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此Tailnet已属于其他分组。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此Tailnet已屬於其他群組。"
}
}
}
},
"This Tailscale connection is unavailable. Check the Tailnet login, SSH policy, and username." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此Tailscale连接不可用。请检查Tailnet登录状态、SSH策略和用户名。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此Tailscale連線無法使用。請檢查Tailnet登入狀態、SSH原則和使用者名稱。"
}
}
}
},
"This Vault record requires sync format %lld." : {
"extractionState" : "manual",
"localizations" : {
@@ -30129,6 +30826,23 @@
}
}
},
"This device is not connected to the Tailnet. Log in from the group editor." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此设备尚未连接到Tailnet。请在分组编辑页登录。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此裝置尚未連線至Tailnet。請在群組編輯頁登入。"
}
}
}
},
"This file cannot be uploaded back to its original path." : {
"extractionState" : "manual",
"localizations" : {
@@ -30370,6 +31084,23 @@
}
}
},
"This peer is not advertising Tailscale SSH host keys. Enable Tailscale SSH on the peer." : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "此节点未提供Tailscale SSH主机密钥。请在该节点上启用Tailscale SSH。"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "此節點未提供Tailscale SSH主機金鑰。請在該節點上啟用Tailscale SSH。"
}
}
}
},
"This permanently removes all connection history." : {
"extractionState" : "manual",
"localizations" : {
@@ -31763,6 +32494,23 @@
}
}
},
"Unknown Route" : {
"extractionState" : "manual",
"localizations" : {
"zh-Hans" : {
"stringUnit" : {
"state" : "translated",
"value" : "未知路由"
}
},
"zh-Hant" : {
"stringUnit" : {
"state" : "translated",
"value" : "未知路由"
}
}
}
},
"Unknown Security framework error" : {
"extractionState" : "manual",
"localizations" : {
@@ -12,6 +12,7 @@ nonisolated public struct AgentSSHAuthenticationRequest: Identifiable, Hashable,
public nonisolated enum Mode: Hashable, Sendable {
case securityKey(SecurityKeySigningRequest)
case identity
case tailscaleUsername
case proxy(ownerHostID: Host.ID)
case keyboardInteractive(SSHKeyboardInteractiveChallenge)
case passphrase(keyID: SSHKeyRef.ID)
@@ -55,7 +56,7 @@ nonisolated public struct AgentSSHAuthenticationRequest: Identifiable, Hashable,
failureMessage: failureMessage,
requiresPassphrase: requiresPassphrase,
mode: failureMessage == nil ? .localIdentity : .serverChallenge,
keyboardInteractive: keyboardInteractive, target: target
keyboardInteractive: keyboardInteractive, target: target, usernameOnly: mode == .tailscaleUsername
)
}
@@ -33,7 +33,7 @@ extension ConversationModel {
let secret = request.questions.contains { if case .secret = $0.kind { true } else { false } }
return .init(id: request.id, kind: secret ? .secret : .questions)
case .sshAuthentication(let request):
if request.keyboardInteractive?.prompts.isEmpty == true { return nil }
if request.keyboardInteractive?.isInformational == true { return nil }
return .init(id: request.id, kind: request.requiresPassphrase ? .passphrase : .authentication)
case .sshHostKey(let request):
return .init(id: request.id, kind: .hostKey)
@@ -2672,7 +2672,7 @@ public final class ConversationModel: Identifiable {
request, at: index ?? pendingSSHAuthentications.endIndex
)
setToolState(request.invocationID, .waitingForUser)
if request.keyboardInteractive?.prompts.isEmpty == true {
if request.keyboardInteractive?.isInformational == true {
Task { [weak self] in
await Task.yield()
self?.respondToSSHAuthentication(.keyboardInteractive([]), requestID: request.id)
@@ -59,6 +59,7 @@ public final class ConnectionJourney {
public enum LocalRequirement: Equatable {
case identity
case tailscaleUsername
case telnetPassword
case passphrase
case proxyAuthentication
@@ -333,6 +334,11 @@ public final class ConnectionJourney {
)
}
public func waitForTailscaleUsername(at hostID: Host.ID, failure: String? = nil) {
waitForLocalIdentity(at: hostID, failure: failure ?? String(localized: "Username"))
localRequirement = .tailscaleUsername
}
public func waitForLocalPassphrase(at hostID: Host.ID, failure: String? = nil) {
guard let index = endpoints.firstIndex(where: { $0.id == .host(hostID) }) else { return }
activeEndpointIndex = 0
@@ -9,6 +9,11 @@ import struct TermindDomain.Host
@MainActor
public protocol RemoteSessionHostStore: AnyObject, Sendable {
func host(_ id: Host.ID) -> Host?
func networkConfigurationStamp(for host: Host) -> Data
func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)?
func savedTailscaleUsername(for host: Host) -> String?
func tailscaleUsername(for host: Host) -> String
@discardableResult func saveTailscaleUsername(_ username: String, hostID: Host.ID) -> Bool
func snippet(_ id: Snippet.ID) -> Snippet?
func identity(_ id: Identity.ID) -> Identity?
func identity(for host: Host) -> Identity?
@@ -70,6 +75,15 @@ public protocol RemoteSessionHostStore: AnyObject, Sendable {
}
extension RemoteSessionHostStore {
public func savedTailscaleUsername(for host: Host) -> String? { nil }
public func tailscaleUsername(for host: Host) -> String { "root" }
public func saveTailscaleUsername(_ username: String, hostID: Host.ID) -> Bool { false }
public func networkConfigurationStamp(for host: Host) -> Data { Data() }
public func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)? {
guard !host.isTailscaleManaged else { throw TailscaleConfigurationError.unavailable }
return nil
}
public func saveKeyPassphrase(_ passphrase: String, for keyID: SSHKeyRef.ID) throws {
throw TermindSSHError.missingCredential
}
@@ -184,6 +198,12 @@ extension RemoteSessionHostStore {
_ submission: SSHAuthenticationSubmission, for host: Host,
selectedIdentity: Identity?, suppliedPassword: String?
) throws -> Host {
if host.isTailscaleManaged {
guard saveTailscaleUsername(submission.username, hostID: host.id) else {
throw TailscaleConfigurationError.unavailable
}
return host
}
let identity: Identity
if let selectedIdentity {
if let suppliedPassword {
@@ -301,10 +321,8 @@ extension RemoteSessionHostStore {
id: configuration.hostID, name: configuration.host,
host: configuration.host, port: configuration.port,
authentication: configuration.authenticationReference,
expectedHostKeys: keys(
id: configuration.hostID, hostname: configuration.host,
port: configuration.port
),
expectedHostKeys: configuration.tailscaleNodeID == nil
? keys(id: configuration.hostID, hostname: configuration.host, port: configuration.port) : [],
proxy: proxy(configuration.proxy)
)
let jumps = configuration.jumps.map { hop in
@@ -315,7 +333,10 @@ extension RemoteSessionHostStore {
proxy: proxy(hop.proxy)
)
}
return SSHAdditionalConnectionSnapshot(target: target, jumps: jumps)
var result = SSHAdditionalConnectionSnapshot(target: target, jumps: jumps)
result.tailscaleNodeID = configuration.tailscaleNodeID
result.requiresTailscale = configuration.network != nil
return result
}
/// Resolves current Identity/Key contents immediately before a silent open.
@@ -426,7 +447,7 @@ extension RemoteSessionHostStore {
authenticationReference: node.authentication
)
}
let configuration = SSHConnectionConfiguration(
var configuration = SSHConnectionConfiguration(
hostID: snapshot.target.id, host: snapshot.target.host,
port: snapshot.target.port, username: targetAuthentication.username,
credentials: targetAuthentication.credentials,
@@ -435,6 +456,12 @@ extension RemoteSessionHostStore {
charset: snapshot.target.charset,
authenticationReference: snapshot.target.authentication
)
configuration.tailscaleNodeID = snapshot.tailscaleNodeID
if let first = host(snapshot.jumps.first?.id ?? snapshot.target.id) {
configuration.network = try tailscaleNetwork(for: first)
} else if snapshot.requiresTailscale {
throw TailscaleConfigurationError.unavailable
}
completed = true
return SSHConnectionRequest(
configuration: configuration, interactionPolicy: .forbidden
@@ -53,6 +53,8 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable {
}
}
public var tailscaleNodeID: String?
public var requiresTailscale = false
public var target: Node
public var jumps: [Node]
@@ -65,6 +67,8 @@ public nonisolated struct SSHAdditionalConnectionSnapshot: Equatable, Sendable {
/// explicit Identity/Key provenance. Durable trust may be refreshed by the
/// host store before this value is retained for additional connections.
public init(configuration: SSHConnectionConfiguration) {
tailscaleNodeID = configuration.tailscaleNodeID
requiresTailscale = configuration.network != nil
func proxy(_ value: SSHTransportProxy?) -> Proxy? {
value.map {
Proxy(
@@ -25,11 +25,12 @@ public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Senda
public var mode: Mode = .localIdentity
public var target: Target = .host
public var allowsSaving = true
public var usernameOnly: Bool
public init(
host: Host, selectedIdentityID: Identity.ID? = nil, username: String, selectedKeyID: SSHKeyRef.ID? = nil,
failureMessage: String? = nil, requiresPassphrase: Bool = false, mode: Mode = .localIdentity,
keyboardInteractive: SSHKeyboardInteractiveChallenge? = nil, target: Target = .host,
allowsSaving: Bool = true
allowsSaving: Bool = true, usernameOnly: Bool = false
) {
self.host = host
self.selectedIdentityID = selectedIdentityID
@@ -41,6 +42,7 @@ public nonisolated struct SSHAuthenticationPrompt: Identifiable, Hashable, Senda
self.keyboardInteractive = keyboardInteractive
self.target = target
self.allowsSaving = allowsSaving
self.usernameOnly = usernameOnly || host.isTailscaleManaged
}
public var isProxy: Bool {
@@ -62,6 +64,14 @@ public enum SSHAuthenticationPromptResolver {
mode: SSHAuthenticationPrompt.Mode = .localIdentity,
requiresPassphrase: Bool? = nil, allowsSaving: Bool = true
) -> SSHAuthenticationPrompt {
if host.isTailscaleManaged {
let suppliedUsername = username?.trimmingCharacters(in: .whitespacesAndNewlines)
return SSHAuthenticationPrompt(
host: host,
username: suppliedUsername?.isEmpty == false
? suppliedUsername! : (submission?.username ?? store.tailscaleUsername(for: host)),
failureMessage: failureMessage, mode: mode, allowsSaving: allowsSaving)
}
let identity = submission?.identityID.flatMap(store.identity) ?? store.identity(for: host)
let keyID = selectedKeyID ?? submission?.keyID ?? identity?.keyID
let resolvedUsername = username ?? submission?.username ?? identity?.username ?? ""
@@ -14,6 +14,8 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
public var trustedKeys: Set<SSHHostKeyIdentity>
public var proxyIdentity: Identity?
public var proxySubmission: SSHProxyAuthenticationSubmission?
public var network: (any SSHNetworkDialing)?
public var tailscaleUsername: String?
public init(
host: Host, identity: Identity?, key: SSHKeyRef?, submission: SSHAuthenticationSubmission?,
trustedKeys: Set<SSHHostKeyIdentity>, proxyIdentity: Identity?,
@@ -73,6 +75,7 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
for (offset, node) in nodes.dropLast().enumerated() {
do {
try Task.checkCancellation()
guard !node.host.isTailscaleManaged else { throw TailscaleConfigurationError.managedHost }
let material = try credentials(node)
resolvedCredentials += material.1
let resolvedProxy = try proxy(node)
@@ -108,20 +111,27 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
resolvedCredentials += material.1
let resolvedProxy = try proxy(target)
if let password = resolvedProxy?.password { resolvedProxies.append(password) }
if !discoveryOnly, trustPolicy == .requireKnownHost, target.trustedKeys.isEmpty {
if !target.host.isTailscaleManaged, !discoveryOnly, trustPolicy == .requireKnownHost, target.trustedKeys.isEmpty
{
throw TermindSSHError.untrustedHost
}
completed = true
return SSHConnectionConfiguration(
var configuration = SSHConnectionConfiguration(
hostID: target.host.id, host: target.host.host, port: target.host.port,
username: material.0, credentials: material.1, expectedHostKeys: target.trustedKeys,
proxy: resolvedProxy, jumps: jumps,
charset: target.host.charset,
authenticationReference: authenticationReference(for: target)
)
configuration.network = nodes.first?.network
configuration.tailscaleNodeID = target.host.tailscale?.stableNodeID
return configuration
}
private func authenticationReference(for node: Node) -> SSHAuthenticationReference {
if node.host.isTailscaleManaged {
return SSHAuthenticationReference(username: node.tailscaleUsername ?? "", permitsNone: true)
}
if let identity = connectionIdentity(for: node) {
return SSHAuthenticationReference(
identityID: identity.id,
@@ -141,6 +151,12 @@ public nonisolated struct SSHConnectionPreparation: Sendable {
}
private func credentials(_ node: Node) throws -> (String, [SSHCredentials]) {
if node.host.isTailscaleManaged {
guard let username = node.tailscaleUsername, !username.isEmpty else {
throw TailscaleConfigurationError.unavailable
}
return (username, [])
}
let username = (node.submission?.username ?? node.identity?.username ?? "")
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
@@ -23,6 +23,19 @@ public nonisolated struct SSHKeyboardInteractiveChallenge: Identifiable, Hashabl
public var name: String
public var instruction: String
public var prompts: [Prompt]
/// A Tailscale SSH banner needs browser approval, not keyboard-interactive credentials.
public var isTailscaleApproval: Bool
public var isInformational: Bool { prompts.isEmpty && !isTailscaleApproval }
public var tailscaleApprovalURL: URL? {
guard isTailscaleApproval else { return nil }
return instruction.split(whereSeparator: \.isWhitespace).compactMap { word in
guard let url = URL(string: String(word)), url.scheme == "https", url.host != nil,
url.user == nil, url.password == nil
else { return nil as URL? }
return url
}.first
}
public var address: String {
let host = hostname.contains(":") ? "[\(hostname)]" : hostname
@@ -32,7 +45,7 @@ public nonisolated struct SSHKeyboardInteractiveChallenge: Identifiable, Hashabl
public init(
id: UUID = UUID(), connectionID: UUID, hostID: UUID, hostname: String,
port: Int, username: String, round: Int, name: String, instruction: String,
prompts: [Prompt]
prompts: [Prompt], isTailscaleApproval: Bool = false
) {
self.id = id
self.connectionID = connectionID
@@ -44,6 +57,7 @@ public nonisolated struct SSHKeyboardInteractiveChallenge: Identifiable, Hashabl
self.name = name
self.instruction = instruction
self.prompts = prompts
self.isTailscaleApproval = isTailscaleApproval
}
}
@@ -78,7 +92,7 @@ public final class SSHKeyboardInteractiveQueue {
}
waiters[challenge.id] = continuation
pending.append(challenge)
if challenge.prompts.isEmpty {
if challenge.isInformational {
Task { [weak self] in
await Task.yield()
self?.respond(challenge.id, answers: [])
@@ -0,0 +1,42 @@
import Foundation
import TermindDomain
/// A group-bound dialer. Failed Tailnet connections never retry through the system network.
public nonisolated protocol SSHNetworkDialing: Sendable {
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity>
}
@MainActor
public protocol TailscaleConnectionProviding: AnyObject {
func connection(for groupID: UUID) throws -> any SSHNetworkDialing
}
public nonisolated struct TailscaleLocalState: Sendable {
public var groupID: UUID
public var profileID: UUID
public var peers: [TailscalePeer]
public var hostname: String?
public init(groupID: UUID, profileID: UUID, peers: [TailscalePeer] = [], hostname: String? = nil) {
self.groupID = groupID
self.profileID = profileID
self.peers = peers
self.hostname = hostname
}
}
@MainActor
public protocol TailscaleLocalStoring: AnyObject {
func load() throws -> [TailscaleLocalState]
func save(_ state: TailscaleLocalState) throws
func remove(groupID: UUID) throws
}
@MainActor
public final class InMemoryTailscaleLocalStore: TailscaleLocalStoring {
private var states: [UUID: TailscaleLocalState] = [:]
public init() {}
public func load() throws -> [TailscaleLocalState] { Array(states.values) }
public func save(_ state: TailscaleLocalState) throws { states[state.groupID] = state }
public func remove(groupID: UUID) throws { states[groupID] = nil }
}
@@ -0,0 +1,16 @@
import Foundation
public nonisolated struct TailscalePingResult: Sendable, Equatable {
public enum Route: Sendable, Equatable {
case direct(String)
case derp(String)
case peerRelay(String)
case unknown
}
public var milliseconds: Double
public var route: Route
public init(milliseconds: Double, route: Route) {
self.milliseconds = milliseconds
self.route = route
}
}
@@ -10,6 +10,10 @@ import struct TermindDomain.Host
/// types for the views. Previews can still construct an in-memory store directly.
@Observable
public final class HostLibraryModel: RemoteSessionHostStore {
@ObservationIgnored public weak var tailscaleConnections: (any TailscaleConnectionProviding)?
@ObservationIgnored public var didChangeTailscaleGroups: (() -> Void)?
@ObservationIgnored public var didDeleteTailscaleGroups: ((Set<UUID>, Set<UUID>) -> Void)?
private var tailscalePeers: [UUID: [TailscalePeer]] = [:]
public private(set) var hosts: [Host] = [] {
didSet {
for old in oldValue where old.agentForwardingEnabled {
@@ -347,7 +351,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
}
public func identity(for host: Host, protocol connectionProtocol: HostTerminalProtocol) -> Identity? {
host.identityID(for: connectionProtocol).flatMap { identity($0) }
guard !host.isTailscaleManaged else { return nil }
return host.identityID(for: connectionProtocol).flatMap { identity($0) }
}
/// `user@host:port` — what the UI shows almost everywhere a host is named.
@@ -361,7 +366,11 @@ public final class HostLibraryModel: RemoteSessionHostStore {
public func loginAddress(for host: Host, protocol connectionProtocol: HostTerminalProtocol) -> String {
let address = host.displayAddress(for: connectionProtocol)
guard let username = identity(for: host, protocol: connectionProtocol)?.username, !username.isEmpty else {
guard
let username = host.isTailscaleManaged
? savedTailscaleUsername(for: host) : identity(for: host, protocol: connectionProtocol)?.username,
!username.isEmpty
else {
return address
}
return "\(username)@\(address)"
@@ -381,7 +390,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
/// The pinned host keys for a saved host, matched the way OpenSSH matches:
/// hostname plus port. Newest keys are shown first in the host's details.
public func knownHosts(for host: Host) -> [KnownHostEntry] {
knownHosts
guard !host.isTailscaleManaged else { return [] }
return
knownHosts
.filter { $0.matches(hostname: host.host, port: host.port) }
.sorted {
if $0.addedAt != $1.addedAt { return $0.addedAt > $1.addedAt }
@@ -526,11 +537,18 @@ public final class HostLibraryModel: RemoteSessionHostStore {
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
let submissions = route.submissions(merging: submissions)
return SSHConnectionPreparation(
nodes: route.hosts.map { node in
nodes: try route.hosts.map { node in
if node.isTailscaleManaged {
var prepared = SSHConnectionPreparation.Node(
host: node, identity: nil, key: nil, submission: nil, trustedKeys: [], proxyIdentity: nil)
prepared.network = try tailscaleNetwork(for: node)
prepared.tailscaleUsername = submissions[node.id]?.username ?? tailscaleUsername(for: node)
return prepared
}
let submission = submissions[node.id]
let identity = submission?.identityID.flatMap { self.identity($0) } ?? identity(for: node)
let keyID = submission != nil ? submission?.keyID : identity?.keyID
return SSHConnectionPreparation.Node(
var prepared = SSHConnectionPreparation.Node(
host: node, identity: identity,
key: keyID.flatMap { key($0) },
submission: submission,
@@ -540,6 +558,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
.flatMap { self.identity($0) },
proxySubmission: proxySubmissions[node.id]
)
prepared.network = try tailscaleNetwork(for: node)
return prepared
},
secrets: keychain
)
@@ -552,6 +572,19 @@ public final class HostLibraryModel: RemoteSessionHostStore {
temporaryHostKeys: [Host.ID: SSHHostKeyIdentity] = [:],
trustPolicy: SSHHostTrustPolicy = .requireKnownHost
) throws -> SSHConnectionConfiguration {
if host.isTailscaleManaged {
let username = (submissions[host.id]?.username ?? tailscaleUsername(for: host))
.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TailscaleConfigurationError.unavailable }
var configuration = SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: 22, username: username,
credentials: [], expectedHostKeys: [], charset: host.charset,
authenticationReference: SSHAuthenticationReference(username: username, permitsNone: true)
)
configuration.network = try tailscaleNetwork(for: host)
configuration.tailscaleNodeID = host.tailscale?.stableNodeID
return configuration
}
let username: String
let credentials: [SSHCredentials]
if let submission = submissions[host.id] {
@@ -579,7 +612,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
trustPolicy: trustPolicy
)
return SSHConnectionConfiguration(
var configuration = SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: host.port, username: username,
credentials: credentials,
expectedHostKeys: expectedHostKeys,
@@ -594,6 +627,8 @@ public final class HostLibraryModel: RemoteSessionHostStore {
host: host, submission: submissions[host.id]
)
)
configuration.network = try tailscaleNetwork(for: route(for: host).first ?? host)
return configuration
}
public func sshJumpConfigurations(
@@ -907,11 +942,12 @@ public final class HostLibraryModel: RemoteSessionHostStore {
}
public func hosts(matching query: String, for useCase: HostUseCase) -> [Host] {
hosts(matching: query, requiring: useCase.protocolRequirement)
hosts(matching: query, requiring: useCase.protocolRequirement).filter { isEligible($0, for: useCase) }
}
public func isEligible(_ host: Host, for useCase: HostUseCase) -> Bool {
host.supports(useCase.protocolRequirement)
if host.isTailscaleManaged && [.jumpHost, .keyExport, .portForwarding].contains(useCase) { return false }
return host.supports(useCase.protocolRequirement)
}
public func require(_ host: Host, for useCase: HostUseCase) throws {
@@ -950,6 +986,9 @@ public final class HostLibraryModel: RemoteSessionHostStore {
@discardableResult
public func saveHost(_ host: Host) -> Bool {
if self.host(host.id)?.isTailscaleManaged == true || host.isTailscaleManaged {
return saveTailscaleHost(host)
}
var host = host
host.name = SavedName.resolve(host.name, default: host.defaultName)
host.portForwardingMaxConnections = Host.normalizedPortForwardingMaxConnections(
@@ -994,6 +1033,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
/// The repository is the only writer; this keeps legacy views in sync until
/// they consume `HostLibraryModel` directly.
public func projectHostDeletion(_ hostID: Host.ID) {
guard host(hostID)?.isTailscaleManaged != true else { return }
let owned = identities.filter { $0.ownerHostID == hostID }
for identity in owned where !identity.credentialRef.isEmpty {
do {
@@ -1048,8 +1088,23 @@ public final class HostLibraryModel: RemoteSessionHostStore {
else { return }
guard hosts[index].detectedDistribution != next else { return }
hosts[index].detectedDistribution = next
persistCore()
if hosts[index].isTailscaleManaged {
guard let groupIndex = groups.firstIndex(where: { $0.id == hosts[index].groupID }),
groups[groupIndex].tailscale != nil
else { return }
let previous = groups
var preferences = groups[groupIndex].tailscale!.hostPreferences[hostID] ?? TailscaleHostPreferences()
preferences.detectedDistribution = next
groups[groupIndex].tailscale!.hostPreferences[hostID] = preferences
guard persistCore() else {
groups = previous
return
}
rebuildTailscaleHosts()
} else {
hosts[index].detectedDistribution = next
persistCore()
}
}
private func refreshStatus(for hostID: Host.ID) {
@@ -1653,6 +1708,13 @@ public final class HostLibraryModel: RemoteSessionHostStore {
/// rather than at the end of the flat array — `sortIndex` is per-parent, so
/// a child of Production has to be numbered against Production's children.
public func upsert(_ group: HostGroup) {
if let configuration = group.tailscale,
groups.contains(where: { $0.id != group.id && $0.tailscale?.network == configuration.network })
{
storageError = TailscaleConfigurationError.duplicateNetwork.localizedDescription
return
}
let previous = groups
if let index = groups.firstIndex(where: { $0.id == group.id }) {
groups[index] = group
} else {
@@ -1660,7 +1722,12 @@ public final class HostLibraryModel: RemoteSessionHostStore {
new.sortIndex = nextSortIndex(under: group.parentID)
groups.append(new)
}
persistCore()
guard persistCore() else {
groups = previous
return
}
rebuildTailscaleHosts()
didChangeTailscaleGroups?()
}
/// Deletes a group and every group beneath it.
@@ -1669,12 +1736,24 @@ public final class HostLibraryModel: RemoteSessionHostStore {
/// for any hosts they do not intend to keep.
public func deleteGroupProjection(_ groupID: HostGroup.ID) {
let doomed = Set([groupID] + descendantGroups(of: groupID).map(\.id))
let tailnets = Set(groups.filter { doomed.contains($0.id) && $0.tailscale != nil }.map(\.id))
let managed = Set(hosts.filter { $0.tailscale.map { doomed.contains($0.groupID) } == true }.map(\.id))
let previousHosts = hosts
let previousGroups = groups
hosts.removeAll { managed.contains($0.id) }
for index in hosts.indices where hosts[index].groupID.map(doomed.contains) ?? false {
hosts[index].groupID = nil
}
groups.removeAll { doomed.contains($0.id) }
guard persistCore() else {
hosts = previousHosts
groups = previousGroups
return
}
for id in doomed { tailscalePeers[id] = nil }
reconcileExpansionState()
persistCore()
didDeleteTailscaleGroups?(tailnets, managed)
didChangeTailscaleGroups?()
}
public func upsert(_ snippet: Snippet) {
@@ -1827,6 +1906,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
var visited: Set<Host.ID> = []
while let current = cursor {
guard current.sshEnabled else { return nil }
if current.isTailscaleManaged && (current.id != proposed.id || current.jumpHostID != nil) { return nil }
guard visited.insert(current.id).inserted, route.count < 8 else { return nil }
route.append(current)
guard let jumpID = current.jumpHostID else { break }
@@ -2003,6 +2083,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
/// Updating every host before persisting makes a multi-selection one logical
/// drag instead of a series of partially saved moves.
public func move(hostIDs: [Host.ID], into group: HostGroup.ID?, before next: Host.ID? = nil) {
guard hostIDs.allSatisfy({ host($0)?.isTailscaleManaged != true }) else { return }
if let group, self.group(group) == nil { return }
guard
let placement = manualOrderPlacement(
@@ -2079,6 +2160,17 @@ public final class HostLibraryModel: RemoteSessionHostStore {
authenticationHostID: Host.ID? = nil, suppliedPassword: String? = nil
) throws -> SSHConnectionRoute {
guard route.isValid else { throw SSHConnectionRoute.invalidRouteError }
if let managed = route.hosts.first(where: \.isTailscaleManaged) {
guard route.hosts.count == 1, host(managed.id)?.tailscale == managed.tailscale else {
throw TailscaleConfigurationError.managedHost
}
if let username = authentication?.username ?? route.usernames[managed.id],
!saveTailscaleUsername(username, hostID: managed.id)
{
throw TailscaleConfigurationError.unavailable
}
return route
}
var batch = SSHImportBatch()
var saved: [Host] = []
var usernames: [Host.ID: String] = [:]
@@ -2260,7 +2352,7 @@ public final class HostLibraryModel: RemoteSessionHostStore {
do {
try vault.save(
VaultSnapshot(
hosts: hosts, groups: groups, identities: identities,
hosts: hosts.filter { !$0.isTailscaleManaged }, groups: groups, identities: identities,
keys: keys, knownHosts: knownHosts, snippets: snippets,
snippetPackages: snippetPackages, portForwardings: portForwardings,
workspaceTemplates: workspaceTemplates
@@ -2277,8 +2369,14 @@ public final class HostLibraryModel: RemoteSessionHostStore {
/// Applies a CloudKit-imported projection without writing it back as a new
/// local edit. Runtime connection and transfer state intentionally remain.
public func replacePersistedSnapshot(_ snapshot: VaultSnapshot) {
let removedTailnets = Set(groups.filter { $0.tailscale != nil }.map(\.id))
.subtracting(snapshot.groups.filter { $0.tailscale != nil }.map(\.id))
let removedManaged = Set(
hosts.filter { $0.tailscale.map { removedTailnets.contains($0.groupID) } == true }.map(\.id))
hosts = snapshot.hosts
groups = snapshot.groups
rebuildTailscaleHosts()
identities = normalizedManualOrder(
snapshot.identities, sortIndex: \Identity.sortIndex, name: \Identity.name
)
@@ -2297,6 +2395,119 @@ public final class HostLibraryModel: RemoteSessionHostStore {
reconcileExpansionState()
storageError = nil
vault.didApplyRemoteSnapshot()
didDeleteTailscaleGroups?(removedTailnets, removedManaged)
didChangeTailscaleGroups?()
}
public func tailscaleGroup(for host: Host) -> HostGroup? {
var next = (self.host(host.id) ?? host).groupID
var visited = Set<UUID>()
while let id = next, visited.insert(id).inserted, let group = group(id) {
if group.tailscale != nil { return group }
next = group.parentID
}
return nil
}
public func networkConfigurationStamp(for host: Host) -> Data {
guard let group = tailscaleGroup(for: host) else { return Data() }
return (try? JSONEncoder().encode(group.tailscale)) ?? Data()
}
public func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)? {
guard let group = tailscaleGroup(for: host) else {
if host.isTailscaleManaged { throw TailscaleConfigurationError.unavailable }
return nil
}
guard let tailscaleConnections else { throw TailscaleConfigurationError.unavailable }
return try tailscaleConnections.connection(for: group.id)
}
public func tailscaleUsername(for host: Host) -> String {
savedTailscaleUsername(for: host) ?? "root"
}
public func savedTailscaleUsername(for host: Host) -> String? {
guard let configuration = tailscaleGroup(for: host)?.tailscale else { return nil }
let override = configuration.hostPreferences[host.id]?.username ?? ""
let username = (override.isEmpty ? configuration.username : override)
.trimmingCharacters(in: .whitespacesAndNewlines)
return username.isEmpty ? nil : username
}
public func replaceTailscalePeers(_ peers: [TailscalePeer], groupID: UUID) {
tailscalePeers[groupID] = peers
rebuildTailscaleHosts()
}
private func rebuildTailscaleHosts() {
var projected = hosts.filter { !$0.isTailscaleManaged }
var ids = Set(projected.map(\.id))
for group in groups {
guard let configuration = group.tailscale else { continue }
for peer in tailscalePeers[group.id] ?? [] where !peer.stableID.isEmpty && peer.supportsSSH {
let id = configuration.network.hostID(stableNodeID: peer.stableID)
guard ids.insert(id).inserted else { continue }
let preferences = configuration.hostPreferences[id] ?? TailscaleHostPreferences()
var host = Host(id: id, name: peer.name, host: peer.dnsName, groupID: group.id)
host.tailscale = TailscaleHostReference(groupID: group.id, stableNodeID: peer.stableID)
host.detectedDistribution = preferences.detectedDistribution
host.tags = preferences.tags
host.colorTag = preferences.colorTag
host.sshCharset = preferences.charset
host.sftpChannelInitialPath = preferences.sftpInitialPath
host.shellIntegrationMode = preferences.shellIntegrationMode
host.startupSnippetID = preferences.startupSnippetID
host.environmentVariables = preferences.environmentVariables
projected.append(host)
}
}
hosts = projected
}
public func saveTailscaleHost(_ host: Host, username: String? = nil) -> Bool {
guard let existing = self.host(host.id), existing.isTailscaleManaged,
host.tailscale == existing.tailscale, host.groupID == existing.groupID,
host.name == existing.name, host.host == existing.host, host.port == 22,
host.identityID == nil, host.jumpHostID == nil, host.proxy == nil,
!host.telnetEnabled, !host.agentForwardingEnabled,
let index = groups.firstIndex(where: { $0.id == host.groupID }), groups[index].tailscale != nil
else {
storageError = TailscaleConfigurationError.managedHost.localizedDescription
return false
}
let previous = groups
var preferences = groups[index].tailscale!.hostPreferences[host.id] ?? TailscaleHostPreferences()
if let username { preferences.username = username.trimmingCharacters(in: .whitespacesAndNewlines) }
preferences.tags = host.tags
preferences.colorTag = host.colorTag
preferences.charset = host.sshCharset
preferences.sftpInitialPath = host.sftpChannelInitialPath
preferences.shellIntegrationMode = host.shellIntegrationMode
preferences.startupSnippetID = host.startupSnippetID
preferences.environmentVariables = host.environmentVariables
groups[index].tailscale!.hostPreferences[host.id] = preferences
guard persistCore() else {
groups = previous
return false
}
rebuildTailscaleHosts()
return true
}
public func saveTailscaleUsername(_ username: String, hostID: UUID) -> Bool {
guard let host = host(hostID), let index = groups.firstIndex(where: { $0.id == host.groupID }),
groups[index].tailscale != nil
else { return false }
let previous = groups
var preferences = groups[index].tailscale!.hostPreferences[hostID] ?? TailscaleHostPreferences()
preferences.username = username.trimmingCharacters(in: .whitespacesAndNewlines)
groups[index].tailscale!.hostPreferences[hostID] = preferences
guard persistCore() else {
groups = previous
return false
}
return true
}
}
@@ -0,0 +1,466 @@
import Foundation
import TermindDomain
import struct TermindDomain.Host
public nonisolated struct TailscaleDeviceInfo: Sendable, Equatable {
public var networkName: String
public var dnsName: String
public var hostname: String
public var addresses: [String]
public var operatingSystem: String
public var keyExpiry: Date?
public init(
networkName: String, dnsName: String, hostname: String, addresses: [String], operatingSystem: String,
keyExpiry: Date?
) {
self.networkName = networkName
self.dnsName = dnsName
self.hostname = hostname
self.addresses = addresses
self.operatingSystem = operatingSystem
self.keyExpiry = keyExpiry
}
}
public nonisolated struct TailscaleNodeStatus: Sendable {
public var running: Bool
public var network: TailscaleNetwork?
public var authURL: URL?
public var peers: [TailscalePeer]
public var device: TailscaleDeviceInfo?
public init(
running: Bool, network: TailscaleNetwork?, authURL: URL?, peers: [TailscalePeer],
device: TailscaleDeviceInfo? = nil
) {
self.running = running
self.network = network
self.authURL = authURL
self.peers = peers
self.device = device
}
}
public nonisolated protocol TailscaleNodeSession: Sendable {
func status() async throws -> TailscaleNodeStatus
func login() async throws
func logout() async throws
func close() async
func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity>
func setExitNode(_ stableID: String?) async throws
func setHostname(_ hostname: String) async throws
func ping(ip: String) async throws -> TailscalePingResult
}
extension TailscaleNodeSession {
public func setExitNode(_ stableID: String?) async throws {
if stableID != nil { throw TailscaleConfigurationError.unavailable }
}
public func setHostname(_ hostname: String) async throws { throw TailscaleConfigurationError.unavailable }
public func ping(ip: String) async throws -> TailscalePingResult { throw TailscaleConfigurationError.unavailable }
}
public typealias TailscaleNodeFactory =
@Sendable (
_ profileID: UUID, _ hostname: String, _ controlURL: String, _ authKey: String?
) async throws -> any TailscaleNodeSession
@MainActor
@Observable
public final class TailscaleModel: TailscaleConnectionProviding {
public private(set) var loginURL: URL?
public private(set) var isLoggingIn = false
public private(set) var loginCompletedGroupID: UUID?
public private(set) var error: String?
public private(set) var runningGroups: Set<UUID> = []
public private(set) var devices: [UUID: TailscaleDeviceInfo] = [:]
public private(set) var peers: [UUID: [TailscalePeer]] = [:]
@ObservationIgnored private let library: HostLibraryModel
@ObservationIgnored private let store: any TailscaleLocalStoring
@ObservationIgnored private let factory: TailscaleNodeFactory
@ObservationIgnored private let hostname: () -> String
@ObservationIgnored private let hasAccess: () -> Bool
@ObservationIgnored private var appliedExitNodes: [UUID: String] = [:]
@ObservationIgnored private var states: [UUID: TailscaleLocalState] = [:]
@ObservationIgnored private var nodes: [UUID: any TailscaleNodeSession] = [:]
@ObservationIgnored private var monitors: [UUID: Task<Void, Never>] = [:]
@ObservationIgnored private var starting: Set<UUID> = []
@ObservationIgnored private var loginTask: Task<Void, Never>?
@ObservationIgnored private var loginGeneration: UUID?
@ObservationIgnored private var pendingNode: (any TailscaleNodeSession)?
@ObservationIgnored private var started = false
@ObservationIgnored private var shuttingDown = false
public init(
library: HostLibraryModel, store: any TailscaleLocalStoring,
factory: @escaping TailscaleNodeFactory, hostname: @escaping () -> String,
hasAccess: @escaping () -> Bool
) {
self.library = library
self.store = store
self.factory = factory
self.hostname = hostname
self.hasAccess = hasAccess
library.tailscaleConnections = self
library.didChangeTailscaleGroups = { [weak self] in self?.reconcile() }
}
public var defaultHostname: String { hostname() }
public func savedHostname(groupID: UUID) -> String? { states[groupID]?.hostname }
public func exitNodeID(groupID: UUID) -> String { library.group(groupID)?.tailscale?.exitNodeID ?? "" }
public func exitNodeCandidates(groupID: UUID) -> [TailscalePeer] {
(peers[groupID] ?? []).filter { $0.exitNodeOption == true }
.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending }
}
public func saveExitNode(_ stableID: String, groupID: UUID) throws {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard var group = library.group(groupID), group.tailscale != nil else {
throw TailscaleConfigurationError.unavailable
}
guard exitNodeID(groupID: groupID) != stableID else { return }
group.tailscale?.exitNodeID = stableID.isEmpty ? nil : stableID
library.upsert(group)
if let error = library.storageError { throw TermindSSHError.library(error) }
}
public func saveHostname(_ value: String, groupID: UUID) async throws {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard var state = states[groupID] else { throw TailscaleConfigurationError.unavailable }
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
let name = trimmed.isEmpty ? hostname() : trimmed
guard state.hostname != name else { return }
if let node = nodes[groupID] { try await node.setHostname(name) }
guard states[groupID]?.profileID == state.profileID else { throw CancellationError() }
state = states[groupID] ?? state
state.hostname = name
try store.save(state)
states[groupID] = state
devices[groupID]?.hostname = name
}
public func start() {
guard !started else { return }
started = true
do {
states = Dictionary(uniqueKeysWithValues: try store.load().map { ($0.groupID, $0) })
for var state in states.values {
if state.hostname == nil {
state.hostname = hostname()
try store.save(state)
states[state.groupID] = state
}
peers[state.groupID] = state.peers
library.replaceTailscalePeers(state.peers, groupID: state.groupID)
}
reconcile()
} catch { self.error = error.localizedDescription }
}
public func reconcile() {
guard !shuttingDown else { return }
let wanted = Set(library.groups.filter { $0.tailscale != nil }.map(\.id))
for id in Set(states.keys).union(nodes.keys).subtracting(wanted) {
monitors.removeValue(forKey: id)?.cancel()
runningGroups.remove(id)
devices[id] = nil
appliedExitNodes[id] = nil
peers[id] = nil
if let node = nodes.removeValue(forKey: id) {
Task {
try? await node.logout()
await node.close()
}
}
states[id] = nil
do { try store.remove(groupID: id) } catch { self.error = error.localizedDescription }
library.replaceTailscalePeers([], groupID: id)
}
guard hasAccess() else { return }
for id in wanted where nodes[id] == nil && !starting.contains(id) {
guard let state = states[id], let configuration = library.group(id)?.tailscale else { continue }
starting.insert(id)
let name = state.hostname ?? hostname()
Task { [weak self] in
guard let self else { return }
defer { self.starting.remove(id) }
do {
let node = try await self.factory(state.profileID, name, configuration.network.controlURL, nil)
guard !self.shuttingDown, self.states[id]?.profileID == state.profileID,
self.library.group(id)?.tailscale?.network == configuration.network, self.hasAccess()
else {
await node.close()
return
}
self.nodes[id] = node
self.monitor(id, node: node)
} catch { self.error = error.localizedDescription }
}
}
}
public func beginLogin(
name: String, parentID: UUID?, controlURL: String, authKey: String?, groupID: UUID? = nil, hostname: String = ""
) {
guard hasAccess() else {
error = TailscaleConfigurationError.requiresPro.localizedDescription
return
}
cancelLogin()
let generation = UUID()
loginGeneration = generation
isLoggingIn = true
loginCompletedGroupID = nil
error = nil
let trimmedHostname = hostname.trimmingCharacters(in: .whitespacesAndNewlines)
let deviceName = trimmedHostname.isEmpty ? self.hostname() : trimmedHostname
loginTask = Task { [weak self] in
guard let self else { return }
var candidate: (any TailscaleNodeSession)?
do {
let canonical = try TailscaleNetwork(controlURL: controlURL, dnsSuffix: "pending.invalid").controlURL
let profileID = UUID()
let node = try await self.factory(profileID, deviceName, canonical, authKey)
candidate = node
try Task.checkCancellation()
self.pendingNode = node
if authKey == nil { try await node.login() }
let deadline = ContinuousClock.now.advanced(by: .seconds(600))
while ContinuousClock.now < deadline {
try Task.checkCancellation()
guard self.hasAccess() else { throw TailscaleConfigurationError.requiresPro }
let status = try await node.status()
try Task.checkCancellation()
guard self.loginGeneration == generation else { throw CancellationError() }
if let groupID, self.library.group(groupID) == nil { throw CancellationError() }
self.loginURL = status.authURL
if status.running, let network = status.network {
if let groupID, let previous = self.library.group(groupID)?.tailscale?.network,
previous != network
{
throw TailscaleConfigurationError.missingNetworkIdentity
}
if self.library.groups.contains(where: { $0.tailscale?.network == network && $0.id != groupID })
{
throw TailscaleConfigurationError.duplicateNetwork
}
var group =
groupID.flatMap(self.library.group)
?? HostGroup(id: network.groupID, name: name, parentID: parentID)
if group.tailscale == nil { group.tailscale = TailscaleGroupConfiguration(network: network) }
try await node.setExitNode(group.tailscale?.exitNodeID)
try Task.checkCancellation()
guard self.loginGeneration == generation else { throw CancellationError() }
let state = TailscaleLocalState(
groupID: group.id, profileID: profileID, peers: status.peers, hostname: deviceName)
group.name = name
group.parentID = parentID
let previousState = self.states[group.id]
try self.store.save(state)
self.library.upsert(group)
guard self.library.storageError == nil else {
if let previousState {
try self.store.save(previousState)
} else {
try self.store.remove(groupID: group.id)
}
throw TailscaleConfigurationError.unavailable
}
self.monitors.removeValue(forKey: group.id)?.cancel()
if let old = self.nodes[group.id] {
Task {
try? await old.logout()
await old.close()
}
}
self.states[group.id] = state
self.nodes[group.id] = node
self.appliedExitNodes[group.id] = group.tailscale?.exitNodeID ?? ""
self.library.replaceTailscalePeers(status.peers, groupID: group.id)
self.runningGroups.insert(group.id)
self.devices[group.id] = status.device
self.peers[group.id] = status.peers
self.monitor(group.id, node: node)
self.pendingNode = nil
candidate = nil
self.loginURL = nil
self.loginCompletedGroupID = group.id
break
}
try await Task.sleep(for: .seconds(1))
}
if candidate != nil { throw TailscaleConfigurationError.unavailable }
} catch {
if let candidate {
try? await candidate.logout()
await candidate.close()
}
if self.loginGeneration == generation && !(error is CancellationError) {
self.loginURL = nil
self.error = error.localizedDescription
}
}
if self.loginGeneration == generation {
self.isLoggingIn = false
self.pendingNode = nil
}
}
}
public func cancelLogin() {
loginGeneration = nil
loginTask?.cancel()
loginTask = nil
loginURL = nil
isLoggingIn = false
if let node = pendingNode {
Task {
try? await node.logout()
await node.close()
}
}
pendingNode = nil
}
public func logout(groupID: UUID) async {
monitors.removeValue(forKey: groupID)?.cancel()
runningGroups.remove(groupID)
devices[groupID] = nil
peers[groupID] = nil
appliedExitNodes[groupID] = nil
let node = nodes.removeValue(forKey: groupID)
states[groupID] = nil
do { try store.remove(groupID: groupID) } catch { self.error = error.localizedDescription }
library.replaceTailscalePeers([], groupID: groupID)
if let node {
try? await node.logout()
await node.close()
}
}
public func connection(for groupID: UUID) throws -> any SSHNetworkDialing {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard runningGroups.contains(groupID), let node = nodes[groupID] else {
throw TailscaleConfigurationError.loginRequired
}
guard appliedExitNodes[groupID] == exitNodeID(groupID: groupID) else {
throw TailscaleConfigurationError.settingsPending
}
return AccessDialer(
node: node,
check: { [weak self] in
guard let self, self.hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard self.runningGroups.contains(groupID) else { throw TailscaleConfigurationError.loginRequired }
guard self.appliedExitNodes[groupID] == self.exitNodeID(groupID: groupID) else {
throw TailscaleConfigurationError.settingsPending
}
})
}
public func peer(for host: Host) -> TailscalePeer? {
guard let group = library.tailscaleGroup(for: host) else { return nil }
let candidates = peers[group.id] ?? []
if let reference = host.tailscale {
return candidates.first { $0.stableID == reference.stableNodeID }
}
let address = host.host.trimmingCharacters(in: CharacterSet(charactersIn: ".")).lowercased()
return candidates.first {
$0.dnsName.lowercased() == address || $0.dnsName.split(separator: ".").first?.lowercased() == address
|| $0.details?.addresses.contains(address) == true
}
}
public func canPing(_ host: Host) -> Bool {
guard hasAccess(), let group = library.tailscaleGroup(for: host), runningGroups.contains(group.id) else {
return false
}
return peer(for: host)?.details?.addresses.isEmpty == false
}
public func ping(hostID: Host.ID) async throws -> TailscalePingResult {
guard hasAccess() else { throw TailscaleConfigurationError.requiresPro }
guard let host = library.host(hostID), let group = library.tailscaleGroup(for: host),
runningGroups.contains(group.id), let node = nodes[group.id],
let ip = peer(for: host)?.details?.addresses.first
else { throw TailscaleConfigurationError.unavailable }
let result = try await node.ping(ip: ip)
try Task.checkCancellation()
guard hasAccess(), runningGroups.contains(group.id), library.tailscaleGroup(for: host)?.id == group.id else {
throw TailscaleConfigurationError.unavailable
}
return result
}
public func shutdown() async {
shuttingDown = true
cancelLogin()
for task in monitors.values { task.cancel() }
monitors = [:]
for node in nodes.values { await node.close() }
nodes = [:]
appliedExitNodes = [:]
runningGroups = []
devices = [:]
peers = [:]
}
private func monitor(_ id: UUID, node: any TailscaleNodeSession) {
monitors.removeValue(forKey: id)?.cancel()
monitors[id] = Task { [weak self] in
while !Task.isCancelled {
guard let self else { return }
do {
let status = try await node.status()
try Task.checkCancellation()
guard let network = self.library.group(id)?.tailscale?.network else { return }
if status.running && status.network == network {
let desiredExitNode = self.exitNodeID(groupID: id)
if self.appliedExitNodes[id] != desiredExitNode {
guard self.hasAccess() else { throw TailscaleConfigurationError.requiresPro }
try await node.setExitNode(desiredExitNode.isEmpty ? nil : desiredExitNode)
try Task.checkCancellation()
self.appliedExitNodes[id] = desiredExitNode
guard self.exitNodeID(groupID: id) == desiredExitNode else { continue }
}
self.runningGroups.insert(id)
self.devices[id] = status.device
self.peers[id] = status.peers
self.library.replaceTailscalePeers(status.peers, groupID: id)
if var state = self.states[id], state.peers != status.peers {
state.peers = status.peers
try self.store.save(state)
self.states[id] = state
}
} else {
self.runningGroups.remove(id)
self.devices[id] = nil
}
} catch {
if Task.isCancelled { return }
self.runningGroups.remove(id)
self.devices[id] = nil
self.error = error.localizedDescription
}
do { try await Task.sleep(for: .seconds(3)) } catch { return }
}
}
}
}
private nonisolated struct AccessDialer: SSHNetworkDialing {
let node: any TailscaleNodeSession
let check: @MainActor @Sendable () throws -> Void
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 {
try await check()
return try await node.dial(host: host, port: port, timeout: timeout)
}
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> {
try await check()
return try await node.hostKeys(stableNodeID: stableNodeID)
}
}
@@ -189,7 +189,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
var seen = Set(configuredSelections.map(\.id))
let additions = hosts.filter { seen.insert($0.id).inserted }
guard requestAddition(count: additions.count) else { return false }
let next = configuredSelections + additions.map { MetricsSelection(id: $0.id) }
let next = configuredSelections + additions.map { selection(for: $0) }
guard persist(next) else { return false }
applyConfiguration(next)
return true
@@ -210,7 +210,8 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
let retained = configuredSelections.filter {
!editableHostIDs.contains($0.id) || requested.contains($0.id)
}
let next = retained + additions.map { MetricsSelection(id: $0) }
let next =
retained + additions.map { id in hosts.host(id).map { selection(for: $0) } ?? MetricsSelection(id: id) }
guard next != configuredSelections else { return true }
guard requestAddition(count: max(0, next.count - configuredSelections.count)) else { return false }
guard persist(next) else { return false }
@@ -218,6 +219,19 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
return true
}
private func selection(for host: Host) -> MetricsSelection {
var selection = MetricsSelection(id: host.id)
selection.tailscaleGroupID = host.tailscale?.groupID
if host.isTailscaleManaged { selection.displayName = host.name }
return selection
}
public func removeTailscaleGroups(_ ids: Set<UUID>) {
let next = configuredSelections.filter { $0.tailscaleGroupID.map { !ids.contains($0) } ?? true }
guard next != configuredSelections, persist(next) else { return }
applyConfiguration(next)
}
public func move(_ ids: [UUID], before next: UUID?) {
let wanted = Set(ids)
let moving = configuredSelections.filter { wanted.contains($0.id) }
@@ -309,6 +323,16 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
}
return
}
if host.isTailscaleManaged {
if save, !hosts.saveTailscaleUsername(name, hostID: host.id) {
server.status = TailscaleConfigurationError.unavailable.localizedDescription
server.journey.fail(server.status)
return
}
submissions[server.id, default: [:]][host.id] = SSHAuthenticationSubmission(username: name)
retry(server)
return
}
let chosenKey = identity?.keyID ?? keyID
if save {
let saved = identity ?? Identity(name: name, username: name, keyID: chosenKey)
@@ -380,7 +404,9 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
mode: error.isServerAuthenticationChallenge ? .serverChallenge : .localIdentity,
requiresPassphrase: passphrase)
server.needsAttention = true
if error.isServerAuthenticationChallenge {
if host.isTailscaleManaged {
server.journey.waitForTailscaleUsername(at: host.id, failure: error.localizedDescription)
} else if error.isServerAuthenticationChallenge {
server.journey.waitForAuthentication(at: host.id, failure: error.localizedDescription)
} else if passphrase {
server.journey.waitForLocalPassphrase(at: host.id, failure: error.localizedDescription)
@@ -420,7 +446,17 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
public func reconcile() {
applyConfiguration(configuredSelections)
for server in servers {
guard let host = hosts.host(server.id) else { continue }
guard let host = hosts.host(server.id) else {
cancel(server.id)
continue
}
do { _ = try hosts.tailscaleNetwork(for: host) } catch {
cancel(server.id)
server.isConnecting = false
server.status = error.localizedDescription
server.collectionState = .failed
continue
}
server.name = host.name
guard host.sshEnabled else {
cancel(server.id)
@@ -448,6 +484,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
encoder.outputFormatting = .sortedKeys
var result = (try? encoder.encode(route)) ?? Data()
for item in route {
result.append(hosts.networkConfigurationStamp(for: item))
if let identity = hosts.identity(for: item) {
result.append((try? encoder.encode(identity)) ?? Data())
if let id = identity.keyID, let key = hosts.key(id) {
@@ -477,14 +514,16 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
var nextServers: [ServerMetric] = []
for selection in normalized {
guard let host = hosts.host(selection.id) else { continue }
let host = hosts.host(selection.id)
if host == nil && selection.tailscaleGroupID == nil { continue }
let name = host?.name ?? selection.displayName ?? selection.id.uuidString
if let server = previous[selection.id] {
let enabledChanged = server.selection.enabled != selection.enabled
let devicesChanged =
server.selection.disks != selection.disks
|| server.selection.interfaces != selection.interfaces
server.selection = selection
server.name = host.name
server.name = name
if enabledChanged || devicesChanged {
cancel(server.id)
server.isConnecting = false
@@ -505,7 +544,7 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
}
nextServers.append(server)
} else {
nextServers.append(ServerMetric(selection: selection, name: host.name))
nextServers.append(ServerMetric(selection: selection, name: name))
}
}
@@ -569,6 +608,12 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
server.journey.fail(message)
return
}
do { _ = try hosts.tailscaleNetwork(for: host) } catch {
server.isConnecting = false
server.status = error.localizedDescription
server.collectionState = .failed
return
}
beginConnectionJourney(for: server, host: host)
let token = UUID()
generations[server.id] = token
@@ -763,7 +808,9 @@ public final class ServerMetricsModel: MetricsSnapshotReading {
}
private func isCurrent(_ serverID: UUID, token: UUID) -> Bool {
current(serverID, token)
guard current(serverID, token), let host = hosts.host(serverID) else { return false }
do { _ = try hosts.tailscaleNetwork(for: host) } catch { return false }
return true
}
private func publishConnected(_ serverID: UUID, token: UUID, hostID: UUID) -> Bool {
@@ -450,6 +450,8 @@ public nonisolated struct SSHAuthenticationReference: Hashable, Sendable {
}
public nonisolated struct SSHConnectionConfiguration: Sendable {
public var network: (any SSHNetworkDialing)?
public var tailscaleNodeID: String?
public var hostID: Host.ID
public var host: String
public var port: Int
@@ -1,6 +1,7 @@
import Foundation
public nonisolated enum ProFeature: String, CaseIterable, Hashable, Sendable {
case tailscale
case assistant
case remoteMCP
case mcpServer
+7 -1
View File
@@ -123,6 +123,9 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
public var id: UUID = UUID()
public var name: String
/// Present only on the device-local projection of a discovered Tailscale peer.
public var tailscale: TailscaleHostReference?
public var isTailscaleManaged: Bool { tailscale != nil }
/// Where to connect: an IP literal (v4 or v6) or a DNS name. Named `host`
/// rather than `hostname` because an IP address is not a hostname, and the
/// field accepts either.
@@ -315,7 +318,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
}
private enum CodingKeys: String, CodingKey {
case id, name, host, port, identityID, jumpHostID, proxy
case id, name, host, port, identityID, jumpHostID, proxy, tailscale
case sftpChannelInitialPath = "sftpSessionInitialPath"
case charset
case sshEnabled, telnetEnabled, telnetPort, telnetIdentityID, telnetCharset, telnetProxy
@@ -326,6 +329,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
id = try values.decode(UUID.self, forKey: .id)
tailscale = try values.decodeIfPresent(TailscaleHostReference.self, forKey: .tailscale)
name = try values.decode(String.self, forKey: .name)
host = try values.decode(String.self, forKey: .host)
sshEnabled = try values.decodeIfPresent(Bool.self, forKey: .sshEnabled) ?? true
@@ -364,6 +368,7 @@ public nonisolated struct Host: Identifiable, Codable, Hashable, Sendable {
public func encode(to encoder: any Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(id, forKey: .id)
try values.encodeIfPresent(tailscale, forKey: .tailscale)
try values.encode(name, forKey: .name)
try values.encode(host, forKey: .host)
try values.encode(sshPort, forKey: .port)
@@ -409,6 +414,7 @@ public nonisolated struct HostGroup: Identifiable, Codable, Hashable, Sendable {
public var parentID: HostGroup.ID?
public var sortIndex: Double = 0
public var isExpanded: Bool = true
public var tailscale: TailscaleGroupConfiguration?
public init(
id: UUID = UUID(), name: String, parentID: HostGroup.ID? = nil, sortIndex: Double = 0, isExpanded: Bool = true
) {
@@ -0,0 +1,200 @@
import CryptoKit
import Foundation
/// Public control-plane identity. Neither a login nor a device key belongs here.
public nonisolated struct TailscaleNetwork: Codable, Hashable, Sendable {
public static let defaultControlURL = "https://controlplane.tailscale.com"
public var controlURL: String
public var dnsSuffix: String
public init(controlURL: String, dnsSuffix: String) throws {
guard var url = URLComponents(string: controlURL.trimmingCharacters(in: .whitespacesAndNewlines)),
url.scheme?.lowercased() == "https", let hostname = url.host, !hostname.isEmpty,
url.user == nil, url.password == nil, url.query == nil, url.fragment == nil
else { throw TailscaleConfigurationError.invalidControlURL }
url.scheme = "https"
url.host = hostname.lowercased()
if url.port == 443 { url.port = nil }
while url.path.hasSuffix("/") { url.path.removeLast() }
if url.host == "login.tailscale.com", url.port == nil, url.path.isEmpty {
url.host = "controlplane.tailscale.com"
}
guard let normalized = url.string else { throw TailscaleConfigurationError.invalidControlURL }
let suffix = dnsSuffix.trimmingCharacters(in: CharacterSet(charactersIn: ". ")).lowercased()
guard !suffix.isEmpty, !suffix.contains(where: { $0.isWhitespace || $0 == "/" }) else {
throw TailscaleConfigurationError.missingNetworkIdentity
}
self.controlURL = normalized
self.dnsSuffix = suffix
}
private enum CodingKeys: String, CodingKey { case controlURL, dnsSuffix }
public init(from decoder: any Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
try self.init(
controlURL: container.decode(String.self, forKey: .controlURL),
dnsSuffix: container.decode(String.self, forKey: .dnsSuffix))
}
public var groupID: UUID { Self.identifier(["group", controlURL, dnsSuffix]) }
public func hostID(stableNodeID: String) -> UUID {
Self.identifier(["host", controlURL, stableNodeID])
}
/// UUIDv5 with the RFC URL namespace and a length-delimited, versioned name.
/// The algorithm is a storage contract; DNS names and IPs never identify nodes.
private static func identifier(_ components: [String]) -> UUID {
let namespace: [UInt8] = [
0x6b, 0xa7, 0xb8, 0x11, 0x9d, 0xad, 0x11, 0xd1,
0x80, 0xb4, 0x00, 0xc0, 0x4f, 0xd4, 0x30, 0xc8,
]
let name = "termind.tailscale.v1:" + components.map { "\($0.utf8.count):\($0)" }.joined()
var bytes = Array(Insecure.SHA1.hash(data: Data(namespace) + Data(name.utf8)).prefix(16))
bytes[6] = (bytes[6] & 0x0f) | 0x50
bytes[8] = (bytes[8] & 0x3f) | 0x80
return UUID(
uuid: (
bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
))
}
}
public nonisolated enum TailscaleConfigurationError: LocalizedError, Sendable {
case invalidControlURL, missingNetworkIdentity, duplicateNetwork, managedHost, unavailable, requiresPro,
hostKeyMismatch, loginRequired, peerSSHUnavailable, approvalTimedOut, settingsPending
public var errorDescription: String? {
switch self {
case .settingsPending:
String(localized: "Tailnet settings are being applied. Try connecting again in a moment.")
case .loginRequired:
String(localized: "This device is not connected to the Tailnet. Log in from the group editor.")
case .peerSSHUnavailable:
String(localized: "This peer is not advertising Tailscale SSH host keys. Enable Tailscale SSH on the peer.")
case .approvalTimedOut:
String(localized: "Tailscale SSH approval timed out. Connect again to request approval.")
case .invalidControlURL: String(localized: "Enter a valid HTTPS Controlplane URL.")
case .duplicateNetwork: String(localized: "This Tailnet already belongs to another group.")
case .requiresPro: String(localized: "Tailscale requires Termind Pro.")
case .hostKeyMismatch:
String(localized: "The SSH host key does not match the keys provided by Tailscale.")
case .managedHost:
String(localized: "Tailscale manages this host’s address, authentication, and SSH host keys.")
case .missingNetworkIdentity, .unavailable:
String(
localized:
"This Tailscale connection is unavailable. Check the Tailnet login, SSH policy, and username.")
}
}
}
public nonisolated struct TailscaleHostPreferences: Codable, Hashable, Sendable {
public var tags: [String] = []
public var colorTag: TagColor = .none
public var detectedDistribution: DetectedDistribution?
public var username = ""
public var charset: RemoteCharset = .utf8
public var sftpInitialPath = ""
public var shellIntegrationMode: HostShellIntegrationMode = .followGlobalSettings
public var startupSnippetID: UUID?
public var environmentVariables: [HostEnvironmentVariable] = []
public init() {}
private enum CodingKeys: String, CodingKey {
case tags, colorTag, username, charset, sftpInitialPath, shellIntegrationMode, startupSnippetID,
environmentVariables, detectedDistribution
}
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
tags = try values.decodeIfPresent([String].self, forKey: .tags) ?? []
colorTag = try values.decodeIfPresent(TagColor.self, forKey: .colorTag) ?? .none
detectedDistribution = try values.decodeIfPresent(DetectedDistribution.self, forKey: .detectedDistribution)
username = try values.decode(String.self, forKey: .username)
charset = try values.decode(RemoteCharset.self, forKey: .charset)
sftpInitialPath = try values.decode(String.self, forKey: .sftpInitialPath)
shellIntegrationMode = try values.decode(HostShellIntegrationMode.self, forKey: .shellIntegrationMode)
startupSnippetID = try values.decodeIfPresent(UUID.self, forKey: .startupSnippetID)
environmentVariables = try values.decode([HostEnvironmentVariable].self, forKey: .environmentVariables)
}
}
public nonisolated struct TailscaleGroupConfiguration: Codable, Hashable, Sendable {
public var network: TailscaleNetwork
public var username = ""
public var exitNodeID: String?
public var hostPreferences: [UUID: TailscaleHostPreferences] = [:]
public init(network: TailscaleNetwork) { self.network = network }
private enum CodingKeys: String, CodingKey { case network, username, hostPreferences, exitNodeID }
public init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
network = try values.decode(TailscaleNetwork.self, forKey: .network)
exitNodeID = try values.decodeIfPresent(String.self, forKey: .exitNodeID)
username = try values.decode(String.self, forKey: .username)
let encoded = try values.decode([String: TailscaleHostPreferences].self, forKey: .hostPreferences)
for (key, value) in encoded {
guard let id = UUID(uuidString: key), id.uuidString.lowercased() == key else {
throw DecodingError.dataCorruptedError(
forKey: .hostPreferences, in: values, debugDescription: "Invalid host identifier")
}
hostPreferences[id] = value
}
}
public func encode(to encoder: any Encoder) throws {
var values = encoder.container(keyedBy: CodingKeys.self)
try values.encode(network, forKey: .network)
try values.encode(username, forKey: .username)
try values.encodeIfPresent(exitNodeID, forKey: .exitNodeID)
try values.encode(
Dictionary(uniqueKeysWithValues: hostPreferences.map { ($0.key.uuidString.lowercased(), $0.value) }),
forKey: .hostPreferences)
}
}
/// Device-local discovery, never a Vault Host record.
public nonisolated struct TailscalePeerDetails: Codable, Hashable, Sendable {
public var addresses: [String]
public var operatingSystem: String
public var keyExpiry: Date?
public var lastSeen: Date?
public init(addresses: [String], operatingSystem: String, keyExpiry: Date? = nil, lastSeen: Date? = nil) {
self.addresses = addresses
self.operatingSystem = operatingSystem
self.keyExpiry = keyExpiry
self.lastSeen = lastSeen
}
}
public nonisolated struct TailscalePeer: Codable, Hashable, Sendable {
public var stableID: String
public var name: String
public var dnsName: String
public var online: Bool
public var sshHostKeys: [String]
public var exitNodeOption: Bool?
public var details: TailscalePeerDetails?
public var supportsSSH: Bool { !sshHostKeys.isEmpty }
public init(
stableID: String, name: String, dnsName: String, online: Bool, sshHostKeys: [String],
details: TailscalePeerDetails? = nil, exitNodeOption: Bool? = nil
) {
self.stableID = stableID
self.name = name
self.dnsName = dnsName
self.online = online
self.sshHostKeys = sshHostKeys
self.details = details
self.exitNodeOption = exitNodeOption
}
}
public nonisolated struct TailscaleHostReference: Codable, Hashable, Sendable {
public var groupID: UUID
public var stableNodeID: String
public init(groupID: UUID, stableNodeID: String) {
self.groupID = groupID
self.stableNodeID = stableNodeID
}
}
@@ -46,7 +46,7 @@ public nonisolated enum MetricsRecordPayload: Codable, Hashable, Sendable {
}
public nonisolated struct MetricsEnvelope: Codable, Hashable, Sendable {
public static let currentFormatVersion = 1
public static let currentFormatVersion = 2
public var formatVersion = Self.currentFormatVersion
public var key: MetricsRecordKey
public var version: VaultVersion
@@ -66,7 +66,7 @@ public nonisolated struct MetricsEnvelope: Codable, Hashable, Sendable {
}
public var isValid: Bool {
formatVersion == Self.currentFormatVersion
(1...Self.currentFormatVersion).contains(formatVersion)
&& MetricsRecordKey(recordName: key.recordName) == key
&& (isDeleted ? payload == nil : payload?.key == key)
}
@@ -2,6 +2,8 @@ import Foundation
public nonisolated struct MetricsSelection: Codable, Hashable, Identifiable, Sendable {
public var id: UUID
public var tailscaleGroupID: UUID?
public var displayName: String?
public var enabled = true
/// nil selects all default devices; an empty set deliberately selects none.
public var disks: Set<String>?
@@ -44,7 +44,8 @@ public nonisolated struct SyncRecordFormats: Sendable {
private static let v1 = Format(readingVersions: [1], writingVersion: 1)
public static let vault = Self([
"host": Format(readingVersions: [1, 2], writingVersion: 2), "hostGroup": v1, "identity": v1,
"host": Format(readingVersions: [1, 2], writingVersion: 2),
"hostGroup": Format(readingVersions: [1, 2], writingVersion: 2), "identity": v1,
"sshKey": Format(readingVersions: [1, 2], writingVersion: 2),
"knownHost": v1, "snippet": v1, "snippetPackage": v1,
"portForwarding": v1, "workspaceTemplate": v1,
@@ -906,6 +906,23 @@ public enum TermindSchemaV46: VersionedSchema {
}
}
public enum TermindSchemaV47: VersionedSchema {
public static let versionIdentifier = Schema.Version(47, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV46.models + [
PersistentTailscaleGroup.self, TermindSchemaV47TailscaleModels.PersistentTailscaleLocalState.self,
]
}
}
public enum TermindSchemaV48: VersionedSchema {
public static let versionIdentifier = Schema.Version(48, 0, 0)
public static var models: [any PersistentModel.Type] {
TermindSchemaV47.models.filter { $0 != TermindSchemaV47TailscaleModels.PersistentTailscaleLocalState.self }
+ [TermindSchemaV48TailscaleModels.PersistentTailscaleLocalState.self]
}
}
public enum TermindMigrationPlan: SchemaMigrationPlan {
public static var schemas: [any VersionedSchema.Type] {
[
@@ -921,7 +938,7 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
TermindSchemaV34.self, TermindSchemaV35.self, TermindSchemaV36.self, TermindSchemaV37.self,
TermindSchemaV38.self, TermindSchemaV39.self, TermindSchemaV40.self, TermindSchemaV41.self,
TermindSchemaV42.self, TermindSchemaV43.self, TermindSchemaV44.self, TermindSchemaV45.self,
TermindSchemaV46.self,
TermindSchemaV46.self, TermindSchemaV47.self, TermindSchemaV48.self,
]
}
@@ -1027,6 +1044,8 @@ public enum TermindMigrationPlan: SchemaMigrationPlan {
MigrationStage.lightweight(fromVersion: TermindSchemaV43.self, toVersion: TermindSchemaV44.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV44.self, toVersion: TermindSchemaV45.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV45.self, toVersion: TermindSchemaV46.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV46.self, toVersion: TermindSchemaV47.self),
MigrationStage.lightweight(fromVersion: TermindSchemaV47.self, toVersion: TermindSchemaV48.self),
]
}
}
@@ -329,6 +329,20 @@ public final class MetricsSyncPersistence: MetricsConfigurationStoring {
}
try journal.acceptRemote(merge.accepted)
snapshot = try materialize()
// A Metrics record can arrive after the Host/Group deletion from the
// separate Vault zone. Only explicit tombstones justify removal;
// absence of a locally discovered Tailscale node never does.
let vault = VaultSyncJournal(context: context)
let retained = try snapshot.selections.filter { selection in
let key =
selection.tailscaleGroupID.map { VaultRecordKey(kind: .hostGroup, id: $0) }
?? VaultRecordKey(kind: .host, id: selection.id)
return try vault.envelope(for: key)?.isDeleted != true
}
let cleaned = MetricsSyncSnapshot(selections: retained)
let removals = try journal.recordLocalChanges(from: snapshot, to: cleaned)
merge.needsUpload.formUnion(removals.keys)
snapshot = cleaned
try journal.markNeedsUpload(merge.needsUpload)
}
return (snapshot, Set(merge.accepted.map(\.key)), merge.needsUpload)
@@ -27,7 +27,7 @@ public final class SwiftDataHostRepository: HostRepository {
let hosts = try context.fetch(FetchDescriptor<PersistentHost>())
.map(\.value)
.filter { !hidden.contains($0.id) }
let groups = try context.fetch(FetchDescriptor<PersistentHostGroup>()).map(\.value)
let groups = try HostPersistence.groups(in: context)
return RepositoryChange(
revision: try currentRevision(),
value: HostLibrarySnapshot(hosts: hosts, groups: groups),
@@ -51,6 +51,7 @@ public final class SwiftDataHostRepository: HostRepository {
}
public func upsertHost(_ host: Host) throws {
guard !host.isTailscaleManaged else { throw TailscaleConfigurationError.managedHost }
let hostID = host.id
var descriptor = FetchDescriptor<PersistentHost>(predicate: #Predicate { $0.id == hostID })
descriptor.fetchLimit = 1
@@ -23,7 +23,7 @@ public final class HostPersistence {
private func fetchSnapshot() throws -> VaultSnapshot {
let hosts = try context.fetch(FetchDescriptor<PersistentHost>()).map(\.value)
let groups = try context.fetch(FetchDescriptor<PersistentHostGroup>()).map(\.value)
let groups = try Self.groups(in: context)
let identities = try context.fetch(FetchDescriptor<PersistentIdentity>()).map(\.value)
let keys = try context.fetch(FetchDescriptor<PersistentSSHKey>()).map(\.value)
let knownHosts = try context.fetch(FetchDescriptor<PersistentKnownHost>()).map(\.value)
@@ -139,6 +139,12 @@ public final class HostPersistence {
}
private func persist(_ snapshot: VaultSnapshot) throws {
guard snapshot.hosts.allSatisfy({ !$0.isTailscaleManaged }) else {
throw TailscaleConfigurationError.managedHost
}
let networks = snapshot.groups.compactMap { $0.tailscale?.network }
guard Set(networks).count == networks.count else { throw TailscaleConfigurationError.duplicateNetwork }
try persistTailscaleGroups(snapshot.groups)
try reconcile(
try context.fetch(FetchDescriptor<PersistentHost>()), with: snapshot.hosts,
rowID: \.id, valueID: \.id,
@@ -0,0 +1,90 @@
import Foundation
import SwiftData
import TermindApplication
import TermindDomain
/// Versioned group configuration is separate from the frozen historical Group model.
@Model
public final class PersistentTailscaleGroup {
@Attribute(.unique) public var id: UUID
public var configurationData: Data
public init(id: UUID, configuration: TailscaleGroupConfiguration) throws {
self.id = id
configurationData = try JSONEncoder().encode(configuration)
}
public func configuration() throws -> TailscaleGroupConfiguration {
try JSONDecoder().decode(TailscaleGroupConfiguration.self, from: configurationData)
}
}
public typealias PersistentTailscaleLocalState = TermindSchemaV48TailscaleModels.PersistentTailscaleLocalState
extension HostPersistence {
static func groups(in context: ModelContext) throws -> [HostGroup] {
let configurations = try Dictionary(
uniqueKeysWithValues:
context.fetch(FetchDescriptor<PersistentTailscaleGroup>()).map { ($0.id, try $0.configuration()) })
return try context.fetch(FetchDescriptor<PersistentHostGroup>()).map {
var group = $0.value
group.tailscale = configurations[group.id]
return group
}
}
func persistTailscaleGroups(_ groups: [HostGroup]) throws {
let wanted = Dictionary(
uniqueKeysWithValues: groups.compactMap { group in
group.tailscale.map { (group.id, $0) }
})
let rows = try context.fetch(FetchDescriptor<PersistentTailscaleGroup>())
let existing = Set(rows.map(\.id))
for row in rows {
if let configuration = wanted[row.id] {
row.configurationData = try JSONEncoder().encode(configuration)
} else {
context.delete(row)
}
}
for (id, configuration) in wanted where !existing.contains(id) {
context.insert(try PersistentTailscaleGroup(id: id, configuration: configuration))
}
}
}
@MainActor
public final class SwiftDataTailscaleLocalStore: TailscaleLocalStoring {
private let context: ModelContext
public init(context: ModelContext) { self.context = context }
public func load() throws -> [TailscaleLocalState] {
try context.fetch(FetchDescriptor<PersistentTailscaleLocalState>()).map {
TailscaleLocalState(
groupID: $0.groupID, profileID: $0.profileID,
peers: $0.peersData.isEmpty ? [] : try JSONDecoder().decode([TailscalePeer].self, from: $0.peersData),
hostname: $0.hostname)
}
}
public func save(_ state: TailscaleLocalState) throws {
let data = try JSONEncoder().encode(state.peers)
try context.commit {
if let row = try context.fetch(FetchDescriptor<PersistentTailscaleLocalState>()).first(where: {
$0.groupID == state.groupID
}) {
row.profileID = state.profileID
row.peersData = data
row.hostname = state.hostname
} else {
context.insert(
PersistentTailscaleLocalState(
groupID: state.groupID, profileID: state.profileID, peersData: data, hostname: state.hostname))
}
}
}
public func remove(groupID: UUID) throws {
try context.commit {
for row in try context.fetch(FetchDescriptor<PersistentTailscaleLocalState>()) where row.groupID == groupID
{
context.delete(row)
}
}
}
}
@@ -20,7 +20,7 @@ public enum TermindPersistence {
#endif
// Local schema migrations do not change the independently versioned CloudKit envelopes.
public static let schema = Schema(versionedSchema: TermindSchemaV46.self)
public static let schema = Schema(versionedSchema: TermindSchemaV48.self)
public static func makeContainer(inMemory: Bool = false, url: URL? = nil) throws -> ModelContainer {
precondition(!inMemory || url == nil, "An in-memory store cannot also have a file URL")
@@ -0,0 +1,17 @@
import Foundation
import SwiftData
/// Original local enrollment shape. Never add fields to this historical model.
public enum TermindSchemaV47TailscaleModels {
@Model
public final class PersistentTailscaleLocalState {
@Attribute(.unique) public var groupID: UUID
public var profileID: UUID
public var peersData: Data
public init(groupID: UUID, profileID: UUID, peersData: Data = Data()) {
self.groupID = groupID
self.profileID = profileID
self.peersData = peersData
}
}
}
@@ -0,0 +1,19 @@
import Foundation
import SwiftData
/// V48 adds this device’s persisted hostname; V47 remains frozen.
public enum TermindSchemaV48TailscaleModels {
@Model
public final class PersistentTailscaleLocalState {
@Attribute(.unique) public var groupID: UUID
public var profileID: UUID
public var hostname: String?
public var peersData: Data
public init(groupID: UUID, profileID: UUID, peersData: Data = Data(), hostname: String? = nil) {
self.groupID = groupID
self.profileID = profileID
self.peersData = peersData
self.hostname = hostname
}
}
}
@@ -56,6 +56,12 @@ public final class PersistentVaultSyncQuarantine {
/// Stable CloudKit V1 encoding is deliberately independent of domain Codable.
public nonisolated enum VaultSyncCodec {
public static func encode(_ envelope: VaultEnvelope) throws -> Data {
if case .host(let host) = envelope.payload, host.isTailscaleManaged {
throw TailscaleConfigurationError.managedHost
}
if case .hostGroup(let group) = envelope.payload, group.tailscale != nil, envelope.formatVersion == 1 {
throw CocoaError(.coderInvalidValue)
}
try SyncRecordFormats.vault.validate(kind: envelope.key.kind.rawValue, version: envelope.formatVersion)
guard envelope.isValid else { throw VaultSyncPersistenceError.invalidEnvelope(envelope.key.recordName) }
let encoder = JSONEncoder()
@@ -77,6 +83,9 @@ public nonisolated enum VaultSyncCodec {
decoders[VaultRecordKind.host.rawValue]?[2] = {
try JSONDecoder().decode(VaultWireV2.Envelope.self, from: $0).domainValue
}
decoders[VaultRecordKind.hostGroup.rawValue]?[2] = {
try JSONDecoder().decode(VaultWireV2.Envelope.self, from: $0).domainValue
}
return VersionedRecordDecoder(decoders)
}()
@@ -163,8 +163,33 @@ nonisolated enum VaultWireV2 {
return result
}
}
struct HostGroup: Codable {
var base: VaultWireV1.HostGroup
var tailscale: TailscaleGroupConfiguration?
enum CodingKeys: String, CodingKey { case tailscale }
init(_ value: TermindDomain.HostGroup) {
base = VaultWireV1.HostGroup(value)
tailscale = value.tailscale
}
init(from decoder: any Decoder) throws {
base = try VaultWireV1.HostGroup(from: decoder)
tailscale = try decoder.container(keyedBy: CodingKeys.self)
.decode(TailscaleGroupConfiguration?.self, forKey: .tailscale)
}
func encode(to encoder: any Encoder) throws {
try base.encode(to: encoder)
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(tailscale, forKey: .tailscale)
}
var domainValue: TermindDomain.HostGroup {
var value = base.domainValue
value.tailscale = tailscale
return value
}
}
enum Payload: Codable {
case host(Host)
case hostGroup(HostGroup)
case sshKey(SSHKeyRef)
}
struct Envelope: Codable {
@@ -180,6 +205,8 @@ nonisolated enum VaultWireV2 {
isDeleted = envelope.isDeleted
if case .host(let host) = envelope.payload {
payload = .host(try Host(host))
} else if case .hostGroup(let group) = envelope.payload {
payload = .hostGroup(HostGroup(group))
} else if case .sshKey(let key) = envelope.payload {
guard key.storage != .external || key.algorithm == .securityKey else {
throw CocoaError(.coderInvalidValue)
@@ -195,6 +222,7 @@ nonisolated enum VaultWireV2 {
var value: VaultRecordPayload?
switch payload {
case .host(let host): value = .host(host.domainValue)
case .hostGroup(let group): value = .hostGroup(group.domainValue)
case .sshKey(let key): value = .sshKey(key.domainValue)
case nil: value = nil
}
@@ -53,6 +53,7 @@ public struct SSHAuthenticationForm: View {
self.cancel = cancel
self.usesContainerChrome = usesContainerChrome
let initialIdentityID = prompt.selectedIdentityID.flatMap { id -> Identity.ID? in
guard !prompt.usernameOnly else { return nil }
guard let identity = library.identity(id) else { return nil }
if identity.isHostCustom { return nil }
switch library.passwordAvailability(for: identity) {
@@ -70,7 +71,8 @@ public struct SSHAuthenticationForm: View {
}
private var canContinue: Bool {
!username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
if prompt.usernameOnly { return !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
return !username.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& (!needsPassphrase || !passphrase.isEmpty)
&& (!prompt.isProxy || !password.isEmpty)
&& !selectedIdentityPasswordIsUnavailable
@@ -93,7 +95,9 @@ public struct SSHAuthenticationForm: View {
public var body: some View {
Group {
if let challenge = prompt.keyboardInteractive, let respondKeyboardInteractive {
if prompt.usernameOnly {
usernameForm
} else if let challenge = prompt.keyboardInteractive, let respondKeyboardInteractive {
SSHKeyboardInteractiveForm(
challenge: challenge, submit: respondKeyboardInteractive, cancel: cancel, keyboard: keyboard
)
@@ -114,12 +118,33 @@ public struct SSHAuthenticationForm: View {
passphrase = ""
}
.onAppear {
if prompt.usernameOnly {
focusedField = .username
return
}
if identityID != nil, selectedIdentity == nil { identityID = nil }
loadPassword(for: selectedIdentity ?? initialCustomIdentity)
updateFocusForRequirement()
}
}
private var usernameForm: some View {
VStack(alignment: .leading, spacing: 14) {
if let message = prompt.failureMessage {
Text(message).font(.caption).foregroundStyle(.red).textSelection(.enabled)
}
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(AppStrings.Vault.username.swiftUIKey, text: $username.singleLineText)
.singleLinePaste().lineLimit(1)
.focused($focusedField, equals: .username)
#if os(iOS)
.textInputAutocapitalization(.never).autocorrectionDisabled()
#endif
}
actions
}
}
private var form: some View {
VStack(alignment: .leading, spacing: 14) {
HStack {
@@ -300,6 +325,10 @@ public struct SSHAuthenticationForm: View {
private func commit(save: Bool) {
let save = save && prompt.allowsSaving
if prompt.usernameOnly {
submit(nil, username.trimmingCharacters(in: .whitespacesAndNewlines), nil, nil, nil, save)
return
}
let submittedPassword = needsPassphrase ? nil : (save ? password : (password.isEmpty ? nil : password))
let submittedPassphrase = passphrase.isEmpty ? nil : passphrase
password = ""
@@ -218,7 +218,7 @@ private struct JourneyEndpointView: View {
private var symbol: String {
if let localRequirement {
switch localRequirement {
case .identity: return "person.fill"
case .identity, .tailscaleUsername: return "person.fill"
case .telnetPassword: return "key.fill"
case .passphrase: return "key.fill"
case .proxyAuthentication: return "lock.shield.fill"
@@ -22,7 +22,8 @@ public struct HostOrganizationSection<GroupField: View, TagsField: View>: View {
public var body: some View {
Section(AppStrings.Vault.host.swiftUIKey) {
VaultNameField(text: $host.name, prompt: Text(verbatim: host.defaultName))
groupField
.disabled(host.isTailscaleManaged)
groupField.disabled(host.isTailscaleManaged)
tagsField
VaultColorField(selection: $host.colorTag)
}
@@ -25,6 +25,7 @@ public struct HostSessionSection: View {
@Binding private var agentForwardingEnabled: Bool
@Binding private var startupSnippetID: Snippet.ID?
@Binding private var environmentVariables: [HostEnvironmentVariable]
private let allowsAgentForwarding: Bool
private let snippets: [Snippet]
private let snippetPackagePath: (SnippetPackage.ID?) -> String?
private let editEnvironmentVariable: (Int?) -> Void
@@ -34,6 +35,7 @@ public struct HostSessionSection: View {
sftpInitialPath: Binding<String>,
startupSnippetID: Binding<Snippet.ID?> = .constant(nil),
agentForwardingEnabled: Binding<Bool> = .constant(false),
allowsAgentForwarding: Bool = true,
environmentVariables: Binding<[HostEnvironmentVariable]> = .constant([]),
snippets: [Snippet] = [],
snippetPackagePath: @escaping (SnippetPackage.ID?) -> String? = { _ in nil },
@@ -42,6 +44,7 @@ public struct HostSessionSection: View {
_charset = charset
_sftpInitialPath = sftpInitialPath
_startupSnippetID = startupSnippetID
self.allowsAgentForwarding = allowsAgentForwarding
_agentForwardingEnabled = agentForwardingEnabled
_environmentVariables = environmentVariables
self.snippets = snippets
@@ -69,8 +72,8 @@ public struct HostSessionSection: View {
}
.pickerStyle(.menu)
.menuOrder(.fixed)
Toggle(isOn: $agentForwardingEnabled) {
Text(AppStrings.Connections.agentForwarding)
if allowsAgentForwarding {
Toggle(isOn: $agentForwardingEnabled) { Text(AppStrings.Connections.agentForwarding) }
}
LabeledContent("SFTP Initial Path") {
TextField("SFTP Initial Path", text: $sftpInitialPath.singleLineText, prompt: Text("~"))
@@ -26,7 +26,13 @@ public struct SSHKeyboardInteractiveForm: View {
public var body: some View {
VStack(alignment: .leading, spacing: 14) {
Text("Interactive authentication").font(.headline)
Group {
if challenge.isTailscaleApproval {
Text(AppStrings.Tailscale.check)
} else {
Text("Interactive authentication")
}
}.font(.headline)
Text(verbatim: challenge.address).font(.caption.monospaced()).foregroundStyle(.secondary)
ViewThatFits(in: .vertical) {
fields.fixedSize(horizontal: false, vertical: true)
@@ -42,7 +48,7 @@ public struct SSHKeyboardInteractiveForm: View {
HStack {
Button(AppStrings.Common.cancel.swiftUIKey, action: cancel).keyboardShortcut(.cancelAction)
Spacer()
if !challenge.prompts.isEmpty {
if !challenge.isInformational {
Button(AppStrings.Common.continue.swiftUIKey, action: commit)
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
@@ -59,7 +65,7 @@ public struct SSHKeyboardInteractiveForm: View {
.task {
focus = answers.isEmpty ? nil : 0
// RFC 4256 also permits informational rounds without any questions.
if challenge.prompts.isEmpty { commit() }
if challenge.isInformational { commit() }
}
.onDisappear { answers = Array(repeating: "", count: answers.count) }
}
@@ -70,6 +76,9 @@ public struct SSHKeyboardInteractiveForm: View {
if !challenge.instruction.isEmpty {
Text(verbatim: challenge.instruction).textSelection(.enabled)
}
if let url = challenge.tailscaleApprovalURL {
Link(AppStrings.Tailscale.openLogin.swiftUIKey, destination: url)
}
ForEach(challenge.prompts.indices, id: \.self) { index in
VStack(alignment: .leading, spacing: 5) {
Text(verbatim: challenge.prompts[index].text)
@@ -0,0 +1,50 @@
import Foundation
extension AppStrings {
public nonisolated enum Tailscale {
public static var ping: LocalizedStringResource { "Ping" }
public static var stopPing: LocalizedStringResource { "Stop Ping" }
public static var status: LocalizedStringResource { "Status" }
public static var online: LocalizedStringResource { "Online" }
public static var offline: LocalizedStringResource { "Offline" }
public static var lastSeen: LocalizedStringResource { "Last Seen" }
public static var latency: LocalizedStringResource { "Latency" }
public static var route: LocalizedStringResource { "Route" }
public static var direct: LocalizedStringResource { "Direct" }
public static var peerRelay: LocalizedStringResource { "Peer Relay" }
public static var unknownRoute: LocalizedStringResource { "Unknown Route" }
public static var endpoint: LocalizedStringResource { "Endpoint" }
public static var derpRegion: LocalizedStringResource { "DERP region" }
public static var noPingData: LocalizedStringResource { "No data" }
public static var directConnection: LocalizedStringResource { "Direct connection" }
public static var peerRelayConnection: LocalizedStringResource { "Peer relay connection" }
public static var derpConnection: LocalizedStringResource { "DERP-relayed connection" }
public static var exitNode: LocalizedStringResource { "Exit Node" }
public static var selectedExitNodeUnavailable: LocalizedStringResource { "Selected exit node is unavailable" }
public static func sftpFailure(username: String, address: String, detail: String) -> LocalizedStringResource {
"SFTP connection to \(username)@\(address) failed: \(detail)"
}
public static var title: LocalizedStringResource { "Tailscale" }
public static var login: LocalizedStringResource { "Login" }
public static var interactive: LocalizedStringResource { "Interactive Login" }
public static var authKey: LocalizedStringResource { "Auth Key" }
public static var controlURL: LocalizedStringResource { "Controlplane URL" }
public static var openLogin: LocalizedStringResource { "Open login page" }
public static var networkName: LocalizedStringResource { "Network" }
public static var magicDNS: LocalizedStringResource { "MagicDNS" }
public static var ipv4: LocalizedStringResource { "IPv4" }
public static var ipv6: LocalizedStringResource { "IPv6" }
public static var keyExpiry: LocalizedStringResource { "Key Expiry" }
public static var operatingSystem: LocalizedStringResource { "OS" }
public static var logout: LocalizedStringResource { "Log out" }
public static var check: LocalizedStringResource { "Tailscale SSH approval" }
public static var network: LocalizedStringResource { "Tailnet" }
public static var signedOut: LocalizedStringResource { "Sign in on this device to use this Tailnet." }
public static var unavailable: LocalizedStringResource {
"This Tailscale connection is unavailable. Check the Tailnet login, SSH policy, and username."
}
public static var duplicate: LocalizedStringResource { "This Tailnet already belongs to another group." }
public static var invalidURL: LocalizedStringResource { "Enter a valid HTTPS Controlplane URL." }
public static var requiresPro: LocalizedStringResource { "Tailscale requires Termind Pro." }
}
}
@@ -0,0 +1,37 @@
import SwiftUI
struct TailscaleCopyField: View {
let label: LocalizedStringResource
let value: String
let copy: (String) -> Void
@State private var copied = false
@State private var copyID = UUID()
var body: some View {
if !value.isEmpty {
LabeledContent(label.swiftUIKey) {
HStack {
Text(verbatim: value)
.textSelection(.enabled)
.multilineTextAlignment(.trailing)
Button {
copy(value)
copied = true
copyID = UUID()
} label: {
Image(systemName: copied ? "checkmark" : "doc.on.doc")
.contentTransition(.symbolEffect(.replace))
}
.buttonStyle(.borderless)
.accessibilityLabel((copied ? AppStrings.Common.copied : AppStrings.Common.copy).swiftUIKey)
.task(id: copyID) {
guard copied else { return }
do { try await Task.sleep(for: .seconds(2)) } catch { return }
copied = false
}
}
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
}
@@ -0,0 +1,57 @@
import SwiftUI
import TermindApplication
/// Hostname editing and information about this device's active Tailnet session.
public struct TailscaleDeviceFields: View {
private let model: TailscaleModel
private let groupID: UUID
@Binding private var hostname: String
private let copy: (String) -> Void
@State private var isLoggingOut = false
public init(model: TailscaleModel, groupID: UUID, hostname: Binding<String>, copy: @escaping (String) -> Void) {
self.model = model
self.groupID = groupID
self.copy = copy
_hostname = hostname
}
public var body: some View {
Group {
LabeledContent(AppStrings.Vault.hostname.swiftUIKey) {
TextField(
AppStrings.Vault.hostname.swiftUIKey, text: $hostname,
prompt: Text(verbatim: model.defaultHostname)
)
.labelsHidden()
.multilineTextAlignment(.trailing)
}
if let device = model.devices[groupID] {
copyable(AppStrings.Tailscale.magicDNS, value: device.dnsName)
ForEach(device.addresses, id: \.self) { address in
copyable(
address.contains(":") ? AppStrings.Tailscale.ipv6 : AppStrings.Tailscale.ipv4, value: address)
}
LabeledContent(AppStrings.Tailscale.keyExpiry.swiftUIKey) {
if let expiry = device.keyExpiry {
Text(expiry, format: .dateTime)
} else {
Text(AppStrings.Settings.disabled)
}
}
LabeledContent(AppStrings.Tailscale.operatingSystem.swiftUIKey, value: device.operatingSystem)
}
Button(AppStrings.Tailscale.logout.swiftUIKey, role: .destructive) {
isLoggingOut = true
Task {
await model.logout(groupID: groupID)
isLoggingOut = false
}
}.disabled(isLoggingOut)
}
}
private func copyable(_ label: LocalizedStringResource, value: String) -> some View {
TailscaleCopyField(label: label, value: value, copy: copy)
}
}
@@ -0,0 +1,32 @@
import SwiftUI
import TermindApplication
public struct TailscaleExitNodeField: View {
let model: TailscaleModel
let groupID: UUID
@Binding var selection: String
public init(model: TailscaleModel, groupID: UUID, selection: Binding<String>) {
self.model = model
self.groupID = groupID
_selection = selection
}
public var body: some View {
let candidates = model.exitNodeCandidates(groupID: groupID)
Picker(AppStrings.Tailscale.exitNode.swiftUIKey, selection: $selection) {
Text(AppStrings.Settings.disabled).tag("")
ForEach(candidates, id: \.stableID) { peer in
HStack {
Image(systemName: "circle.fill").foregroundStyle(peer.online ? .green : .secondary)
Text(verbatim: peer.name)
if !peer.online { Text(AppStrings.Tailscale.offline).foregroundStyle(.secondary) }
}.tag(peer.stableID)
}
if !selection.isEmpty, !candidates.contains(where: { $0.stableID == selection }) {
Text(AppStrings.Tailscale.selectedExitNodeUnavailable).tag(selection)
}
}
if let error = model.error { Text(error).foregroundStyle(.red).font(.caption) }
}
}
@@ -0,0 +1,79 @@
import SwiftUI
import TermindApplication
import TermindDomain
public struct TailscaleLoginFields: View {
let model: TailscaleModel
let name: String
let parentID: UUID?
let group: HostGroup?
let completed: (UUID) -> Void
@State private var hostname = ""
@State private var controlURL = ""
@State private var authKey = ""
@State private var usesKey = false
@State private var openedLoginURL: URL?
@Environment(\.openURL) private var openURL
public init(
model: TailscaleModel, name: String, parentID: UUID?, group: HostGroup?,
completed: @escaping (UUID) -> Void
) {
self.model = model
self.name = name
self.parentID = parentID
self.group = group
self.completed = completed
_hostname = State(initialValue: group.flatMap { model.savedHostname(groupID: $0.id) } ?? "")
let savedControlURL = group?.tailscale?.network.controlURL ?? ""
_controlURL = State(initialValue: savedControlURL == TailscaleNetwork.defaultControlURL ? "" : savedControlURL)
}
public var body: some View {
Group {
TextField(
AppStrings.Vault.hostname.swiftUIKey, text: $hostname,
prompt: Text(verbatim: model.defaultHostname)
).disabled(model.isLoggingIn)
TextField(
AppStrings.Tailscale.controlURL.swiftUIKey, text: $controlURL,
prompt: Text(verbatim: TailscaleNetwork.defaultControlURL)
)
.disabled(group?.tailscale != nil || model.isLoggingIn)
.onChange(of: model.loginCompletedGroupID) { _, id in if let id { completed(id) } }
.onChange(of: model.loginURL) { _, url in
guard let url, url.scheme == "https", url.host != nil, url.user == nil, url.password == nil,
openedLoginURL != url
else { return }
openedLoginURL = url
openURL(url)
}
.onDisappear {
model.cancelLogin()
authKey = ""
}
Picker(AppStrings.Vault.authentication.swiftUIKey, selection: $usesKey) {
Text(AppStrings.Tailscale.interactive).tag(false)
Text(AppStrings.Tailscale.authKey).tag(true)
}.disabled(model.isLoggingIn)
if usesKey {
SecureField(AppStrings.Tailscale.authKey.swiftUIKey, text: $authKey).disabled(model.isLoggingIn)
}
if model.isLoggingIn { ProgressView() }
if let error = model.error { Text(error).foregroundStyle(.red) }
if let url = model.loginURL, url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
Button(AppStrings.Tailscale.openLogin.swiftUIKey) { openURL(url) }
} else {
Button(AppStrings.Tailscale.login.swiftUIKey) {
openedLoginURL = nil
let enteredControlURL = controlURL.trimmingCharacters(in: .whitespacesAndNewlines)
model.beginLogin(
name: name, parentID: parentID,
controlURL: enteredControlURL.isEmpty ? TailscaleNetwork.defaultControlURL : enteredControlURL,
authKey: usesKey ? authKey : nil, groupID: group?.id, hostname: hostname)
authKey = ""
}.disabled(model.isLoggingIn || name.isEmpty || (usesKey && authKey.isEmpty))
}
}
}
}
@@ -0,0 +1,84 @@
import SwiftUI
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
/// Diagnostics belong to the presented form; dismissal cancels the active ping.
public struct TailscalePeerForm: View {
let model: TailscaleModel
let host: Host
let copy: (String) -> Void
@State private var pinging = false
@State private var result: TailscalePingResult?
@State private var error: String?
@State private var latencyHistory: [Double] = []
public init(model: TailscaleModel, host: Host, copy: @escaping (String) -> Void) {
self.model = model
self.host = host
self.copy = copy
}
public var body: some View {
Form {
if let peer = model.peer(for: host) {
Section {
TailscaleCopyField(label: AppStrings.Vault.hostname, value: peer.name, copy: copy)
TailscaleCopyField(label: AppStrings.Tailscale.magicDNS, value: peer.dnsName, copy: copy)
ForEach(peer.details?.addresses ?? [], id: \.self) { address in
TailscaleCopyField(
label: address.contains(":") ? AppStrings.Tailscale.ipv6 : AppStrings.Tailscale.ipv4,
value: address, copy: copy)
}
LabeledContent(AppStrings.Tailscale.status.swiftUIKey) {
Text(peer.online ? AppStrings.Tailscale.online : AppStrings.Tailscale.offline)
}
if let details = peer.details {
LabeledContent(
AppStrings.Tailscale.operatingSystem.swiftUIKey, value: details.operatingSystem)
LabeledContent(AppStrings.Tailscale.keyExpiry.swiftUIKey) {
if let expiry = details.keyExpiry {
Text(expiry, format: .dateTime)
} else {
Text(AppStrings.Settings.disabled)
}
}
if !peer.online, let lastSeen = details.lastSeen {
LabeledContent(AppStrings.Tailscale.lastSeen.swiftUIKey) {
Text(lastSeen, format: .dateTime)
}
}
}
}
TailscalePingSection(
pinging: $pinging, result: result, error: error, history: latencyHistory,
isAvailable: model.canPing(host))
} else {
Text(AppStrings.Tailscale.unavailable)
}
}
.formStyle(.grouped)
.task(id: pinging) {
guard pinging else { return }
result = nil
latencyHistory = []
error = nil
while !Task.isCancelled {
do {
let next = try await model.ping(hostID: host.id)
try Task.checkCancellation()
result = next
latencyHistory.append(next.milliseconds)
if latencyHistory.count > 30 { latencyHistory.removeFirst() }
error = nil
} catch {
guard !Task.isCancelled else { return }
self.error = error.localizedDescription
}
do { try await Task.sleep(for: .seconds(1)) } catch { return }
}
}
}
}
@@ -0,0 +1,37 @@
import SwiftUI
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
#if os(iOS)
/// The mobile sheet supplies navigation chrome around the shared diagnostics.
public struct TailscalePeerView: View {
let model: TailscaleModel
let host: Host
let copy: (String) -> Void
@Environment(\.dismiss) private var dismiss
public init(model: TailscaleModel, host: Host, copy: @escaping (String) -> Void) {
self.model = model
self.host = host
self.copy = copy
}
public var body: some View {
NavigationStack {
TailscalePeerForm(model: model, host: host, copy: copy)
.navigationTitle(AppStrings.Tailscale.title.swiftUIKey)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button(AppStrings.Common.close.swiftUIKey, systemImage: "xmark") { dismiss() }
.labelStyle(.iconOnly)
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("tailscale.close")
}
}
}
}
}
#endif
@@ -0,0 +1,117 @@
import Charts
import SwiftUI
import TermindApplication
/// Compact connection summary and a rolling latency chart, following the peer diagnostics layout.
struct TailscalePingSection: View {
@Binding var pinging: Bool
let result: TailscalePingResult?
let error: String?
let history: [Double]
let isAvailable: Bool
private var routeColor: Color {
switch result?.route {
case .direct: .green
case .peerRelay: .blue
case .derp: .orange
case .unknown, nil: .secondary
}
}
var body: some View {
Section {
if let result {
HStack {
connectionLabel(result.route)
.foregroundStyle(routeColor)
Spacer()
(Text(result.milliseconds, format: .number.precision(.fractionLength(1)))
+ Text(verbatim: " ms"))
.font(.headline.monospacedDigit())
}
switch result.route {
case .direct(let endpoint):
LabeledContent(AppStrings.Tailscale.endpoint.swiftUIKey, value: endpoint)
case .peerRelay(let relay):
LabeledContent(AppStrings.Tailscale.peerRelay.swiftUIKey, value: relay)
case .derp(let region):
LabeledContent(AppStrings.Tailscale.derpRegion.swiftUIKey, value: region)
case .unknown: EmptyView()
}
if pinging { latencyChart }
}
if let error {
Text(error).foregroundStyle(.red).textSelection(.enabled)
} else if pinging, result == nil {
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text(AppStrings.Connections.connecting).foregroundStyle(.secondary)
}
} else if result == nil {
Text(AppStrings.Tailscale.noPingData).foregroundStyle(.secondary)
}
} header: {
HStack {
Text(AppStrings.Tailscale.ping)
Spacer()
Button {
pinging.toggle()
} label: {
Image(systemName: pinging ? "stop.fill" : "play.fill")
.frame(width: 28, height: 28)
.contentShape(Rectangle())
}
.buttonStyle(.borderless)
.disabled(!pinging && !isAvailable)
.accessibilityLabel((pinging ? AppStrings.Tailscale.stopPing : AppStrings.Tailscale.ping).swiftUIKey)
}
.textCase(nil)
}
}
@ViewBuilder private func connectionLabel(_ route: TailscalePingResult.Route) -> some View {
switch route {
case .direct: Label(AppStrings.Tailscale.directConnection.swiftUIKey, systemImage: "arrow.right")
case .peerRelay:
Label(AppStrings.Tailscale.peerRelayConnection.swiftUIKey, systemImage: "arrow.triangle.2.circlepath")
case .derp:
Label(AppStrings.Tailscale.derpConnection.swiftUIKey, systemImage: "arrow.triangle.2.circlepath")
case .unknown: Label(AppStrings.Tailscale.unknownRoute.swiftUIKey, systemImage: "questionmark.circle")
}
}
private var latencyChart: some View {
let ceiling = max((history.max() ?? 0) * 1.2, 1)
return Chart {
ForEach(Array(history.enumerated()), id: \.offset) { index, latency in
LineMark(
x: .value(String(localized: AppStrings.Tailscale.ping), index),
y: .value(String(localized: AppStrings.Tailscale.latency), latency)
)
.foregroundStyle(routeColor)
if history.count == 1 {
PointMark(
x: .value(String(localized: AppStrings.Tailscale.ping), index),
y: .value(String(localized: AppStrings.Tailscale.latency), latency)
).foregroundStyle(routeColor)
}
}
}
.chartXScale(domain: 0...29)
.chartYScale(domain: 0...ceiling)
.chartXAxis(.hidden)
.chartYAxis {
AxisMarks(position: .trailing, values: [0, ceiling / 3, ceiling * 2 / 3, ceiling]) { value in
AxisGridLine()
AxisValueLabel {
if let latency = value.as(Double.self) {
Text(latency, format: .number.precision(.fractionLength(0))) + Text(verbatim: " ms")
}
}
}
}
.frame(height: 80)
.accessibilityLabel(AppStrings.Tailscale.latency.swiftUIKey)
}
}
@@ -303,7 +303,8 @@ public final class AgentHostResolver: Sendable {
displayName: node.name, address: node.displayAddress,
mode: requirement.mode,
selectedIdentityID: existing?.identityID ?? identity?.id,
username: existing?.username ?? identity?.username ?? "",
username: existing?.username
?? (node.isTailscaleManaged ? store.tailscaleUsername(for: node) : identity?.username) ?? "",
selectedKeyID: existing?.keyID ?? identity?.keyID,
failureMessage: failure
)
@@ -408,6 +409,12 @@ public final class AgentHostResolver: Sendable {
forcedHostID: Host.ID?
) -> AuthenticationRequirement? {
guard forcedHostID == nil || forcedHostID == host.id else { return nil }
if host.isTailscaleManaged {
let username = (submissions[host.id]?.username ?? store.savedTailscaleUsername(for: host))?
.trimmingCharacters(in: .whitespacesAndNewlines)
return username?.isEmpty != false || forcedHostID == host.id
? AuthenticationRequirement(mode: .tailscaleUsername) : nil
}
if let submission = submissions[host.id] {
if let keyID = submission.keyID,
store.key(keyID).map({
@@ -449,6 +456,14 @@ public final class AgentHostResolver: Sendable {
private func submission(
from answer: AgentSSHAuthenticationAnswer, for host: Host
) throws -> SSHAuthenticationSubmission {
if host.isTailscaleManaged {
let username = answer.username.trimmingCharacters(in: .whitespacesAndNewlines)
guard !username.isEmpty else { throw TermindSSHError.missingUsername }
if answer.save, !store.saveTailscaleUsername(username, hostID: host.id) {
throw TailscaleConfigurationError.unavailable
}
return SSHAuthenticationSubmission(username: username)
}
if let identityID = answer.identityID {
guard let identity = store.identity(identityID) else {
throw TermindSSHError.missingCredential
@@ -856,6 +856,9 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
) throws -> SSHAuthenticatedConnection {
_ = initialization
defer { configuration.clearSecrets() }
guard configuration.network == nil, configuration.tailscaleNodeID == nil else {
throw TailscaleConfigurationError.unavailable
}
let operationID = UUID().uuidString.lowercased()
var logger = logger
logger[metadataKey: "operation_id"] = "\(operationID)"
@@ -935,7 +938,15 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
logger[metadataKey: "host_id"] = RemoteLogging.hostID(request.configuration.hostID)
let authenticationLogger = logger
let request = request.enforcingInteractionPolicy()
let configuration = request.configuration
var resolvedConfiguration = request.configuration
if let nodeID = resolvedConfiguration.tailscaleNodeID {
guard let network = resolvedConfiguration.network, resolvedConfiguration.jumps.isEmpty else {
throw TailscaleConfigurationError.unavailable
}
resolvedConfiguration.expectedHostKeys = try await network.hostKeys(stableNodeID: nodeID)
guard !resolvedConfiguration.expectedHostKeys.isEmpty else { throw TailscaleConfigurationError.unavailable }
}
let configuration = resolvedConfiguration
defer { configuration.clearSecrets() }
logger.info(
"ssh.authentication.started",
@@ -948,6 +959,7 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
proxy: configuration.proxy, jumps: configuration.jumps,
timeout: timeout, progress: progress,
authorizeHostKey: request.authorizeHostKey,
network: configuration.network,
keyboardInteractive: request.keyboardInteractive,
securityKeySigning: request.securityKeySigning,
interactionPolicy: request.interactionPolicy,
@@ -968,19 +980,54 @@ nonisolated final class SSHPTYChannelWorker: @unchecked Sendable {
)
}
let requiredApproval = !configuration.expectedHostKeys.contains(observed.identity)
try await authorizeHostKey(
hostID: configuration.hostID, observed: observed,
expected: configuration.expectedHostKeys,
constraint: request.hostKeyConstraints[configuration.hostID],
authorizer: request.authorizeHostKey
)
try Task.checkCancellation()
let authentication = try await queue.perform {
try state.authenticate(
configuration: configuration,
afterHostKeyApproval: requiredApproval,
progress: progress, keyboardInteractive: exchange, logger: authenticationLogger
let authentication: String
if configuration.tailscaleNodeID != nil, configuration.network != nil {
guard configuration.expectedHostKeys.contains(observed.identity) else {
throw TailscaleConfigurationError.hostKeyMismatch
}
progress?(.authenticating(configuration.hostID))
let deadline = ContinuousClock.now.advanced(by: .seconds(600))
var presentedBanners: Set<String> = []
let approvalConnectionID = UUID()
while true {
try Task.checkCancellation()
let step = try await queue.perform {
try state.authenticateTailscale(username: configuration.username)
}
if step.authenticated { break }
if let banner = step.banner, !banner.isEmpty, presentedBanners.insert(banner).inserted {
guard request.interactionPolicy == .allowed, let present = request.keyboardInteractive
else {
throw SSHInteractionRequired()
}
_ = try await present(
SSHKeyboardInteractiveChallenge(
connectionID: approvalConnectionID, hostID: configuration.hostID,
hostname: configuration.host, port: configuration.port,
username: configuration.username,
round: presentedBanners.count, name: "", instruction: banner, prompts: [],
isTailscaleApproval: true))
}
guard ContinuousClock.now < deadline else { throw TailscaleConfigurationError.approvalTimedOut }
try await Task.sleep(for: .milliseconds(50))
}
progress?(.authenticated(configuration.hostID))
authentication = "tailscale"
} else {
try await authorizeHostKey(
hostID: configuration.hostID, observed: observed,
expected: configuration.expectedHostKeys,
constraint: request.hostKeyConstraints[configuration.hostID],
authorizer: request.authorizeHostKey
)
try Task.checkCancellation()
authentication = try await queue.perform {
try state.authenticate(
configuration: configuration,
afterHostKeyApproval: requiredApproval,
progress: progress, keyboardInteractive: exchange, logger: authenticationLogger
)
}
}
let result = try await queue.perform { try state.takeResult() }
logger.info(
@@ -1979,6 +2026,30 @@ private nonisolated final class SSHAuthenticatedOpeningState: @unchecked Sendabl
return authentication
}
func authenticateTailscale(username: String) throws -> (authenticated: Bool, banner: String?) {
guard let session else { throw TermindSSHError.disconnected }
libssh2_session_set_blocking(session, 0)
_ = username.withCString { libssh2_userauth_list(session, $0, UInt32(username.utf8.count)) }
if libssh2_userauth_authenticated(session) != 0 {
libssh2_session_set_blocking(session, 1)
return (true, nil)
}
let code = libssh2_session_last_errno(session)
var message: UnsafeMutablePointer<CChar>?
var length: Int32 = 0
_ = libssh2_session_last_error(session, &message, &length, 0)
let failureDetail = message.map { String(cString: $0) }
var banner: UnsafeMutablePointer<CChar>?
_ = libssh2_userauth_banner(session, &banner)
guard code == LIBSSH2_ERROR_EAGAIN else {
let detail = banner.map { String(cString: $0) } ?? failureDetail
let reason = String(localized: "Tailscale SSH authentication failed.")
throw TermindSSHError.authenticationFailed(
detail.map { "\(reason)\n\($0) [libssh2 \(code)]" } ?? "\(reason) [libssh2 \(code)]")
}
return (false, banner.map { String(cString: $0) })
}
func takeResult() throws -> SSHAuthenticatedConnection {
guard let transport, let session else { throw TermindSSHError.disconnected }
let result = SSHAuthenticatedConnection(
@@ -118,6 +118,7 @@ public nonisolated final class SSHRoutedSocket: @unchecked Sendable {
jumps: [SSHRouteHopConfiguration], timeout: TimeInterval,
progress: SSHRouteProgressHandler? = nil,
authorizeHostKey: SSHHostKeyAuthorizer?,
network: (any SSHNetworkDialing)? = nil,
keyboardInteractive: SSHKeyboardInteractiveHandler? = nil,
securityKeySigning: SecurityKeySigningHandler? = nil,
interactionPolicy: SSHInteractionPolicy = .allowed,
@@ -134,13 +135,29 @@ public nonisolated final class SSHRoutedSocket: @unchecked Sendable {
)
return try await withTaskCancellationHandler {
do {
let firstProxy = jumps.first?.proxy ?? proxy
let firstProxy = jumps.isEmpty ? proxy : jumps[0].proxy
if let id = firstProxy?.ownerHostID { progress?(.proxyConnecting(id)) }
try await queue.perform {
try state.openFirst(
destinationHost: host, destinationPort: port,
destinationProxy: proxy, jumps: jumps, timeout: timeout
)
if let network {
let endpointHost = firstProxy?.hostname ?? jumps.first?.host ?? host
let endpointPort = firstProxy?.port ?? jumps.first?.port ?? port
let socket = try await network.open(host: endpointHost, port: endpointPort, timeout: timeout)
await queue.perform { state.adopt(socket) }
try Task.checkCancellation()
if let firstProxy {
try await queue.perform {
try SSHSocketConnector.negotiate(
socket: socket, host: jumps.first?.host ?? host,
port: jumps.first?.port ?? port, proxy: firstProxy
)
}
}
} else {
try await queue.perform {
try state.openFirst(
destinationHost: host, destinationPort: port,
destinationProxy: proxy, jumps: jumps, timeout: timeout
)
}
}
if let id = firstProxy?.ownerHostID { progress?(.proxyConnected(id)) }
for index in jumps.indices {
@@ -271,6 +288,11 @@ private nonisolated final class SSHRouteOpeningState: @unchecked Sendable {
private var bridges: [SSHChannelSocketBridge] = []
private var pendingSession: OpaquePointer?
func adopt(_ socket: Int32) {
currentSocket = socket
setCancellableSocket(socket)
}
func openFirst(
destinationHost: String, destinationPort: Int,
destinationProxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration],
@@ -0,0 +1,247 @@
import CryptoKit
import Foundation
import TailscaleKit
import TermindApplication
import TermindDomain
/// Owns one embedded userspace node; no system VPN or daemon is required.
public actor LibTailscaleNodeSession: TailscaleNodeSession {
public nonisolated static var version: String { Node.version }
private let node: Node
private let controlURL: String
private init(node: Node, controlURL: String) {
self.node = node
self.controlURL = controlURL
}
public static let factory: TailscaleNodeFactory = { profileID, hostname, controlURL, authKey in
try await Task.detached {
let directory = try FileManager.default.url(
for: .applicationSupportDirectory, in: .userDomainMask,
appropriateFor: nil, create: true
).appendingPathComponent("Termind/Tailscale/\(profileID.uuidString)", isDirectory: true)
let node = try Node(
hostname: hostname, directory: directory, controlURL: controlURL,
stateNamespace: "app.termind.tailscale.\(profileID.uuidString)", authKey: authKey
)
return LibTailscaleNodeSession(node: node, controlURL: controlURL)
}.value
}
public func status() async throws -> TailscaleNodeStatus {
let data = try await node.request(path: "status")
return try Self.decodeStatus(data, controlURL: controlURL)
}
public static func decodeStatus(_ data: Data, controlURL: String) throws -> TailscaleNodeStatus {
let status = try JSONDecoder().decode(Status.self, from: data)
let network = try status.currentTailnet.map {
try TailscaleNetwork(controlURL: controlURL, dnsSuffix: $0.magicDNSSuffix)
}
// A missing peer map is not an authoritative empty snapshot.
let running = status.backendState == "Running" && network != nil
let peers = (status.peers ?? [:]).values.filter {
$0.inNetworkMap && $0.shareeNode != true && !$0.stableID.isEmpty && !$0.dnsName.isEmpty
}.map {
TailscalePeer(
stableID: $0.stableID, name: $0.hostName,
dnsName: $0.dnsName.trimmingCharacters(in: CharacterSet(charactersIn: ".")),
online: $0.online, sshHostKeys: $0.sshHostKeys ?? [],
details: TailscalePeerDetails(
addresses: $0.tailscaleIPs ?? [], operatingSystem: $0.os ?? "",
keyExpiry: Self.date($0.keyExpiry), lastSeen: Self.date($0.lastSeen)),
exitNodeOption: $0.exitNodeOption)
}.sorted { $0.stableID < $1.stableID }
let device = try status.localDevice.map { local in
let expiry: Date?
if let raw = local.keyExpiry, !raw.hasPrefix("0001-") {
expiry =
try (try? Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(raw))
?? Date.ISO8601FormatStyle().parse(raw)
} else {
expiry = nil
}
return TailscaleDeviceInfo(
networkName: status.currentTailnet?.name ?? network?.dnsSuffix ?? "",
dnsName: (local.dnsName ?? "").trimmingCharacters(in: CharacterSet(charactersIn: ".")),
hostname: local.hostName ?? "", addresses: local.tailscaleIPs ?? [], operatingSystem: local.os ?? "",
keyExpiry: expiry)
}
return TailscaleNodeStatus(
running: running, network: network,
authURL: status.authURL.flatMap(URL.init(string:)), peers: peers, device: device
)
}
private static func date(_ raw: String?) -> Date? {
guard let raw, !raw.hasPrefix("0001-") else { return nil }
return (try? Date.ISO8601FormatStyle(includingFractionalSeconds: true).parse(raw))
?? (try? Date.ISO8601FormatStyle().parse(raw))
}
public func setHostname(_ hostname: String) async throws {
let body = try JSONSerialization.data(withJSONObject: ["Hostname": hostname, "HostnameSet": true])
_ = try await node.request(method: "PATCH", path: "prefs", body: String(decoding: body, as: UTF8.self))
}
public func setExitNode(_ stableID: String?) async throws {
let body = try JSONSerialization.data(withJSONObject: [
"ExitNodeID": stableID ?? "", "ExitNodeIDSet": true,
"ExitNodeIP": "", "ExitNodeIPSet": true, "AutoExitNode": "", "AutoExitNodeSet": true,
])
_ = try await node.request(method: "PATCH", path: "prefs", body: String(decoding: body, as: UTF8.self))
}
public func ping(ip: String) async throws -> TailscalePingResult {
var query = URLComponents()
query.queryItems = [URLQueryItem(name: "ip", value: ip), URLQueryItem(name: "type", value: "disco")]
let data = try await node.request(method: "POST", path: "ping?" + (query.percentEncodedQuery ?? ""))
return try Self.decodePing(data)
}
public static func decodePing(_ data: Data) throws -> TailscalePingResult {
struct Response: Decodable {
var error: String?
var latencySeconds: Double?
var endpoint: String?
var peerRelay: String?
var derpRegionID: Int?
var derpRegionCode: String?
enum CodingKeys: String, CodingKey {
case error = "Err"
case latencySeconds = "LatencySeconds"
case endpoint = "Endpoint"
case peerRelay = "PeerRelay"
case derpRegionID = "DERPRegionID"
case derpRegionCode = "DERPRegionCode"
}
}
let value = try JSONDecoder().decode(Response.self, from: data)
if let error = value.error, !error.isEmpty { throw TermindSSHError.library(error) }
guard let latency = value.latencySeconds, latency.isFinite, latency >= 0 else {
throw TailscaleConfigurationError.unavailable
}
let route: TailscalePingResult.Route
if let endpoint = value.endpoint, !endpoint.isEmpty {
route = .direct(endpoint)
} else if let relay = value.peerRelay, !relay.isEmpty {
route = .peerRelay(relay)
} else if let id = value.derpRegionID, id != 0 {
route = .derp(value.derpRegionCode ?? String(id))
} else {
route = .unknown
}
return TailscalePingResult(milliseconds: latency * 1000, route: route)
}
public func login() async throws { _ = try await node.request(method: "POST", path: "login-interactive") }
public func logout() async throws {
_ = try? await node.request(method: "POST", path: "logout")
await node.close()
try await node.eraseState()
}
public func close() async { await node.close() }
public func dial(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 {
try await node.dial(host: host, port: port, timeout: Int32(max(1, min(timeout, 600))))
}
public func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> {
let status = try await status()
guard status.running else { throw TailscaleConfigurationError.loginRequired }
guard let peer = status.peers.first(where: { $0.stableID == stableNodeID }) else {
throw TailscaleConfigurationError.peerSSHUnavailable
}
let keys = Set(peer.sshHostKeys.compactMap(Self.hostKey))
guard !keys.isEmpty else { throw TailscaleConfigurationError.peerSSHUnavailable }
return keys
}
public static func hostKey(_ line: String) -> SSHHostKeyIdentity? {
let fields = line.split(whereSeparator: \.isWhitespace)
guard fields.count >= 2, let algorithm = SSHKeyAlgorithm(rawValue: String(fields[0])),
let blob = Data(base64Encoded: String(fields[1])), blob.count > 4
else { return nil }
let length = blob.prefix(4).reduce(0) { ($0 << 8) | Int($1) }
guard length > 0, length <= blob.count - 4,
String(data: blob.subdata(in: 4..<(4 + length)), encoding: .utf8) == algorithm.rawValue
else { return nil }
let fingerprint = Data(SHA256.hash(data: blob)).base64EncodedString().replacingOccurrences(of: "=", with: "")
return SSHHostKeyIdentity(algorithm: algorithm, fingerprint: "SHA256:\(fingerprint)")
}
private struct Status: Decodable {
var backendState: String
var authURL: String?
var currentTailnet: Tailnet?
var peers: [String: PeerStatus]?
var localDevice: LocalDevice?
enum CodingKeys: String, CodingKey {
case backendState = "BackendState"
case authURL = "AuthURL"
case currentTailnet = "CurrentTailnet"
case peers = "Peer"
case localDevice = "Self"
}
init(from decoder: any Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
backendState = try values.decode(String.self, forKey: .backendState)
authURL = try values.decodeIfPresent(String.self, forKey: .authURL)
currentTailnet = try values.decodeIfPresent(Tailnet.self, forKey: .currentTailnet)
// Null is the upstream representation of a successful empty list.
peers = try values.decode([String: PeerStatus]?.self, forKey: .peers)
localDevice = try values.decodeIfPresent(LocalDevice.self, forKey: .localDevice)
}
struct Tailnet: Decodable {
var name: String?
var magicDNSSuffix: String
enum CodingKeys: String, CodingKey {
case name = "Name"
case magicDNSSuffix = "MagicDNSSuffix"
}
}
struct LocalDevice: Decodable {
var hostName: String?
var dnsName: String?
var tailscaleIPs: [String]?
var os: String?
var keyExpiry: String?
enum CodingKeys: String, CodingKey {
case hostName = "HostName"
case dnsName = "DNSName"
case tailscaleIPs = "TailscaleIPs"
case os = "OS"
case keyExpiry = "KeyExpiry"
}
}
struct PeerStatus: Decodable {
var stableID: String
var hostName: String
var dnsName: String
var online: Bool
var inNetworkMap: Bool
var shareeNode: Bool?
var exitNodeOption: Bool?
var sshHostKeys: [String]?
var tailscaleIPs: [String]?
var os: String?
var keyExpiry: String?
var lastSeen: String?
enum CodingKeys: String, CodingKey {
case stableID = "ID"
case hostName = "HostName"
case dnsName = "DNSName"
case online = "Online"
case inNetworkMap = "InNetworkMap"
case shareeNode = "ShareeNode"
case sshHostKeys
case exitNodeOption = "ExitNodeOption"
case tailscaleIPs = "TailscaleIPs"
case os = "OS"
case keyExpiry = "KeyExpiry"
case lastSeen = "LastSeen"
}
}
}
}
+28 -1
View File
@@ -35,6 +35,7 @@ final class MobileAppModel {
let container: ModelContainer
let hosts: any HostRepository
let library: HostLibraryModel
let tailscale: TailscaleModel
let credentials: CredentialLibraryModel
let keyExportServices: KeyExportServices
let snippets: SnippetLibraryModel
@@ -304,6 +305,13 @@ final class MobileAppModel {
persistence: persistence, secrets: secrets,
localDefaults: memoryOnly ? nil : settingsDefaults
)
let tailscaleDevices = devices
let tailscaleAccess = pro
tailscale = TailscaleModel(
library: library, store: SwiftDataTailscaleLocalStore(context: ModelContext(container)),
factory: LibTailscaleNodeSession.factory,
hostname: { tailscaleDevices.currentDeviceName }, hasAccess: { tailscaleAccess.canUse(.tailscale) }
)
let forwardingRouter = agentForwardingRouter
connectionCoordinator.updateAgentForwarding(
VaultSSHAgent.factory(
@@ -398,7 +406,20 @@ final class MobileAppModel {
sftpSettings.didChange = { [weak self] _ in
self?.reconcileConnectionPreferences()
}
library.didDeleteTailscaleGroups = { [weak self] groups, hostIDs in
guard let self else { return }
self.metrics.core.removeTailscaleGroups(groups)
for id in hostIDs {
self.closeWorkspaces(for: id)
self.sftpEdits.invalidate(hostID: id)
self.transfers.cancelAll(for: id)
self.metrics.core.remove(id)
}
}
tailscale.start()
pro.accessDidChange = { [weak self] in
self?.tailscale.reconcile()
self?.metrics.core.reconcile()
self?.reconcileConnectionPreferences()
}
sshSettings.didChange = { preferences in
@@ -479,6 +500,7 @@ final class MobileAppModel {
@discardableResult
func deleteHost(_ id: UUID) -> Bool {
guard library.host(id)?.isTailscaleManaged != true else { return false }
do {
assistant.disconnectHost(id)
closeWorkspaces(for: id)
@@ -499,7 +521,7 @@ final class MobileAppModel {
func deleteHostGroup(_ id: HostGroup.ID, keepHosts: Bool) {
if !keepHosts {
let hosts = library.hosts(inTreeOf: id)
let hosts = library.hosts(inTreeOf: id).filter { !$0.isTailscaleManaged }
guard hosts.allSatisfy({ deleteHost($0.id) }) else { return }
}
library.deleteGroupProjection(id)
@@ -507,6 +529,7 @@ final class MobileAppModel {
}
func duplicateHost(_ host: Host) {
guard !host.isTailscaleManaged else { return }
var copy = host
if host.identityID.flatMap(library.identity)?.isHostCustom == true { copy.identityID = nil }
if host.telnetIdentityID.flatMap(library.identity)?.isHostCustom == true { copy.telnetIdentityID = nil }
@@ -629,6 +652,10 @@ final class MobileAppModel {
}
func applyRemoteVault(_ snapshot: VaultSnapshot, keys: Set<VaultRecordKey>) {
let retainedHostIDs = Set(snapshot.hosts.map(\.id))
for key in keys where key.kind == .host && !retainedHostIDs.contains(key.id) {
metrics.core.remove(key.id)
}
let plan = VaultRemoteChangePlan.make(
currentHosts: library.hosts,
snapshot: snapshot,
+1
View File
@@ -9,6 +9,7 @@ nonisolated enum MobileRuntimeVersions {
return decode(info.version, length: Int(info.version_len)) ?? unknown
}
static var tailscale: String { LibTailscaleNodeSession.version }
static var libssh2: String { SSHRuntimeVersions.libssh2 }
static var openSSL: String { SSHRuntimeVersions.openSSL }
+117 -91
View File
@@ -27,6 +27,8 @@ struct MobileHostEditor: View {
let app: MobileAppModel
private var library: HostLibraryModel { app.library }
let original: Host
@State private var tailscaleUsername = ""
@State private var originalTailscaleUsername = ""
@State private var draft: Host
@State private var editorRoute: EditorRoute?
@State private var customIdentity = HostCustomIdentityDraft()
@@ -46,10 +48,13 @@ struct MobileHostEditor: View {
: String(localized: AppStrings.Vault.editHost)
}
@Environment(\.dismiss) private var dismiss
var body: some View {
MobileEditorSheet(
title: editorTitle,
isDirty: draft != original || !customIdentity.sshUsername.isEmpty
isDirty: tailscaleUsername != originalTailscaleUsername || draft != original
|| !customIdentity.sshUsername.isEmpty
|| !customIdentity.sshPassword.isEmpty || customIdentity.sshKeyID != nil
|| !customIdentity.telnetUsername.isEmpty || !customIdentity.telnetPassword.isEmpty
|| !customIdentity.telnetProxyPassword.isEmpty,
@@ -57,14 +62,22 @@ struct MobileHostEditor: View {
&& draft.preferredTerminalProtocol != nil
&& (!draft.sshEnabled || (1...65_535).contains(draft.sshPort))
&& (!draft.telnetEnabled || (1...65_535).contains(draft.telnetPort))
&& customIdentity.isSSHValid(for: draft)
&& (draft.isTailscaleManaged || customIdentity.isSSHValid(for: draft))
&& customIdentity.isProxyValid(for: draft)
&& customIdentity.isTelnetValid(for: draft)
&& customIdentity.isTelnetProxyValid(for: draft)
&& (!draft.sshEnabled || draft.environmentVariablesAreValid),
topBar: AnyView(HostProtocolProfileControls(protocol: $selectedProtocol)),
topBar: AnyView(
HostProtocolProfileControls(protocol: $selectedProtocol).disabled(draft.isTailscaleManaged)),
save: {
try customIdentity.save(into: &draft, using: library)
if draft.isTailscaleManaged {
guard library.saveTailscaleHost(draft, username: tailscaleUsername) else {
throw MobileEditorError.message(
library.storageError ?? TailscaleConfigurationError.unavailable.localizedDescription)
}
} else {
try customIdentity.save(into: &draft, using: library)
}
},
content: {
HostOrganizationSection(host: $draft) {
@@ -82,117 +95,124 @@ struct MobileHostEditor: View {
MobileTagField(tags: $draft.tags, suggestions: library.allTags)
}
}
HostConnectionFields(host: $draft, protocol: $selectedProtocol)
HostConnectionFields(host: $draft, protocol: $selectedProtocol).disabled(draft.isTailscaleManaged)
if selectedProtocol == .ssh && draft.sshEnabled {
Section(AppStrings.Vault.authentication.swiftUIKey) {
Picker(AppStrings.Vault.identity.swiftUIKey, selection: identityChoice) {
Text(AppStrings.Vault.custom).tag(Choice<Identity.ID>.unset)
Divider()
ForEach(library.reusableIdentities) { identity in
Text(identity.name).tag(Choice.existing(identity.id))
}
Divider()
Text(AppStrings.Keychain.newIdentity).tag(Choice<Identity.ID>.new)
}
if usesCustomIdentity {
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(
AppStrings.Vault.username.swiftUIKey,
text: $customIdentity.sshUsername.singleLineText
)
if draft.isTailscaleManaged {
Section {
TextField(AppStrings.Vault.username.swiftUIKey, text: $tailscaleUsername)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent(AppStrings.Vault.password.swiftUIKey) {
RevealableSecretField(AppStrings.Common.optional, text: $customIdentity.sshPassword)
.multilineTextAlignment(.trailing)
.disabled(!customIdentity.sshPasswordIsEditable)
}
Picker(AppStrings.Vault.key.swiftUIKey, selection: $customIdentity.sshKeyID) {
Text(AppStrings.Common.none).tag(SSHKeyRef.ID?.none)
ForEach(library.keys) { key in Text(key.label).tag(SSHKeyRef.ID?.some(key.id)) }
}
}
}
Section(AppStrings.Vault.routing.swiftUIKey) {
Picker(AppStrings.Vault.jumpHost.swiftUIKey, selection: $draft.jumpHostID) {
Text(AppStrings.Common.none).tag(UUID?.none)
ForEach(library.hosts.filter { $0.id != draft.id && $0.sshEnabled }) {
Text($0.name).tag(Optional($0.id))
}
}
Picker(AppStrings.Vault.proxy.swiftUIKey, selection: proxyKind) {
Text(AppStrings.Common.none).tag(HostProxy.ProtocolKind?.none)
ForEach(HostProxy.ProtocolKind.allCases) { kind in
Text(kind.displayName).tag(HostProxy.ProtocolKind?.some(kind))
}
}
if draft.proxy != nil {
LabeledContent(AppStrings.Vault.hostname.swiftUIKey) {
TextField(
AppStrings.Vault.hostname.swiftUIKey,
text: proxyString(\.hostname).singleLineText, axis: .horizontal
)
.singleLinePaste()
.lineLimit(1)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.multilineTextAlignment(.trailing)
}
.accessibilityLabel(AppStrings.Vault.proxyHostname.swiftUIKey)
LabeledContent(AppStrings.Common.port.swiftUIKey) {
TextField(
AppStrings.Common.port.swiftUIKey, value: proxyInt(\.port),
format: .number.grouping(.never)
).lineLimit(
1
)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
}
.accessibilityLabel(AppStrings.Vault.proxyPort.swiftUIKey)
Picker(AppStrings.Vault.identity.swiftUIKey, selection: proxyIdentityChoice) {
Text(AppStrings.Common.none).tag(Choice<Identity.ID>.unset)
Text(AppStrings.Vault.custom).tag(Choice<Identity.ID>.custom)
} else {
Section(AppStrings.Vault.authentication.swiftUIKey) {
Picker(AppStrings.Vault.identity.swiftUIKey, selection: identityChoice) {
Text(AppStrings.Vault.custom).tag(Choice<Identity.ID>.unset)
Divider()
ForEach(proxyIdentities) { identity in
Text("\(identity.name) · \(identity.username)")
.tag(Choice.existing(identity.id))
ForEach(library.reusableIdentities) { identity in
Text(identity.name).tag(Choice.existing(identity.id))
}
Divider()
Text(AppStrings.Keychain.newIdentity).tag(Choice<Identity.ID>.new)
}
.accessibilityLabel(AppStrings.Vault.proxyIdentity.swiftUIKey)
if customIdentity.usesCustomProxy {
if usesCustomIdentity {
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(
AppStrings.Vault.username.swiftUIKey,
text: proxyString(\.username).singleLineText
text: $customIdentity.sshUsername.singleLineText
)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent(AppStrings.Vault.password.swiftUIKey) {
RevealableSecretField(
AppStrings.Vault.required, text: $customIdentity.proxyPassword
)
.multilineTextAlignment(.trailing)
.disabled(!customIdentity.proxyPasswordIsEditable)
RevealableSecretField(AppStrings.Common.optional, text: $customIdentity.sshPassword)
.multilineTextAlignment(.trailing)
.disabled(!customIdentity.sshPasswordIsEditable)
}
} else if draft.proxy?.identityID != nil {
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
Picker(AppStrings.Vault.key.swiftUIKey, selection: $customIdentity.sshKeyID) {
Text(AppStrings.Common.none).tag(SSHKeyRef.ID?.none)
ForEach(library.keys) { key in Text(key.label).tag(SSHKeyRef.ID?.some(key.id)) }
}
}
}
Section(AppStrings.Vault.routing.swiftUIKey) {
Picker(AppStrings.Vault.jumpHost.swiftUIKey, selection: $draft.jumpHostID) {
Text(AppStrings.Common.none).tag(UUID?.none)
ForEach(library.hosts.filter { $0.id != draft.id && $0.sshEnabled }) {
Text($0.name).tag(Optional($0.id))
}
}
Picker(AppStrings.Vault.proxy.swiftUIKey, selection: proxyKind) {
Text(AppStrings.Common.none).tag(HostProxy.ProtocolKind?.none)
ForEach(HostProxy.ProtocolKind.allCases) { kind in
Text(kind.displayName).tag(HostProxy.ProtocolKind?.some(kind))
}
}
if draft.proxy != nil {
LabeledContent(AppStrings.Vault.hostname.swiftUIKey) {
TextField(
AppStrings.Vault.username.swiftUIKey,
text: proxyString(\.username).singleLineText, axis: .horizontal
AppStrings.Vault.hostname.swiftUIKey,
text: proxyString(\.hostname).singleLineText, axis: .horizontal
)
.singleLinePaste()
.lineLimit(1)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.keyboardType(.URL)
.multilineTextAlignment(.trailing)
}
.accessibilityLabel(AppStrings.Vault.proxyUsername.swiftUIKey)
.accessibilityLabel(AppStrings.Vault.proxyHostname.swiftUIKey)
LabeledContent(AppStrings.Common.port.swiftUIKey) {
TextField(
AppStrings.Common.port.swiftUIKey, value: proxyInt(\.port),
format: .number.grouping(.never)
).lineLimit(
1
)
.keyboardType(.numberPad)
.multilineTextAlignment(.trailing)
}
.accessibilityLabel(AppStrings.Vault.proxyPort.swiftUIKey)
Picker(AppStrings.Vault.identity.swiftUIKey, selection: proxyIdentityChoice) {
Text(AppStrings.Common.none).tag(Choice<Identity.ID>.unset)
Text(AppStrings.Vault.custom).tag(Choice<Identity.ID>.custom)
Divider()
ForEach(proxyIdentities) { identity in
Text("\(identity.name) · \(identity.username)")
.tag(Choice.existing(identity.id))
}
Divider()
Text(AppStrings.Keychain.newIdentity).tag(Choice<Identity.ID>.new)
}
.accessibilityLabel(AppStrings.Vault.proxyIdentity.swiftUIKey)
if customIdentity.usesCustomProxy {
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(
AppStrings.Vault.username.swiftUIKey,
text: proxyString(\.username).singleLineText
)
.textInputAutocapitalization(.never).autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
LabeledContent(AppStrings.Vault.password.swiftUIKey) {
RevealableSecretField(
AppStrings.Vault.required, text: $customIdentity.proxyPassword
)
.multilineTextAlignment(.trailing)
.disabled(!customIdentity.proxyPasswordIsEditable)
}
} else if draft.proxy?.identityID != nil {
LabeledContent(AppStrings.Vault.username.swiftUIKey) {
TextField(
AppStrings.Vault.username.swiftUIKey,
text: proxyString(\.username).singleLineText, axis: .horizontal
)
.singleLinePaste()
.lineLimit(1)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.multilineTextAlignment(.trailing)
}
.accessibilityLabel(AppStrings.Vault.proxyUsername.swiftUIKey)
}
}
}
}
@@ -201,6 +221,7 @@ struct MobileHostEditor: View {
sftpInitialPath: $draft.sftpChannelInitialPath,
startupSnippetID: startupSnippetChoice,
agentForwardingEnabled: $draft.agentForwardingEnabled,
allowsAgentForwarding: !draft.isTailscaleManaged,
environmentVariables: $draft.environmentVariables,
snippets: library.snippets,
snippetPackagePath: library.snippetPackagePath,
@@ -522,7 +543,12 @@ struct MobileHostEditor: View {
private func loadCustomOnce() {
guard !didLoadCustom else { return }
didLoadCustom = true
customIdentity = HostCustomIdentityDraft(host: draft, library: library)
if draft.isTailscaleManaged {
tailscaleUsername = library.tailscaleUsername(for: draft)
originalTailscaleUsername = tailscaleUsername
} else {
customIdentity = HostCustomIdentityDraft(host: draft, library: library)
}
}
}
@@ -46,6 +46,7 @@ struct MobileAppSettingsView: View {
LabeledContent(AppStrings.Settings.ghostty.swiftUIKey, value: MobileRuntimeVersions.ghostty)
LabeledContent(AppStrings.Settings.libssh2.swiftUIKey, value: MobileRuntimeVersions.libssh2)
LabeledContent(AppStrings.Settings.openSSL.swiftUIKey, value: MobileRuntimeVersions.openSSL)
LabeledContent(AppStrings.Tailscale.title.swiftUIKey, value: MobileRuntimeVersions.tailscale)
}
Section(AppStrings.Settings.support.swiftUIKey) {
Link(destination: AppReviewController.reviewURL) {
+70 -5
View File
@@ -18,6 +18,7 @@ enum MobileHostMenuAction: Hashable, Sendable {
case telnet
case sftp
case edit
case tailscale
case duplicate
case delete
}
@@ -25,16 +26,18 @@ enum MobileHostMenuAction: Hashable, Sendable {
nonisolated enum MobileHostMenuPolicy {
static let sections: [[MobileHostMenuAction]] = [
[.ssh, .telnet, .sftp],
[.tailscale],
[.edit, .duplicate],
[.delete],
]
static func sections(for host: Host) -> [[MobileHostMenuAction]] {
static func sections(for host: Host, hasTailscalePeer: Bool = false) -> [[MobileHostMenuAction]] {
sections.compactMap { section in
let available = section.filter { action in
switch action {
case .ssh, .sftp: host.sshEnabled
case .telnet: host.telnetEnabled
case .tailscale: host.isTailscaleManaged || hasTailscalePeer
case .edit, .duplicate, .delete: true
}
}
@@ -291,6 +294,7 @@ struct MobileVaultView: View {
private let openMetrics: (() -> Void)?
@Binding var editingHost: Host?
@State private var query = ""
@State private var tailscaleHost: Host?
@State private var deletingHost: Host?
@State private var groupEditorRequest: GroupEditorRequest?
@State private var deletingGroup: HostGroup?
@@ -491,6 +495,9 @@ struct MobileVaultView: View {
}
}
}
.sheet(item: $tailscaleHost) { host in
TailscalePeerView(model: app.tailscale, host: host) { UIPasteboard.general.string = $0 }
}
.sheet(item: $editingHost) { MobileHostEditor(app: app, host: $0) }
.sheet(item: $groupEditorRequest) { request in
MobileHostGroupEditor(
@@ -813,7 +820,8 @@ struct MobileVaultView: View {
.accessibilityElement(children: .combine)
.accessibilityHint(AppStrings.Vault.openWorkspace.swiftUIKey)
} actions: {
let sections = MobileHostMenuPolicy.sections(for: host)
let sections = MobileHostMenuPolicy.sections(
for: host, hasTailscalePeer: app.tailscale.peer(for: host) != nil)
ForEach(sections.indices, id: \.self) { section in
ForEach(sections[section], id: \.self) { action in
hostMenuButton(action, host: host)
@@ -842,14 +850,18 @@ struct MobileVaultView: View {
}
case .edit:
Button(AppStrings.Common.edit.swiftUIKey, systemImage: "pencil") { editingHost = host }
case .tailscale:
Button(AppStrings.Tailscale.title.swiftUIKey, systemImage: "point.3.connected.trianglepath.dotted") {
tailscaleHost = host
}
case .duplicate:
Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: "plus.square.on.square") {
app.duplicateHost(host)
}
}.disabled(host.isTailscaleManaged)
case .delete:
Button(AppStrings.Common.delete.swiftUIKey, systemImage: "trash", role: .destructive) {
deletingHost = host
}
}.disabled(host.isTailscaleManaged)
}
}
}
@@ -859,6 +871,9 @@ struct MobileHostGroupEditor: View {
let original: HostGroup
let existing: Bool
let didSave: (HostGroup) -> Void
@State private var showsTailscaleLogin = false
@State private var exitNodeID = ""
@State private var hostname = ""
@State private var draft: HostGroup
@Environment(\.dismiss) private var dismiss
@@ -872,6 +887,8 @@ struct MobileHostGroupEditor: View {
existing = group != nil
self.didSave = didSave
_draft = State(initialValue: initial)
_hostname = State(initialValue: app.tailscale.savedHostname(groupID: initial.id) ?? "")
_exitNodeID = State(initialValue: app.tailscale.exitNodeID(groupID: initial.id))
}
private var trimmedName: String {
@@ -882,7 +899,9 @@ struct MobileHostGroupEditor: View {
MobileEditorSheet(
title: existing
? String(localized: AppStrings.Vault.editGroup) : String(localized: AppStrings.Vault.newGroup),
isDirty: draft != original, canSave: !trimmedName.isEmpty, compact: true,
isDirty: draft != original || hostname != (app.tailscale.savedHostname(groupID: draft.id) ?? "")
|| exitNodeID != app.tailscale.exitNodeID(groupID: draft.id),
canSave: !trimmedName.isEmpty && !showsTailscaleLogin, compact: true,
save: save
) {
TextField(AppStrings.Common.name.swiftUIKey, text: $draft.name.singleLineText, axis: .horizontal)
@@ -894,6 +913,32 @@ struct MobileHostGroupEditor: View {
Text(choice.path).tag(HostGroup.ID?.some(choice.group.id))
}
}
Section(AppStrings.Tailscale.title.swiftUIKey) {
if let configuration = draft.tailscale {
LabeledContent(AppStrings.Tailscale.network.swiftUIKey, value: configuration.network.dnsSuffix)
TailscaleExitNodeField(model: app.tailscale, groupID: draft.id, selection: $exitNodeID)
if app.tailscale.runningGroups.contains(draft.id) {
TailscaleDeviceFields(model: app.tailscale, groupID: draft.id, hostname: $hostname) { value in
UIPasteboard.general.string = value
}
} else if !showsTailscaleLogin {
Button(AppStrings.Tailscale.login.swiftUIKey) {
if app.pro.require(.tailscale) { showsTailscaleLogin = true }
}.disabled(trimmedName.isEmpty)
}
} else {
Toggle(AppStrings.Common.enabled.swiftUIKey, isOn: tailscaleEnabled)
}
if showsTailscaleLogin {
TailscaleLoginFields(
model: app.tailscale, name: trimmedName, parentID: draft.parentID,
group: existing ? draft : nil
) { id in
if let group = app.library.group(id) { didSave(group) }
dismiss()
}
}
}
}
.onChange(of: app.library.groups.map(\.id)) {
if existing && !app.library.groups.contains(where: { $0.id == original.id }) {
@@ -902,6 +947,19 @@ struct MobileHostGroupEditor: View {
}
}
private var tailscaleEnabled: Binding<Bool> {
Binding(
get: { showsTailscaleLogin },
set: { enabled in
if enabled {
showsTailscaleLogin = app.pro.require(.tailscale)
} else {
app.tailscale.cancelLogin()
showsTailscaleLogin = false
}
})
}
private var parentChoices: [(group: HostGroup, path: String)] {
let forbidden = Set([draft.id] + app.library.descendantGroups(of: draft.id).map(\.id))
return app.library.groups.filter { !forbidden.contains($0.id) }
@@ -921,6 +979,13 @@ struct MobileHostGroupEditor: View {
}
private func save() async throws {
if draft.tailscale != nil, app.tailscale.savedHostname(groupID: draft.id) != nil {
try await app.tailscale.saveHostname(hostname, groupID: draft.id)
}
if draft.tailscale != nil {
try app.tailscale.saveExitNode(exitNodeID, groupID: draft.id)
draft.tailscale = app.library.group(draft.id)?.tailscale
}
draft.name = trimmedName
app.library.upsert(draft)
if let error = app.library.storageError { throw MobileEditorError.message(error) }
+37 -2
View File
@@ -390,6 +390,13 @@ final class MobileWorkspace: Identifiable {
authenticationPrompt?.failureMessage = error.localizedDescription
return
}
} else if host.isTailscaleManaged {
if save, !hosts.saveTailscaleUsername(name, hostID: host.id) {
connectionFailure = TailscaleConfigurationError.unavailable.localizedDescription
authenticationPrompt = makeAuthenticationPrompt()
return
}
submissions[host.id] = SSHAuthenticationSubmission(username: name)
} else {
let chosenKey = identity?.keyID ?? keyID
if save {
@@ -665,6 +672,19 @@ final class MobileWorkspace: Identifiable {
purpose: descriptor.kind == .ssh ? .sshSession : .sftpChannel,
username: usernameHint ?? hosts.identity(for: host)?.username,
mode: reconnecting ? .reconnection : .connection)
if host.isTailscaleManaged, submissions[host.id] == nil, usernameHint == nil,
hosts.savedTailscaleUsername(for: host) == nil
{
authenticationRequired = true
authenticationIsProxy = false
authenticationMode = .localIdentity
authenticationHostID = host.id
username = hosts.tailscaleUsername(for: host)
authenticationPrompt = makeAuthenticationPrompt()
journey.waitForTailscaleUsername(at: host.id)
busy = false
return
}
if let proxyHost = proxySubmissions.firstHostRequiringProxyPassword(in: route) {
authenticationRequired = true
authenticationIsProxy = true
@@ -865,9 +885,21 @@ final class MobileWorkspace: Identifiable {
connectionFailure =
error is CancellationError
? String(localized: AppStrings.Connections.authenticationCancelled) : error.localizedDescription
let managedSFTPFailure =
host.isTailscaleManaged && descriptor.kind == .sftp
&& !(error is CancellationError)
if managedSFTPFailure {
connectionFailure = String(
localized: AppStrings.Tailscale.sftpFailure(
username: submissions[host.id]?.username ?? usernameHint
?? hosts.tailscaleUsername(for: host),
address: host.host, detail: error.localizedDescription))
authenticationRequired = false
authenticationPrompt = nil
}
var requiresUserInteraction = false
var preventsAutomaticReconnect = error is CancellationError
if let sshError = error as? TermindSSHError {
var preventsAutomaticReconnect = error is CancellationError || managedSFTPFailure
if let sshError = error as? TermindSSHError, !managedSFTPFailure {
switch sshError {
case .missingCredential, .missingUsername, .keyPassphraseRequired, .authenticationFailed:
authenticationRequired = true
@@ -891,6 +923,9 @@ final class MobileWorkspace: Identifiable {
journey.waitForProxyAuthentication(
at: authenticationHostID ?? descriptor.hostID,
failure: error.localizedDescription)
} else if authenticationPrompt?.usernameOnly == true {
journey.waitForTailscaleUsername(
at: authenticationHostID ?? descriptor.hostID, failure: error.localizedDescription)
} else if authenticationPrompt?.requiresPassphrase == true {
journey.waitForLocalPassphrase(
at: authenticationHostID ?? descriptor.hostID, failure: error.localizedDescription)
+2 -1
View File
@@ -94,6 +94,7 @@ struct MobileVaultFeatureTests {
#expect(
MobileHostMenuPolicy.sections == [
[.ssh, .telnet, .sftp],
[.tailscale],
[.edit, .duplicate],
[.delete],
])
@@ -116,7 +117,7 @@ struct MobileVaultFeatureTests {
])
host.sshEnabled = true
#expect(MobileHostMenuPolicy.sections(for: host) == MobileHostMenuPolicy.sections)
#expect(MobileHostMenuPolicy.sections(for: host, hasTailscalePeer: true) == MobileHostMenuPolicy.sections)
host.sshEnabled = false
host.telnetEnabled = false
+34 -5
View File
@@ -54,6 +54,7 @@ final class AppModel {
private let hosts: HostLibraryModel
let proAccess: ProAccessController
let tailscale: TailscaleModel
var hostLibrary: HostLibraryModel { hosts }
let knownHosts: KnownHostsLibraryModel
let credentials: CredentialLibraryModel
@@ -188,6 +189,7 @@ final class AppModel {
sftpHistory: SFTPHistoryModel = SFTPHistoryModel(),
connectionHistory: ConnectionHistoryModel = ConnectionHistoryModel(),
devices: DeviceDirectoryModel? = nil,
tailscaleStore: (any TailscaleLocalStoring)? = nil,
externalToolCalls: ToolAuditModel = ToolAuditModel(),
dependencies: AppDependencies? = nil,
speechTranscription: SpeechTranscriptionCoordinator? = nil,
@@ -233,7 +235,13 @@ final class AppModel {
self.commandHistory = commandHistory
self.sftpHistory = sftpHistory
self.connectionHistory = connectionHistory
self.devices = devices ?? DeviceDirectoryModel(currentDeviceID: connectionHistory.sourceDeviceID)
let devices = devices ?? DeviceDirectoryModel(currentDeviceID: connectionHistory.sourceDeviceID)
self.devices = devices
tailscale = TailscaleModel(
library: hosts, store: tailscaleStore ?? InMemoryTailscaleLocalStore(),
factory: LibTailscaleNodeSession.factory,
hostname: { devices.currentDeviceName }, hasAccess: { proAccess.canUse(.tailscale) }
)
self.externalToolCalls = externalToolCalls
connections = connectionCoordinator
serialPorts = dependencies.serialPortService
@@ -309,10 +317,9 @@ final class AppModel {
}
)
hosts.willDeleteHost = {
[weak forwarding, weak serverMetrics, weak mcpServer, agentConnections, weak sftpEdits] hostID, ruleIDs in
[weak forwarding, weak mcpServer, agentConnections, weak sftpEdits] hostID, ruleIDs in
sftpEdits?.invalidate(hostID: hostID)
mcpServer?.tools.revokeHost(hostID)
serverMetrics?.remove(hostID)
forwarding?.hostConfigurationChanged(hostID)
forwarding?.stop(ruleIDs)
// Not a `HostDeletionOutbox` step like the others: a live socket
@@ -358,6 +365,7 @@ final class AppModel {
outboxRunner.registerHostDeletionHandlers(
repository: hostRepository,
closeWorkspaces: { [weak self] deletion in
self?.serverMetrics.remove(deletion.hostID)
self?.closeWorkspaces(for: deletion.hostID)
self?.connectionHistory.clear(for: deletion.hostID)
},
@@ -383,7 +391,20 @@ final class AppModel {
sftpSettings.didChange = { [weak self] _ in
self?.reconcileSFTPPreferences()
}
hosts.didDeleteTailscaleGroups = { [weak self] groups, hostIDs in
guard let self else { return }
self.serverMetrics.removeTailscaleGroups(groups)
for id in hostIDs {
self.closeWorkspaces(for: id)
self.sftpEdits.invalidate(hostID: id)
self.sftpTransfers.cancelAll(for: id)
self.serverMetrics.remove(id)
}
}
tailscale.start()
proAccess.accessDidChange = { [weak self] in
self?.tailscale.reconcile()
self?.serverMetrics.reconcile()
self?.reconcileConnectionPreferences()
}
sshSettings.didChange = { preferences in
@@ -473,6 +494,10 @@ final class AppModel {
private func applyRemoteVaultSnapshot(
_ snapshot: VaultSnapshot, changedKeys: Set<VaultRecordKey>
) {
let retainedHostIDs = Set(snapshot.hosts.map(\.id))
for key in changedKeys where key.kind == .host && !retainedHostIDs.contains(key.id) {
serverMetrics.remove(key.id)
}
let plan = VaultRemoteChangePlan.make(
currentHosts: hosts.hosts,
snapshot: snapshot,
@@ -483,6 +508,7 @@ final class AppModel {
hosts.willDeleteHost?(change.id, hosts.portForwardings(for: change.id).map(\.id))
}
if change.isRemoved {
serverMetrics.remove(change.id)
closeWorkspaces(for: change.id)
sftpEdits.invalidate(hostID: change.id)
sftpTransfers.cancelAll(for: change.id)
@@ -865,8 +891,10 @@ final class AppModel {
@discardableResult
func requestHostDeletion(_ hostID: Host.ID) -> Bool {
guard hosts.host(hostID)?.isTailscaleManaged != true else { return false }
do {
try hostRepository.requestHostDeletion(hostID)
serverMetrics.remove(hostID)
forwarding.hostConfigurationChanged(hostID)
forwarding.remove(hosts.portForwardings(for: hostID).map(\.id))
commandHistory.clear(for: hostID)
@@ -882,7 +910,7 @@ final class AppModel {
func deleteHostGroup(_ groupID: HostGroup.ID, keepHosts: Bool) {
if !keepHosts {
let affected = hosts.hosts(inTreeOf: groupID)
let affected = hosts.hosts(inTreeOf: groupID).filter { !$0.isTailscaleManaged }
guard affected.allSatisfy({ requestHostDeletion($0.id) }) else { return }
}
hosts.deleteGroupProjection(groupID)
@@ -942,7 +970,7 @@ final class AppModel {
Task { @MainActor in await conversation.stopAndWait() }
}
let task = Task {
[shutdownDependencies, mcpServer, sftpTransfers, agentBackgroundCommands, agentConnections] in
[shutdownDependencies, mcpServer, sftpTransfers, agentBackgroundCommands, agentConnections, tailscale] in
async let stopMCP: Void = {
if let stop = shutdownDependencies?.stopMCP {
await stop()
@@ -970,6 +998,7 @@ final class AppModel {
} else {
await agentConnections.closeAll()
}
await tailscale.shutdown()
}
terminationTask = task
memory.suspend()
+1
View File
@@ -9,6 +9,7 @@ nonisolated enum RuntimeVersions {
return decode(info.version, length: Int(info.version_len)) ?? unknown
}
static var tailscale: String { LibTailscaleNodeSession.version }
static var libssh2: String { SSHRuntimeVersions.libssh2 }
static var openSSL: String { SSHRuntimeVersions.openSSL }
+1
View File
@@ -178,6 +178,7 @@ struct TermindApp: App {
sourceDeviceID: connectionHistoryDeviceID
),
devices: devices,
tailscaleStore: SwiftDataTailscaleLocalStore(context: ModelContext(container)),
externalToolCalls: externalToolCalls,
dependencies: AppDependencies(
hostRepository: repository,
@@ -16,6 +16,15 @@ final class TransferLibraryModel {
extension TransferLibraryModel: RemoteSessionHostStore {
func host(_ id: Host.ID) -> Host? { library.host(id) }
func networkConfigurationStamp(for host: Host) -> Data { library.networkConfigurationStamp(for: host) }
func tailscaleNetwork(for host: Host) throws -> (any SSHNetworkDialing)? {
try library.tailscaleNetwork(for: host)
}
func savedTailscaleUsername(for host: Host) -> String? { library.savedTailscaleUsername(for: host) }
func tailscaleUsername(for host: Host) -> String { library.tailscaleUsername(for: host) }
@discardableResult func saveTailscaleUsername(_ username: String, hostID: Host.ID) -> Bool {
library.saveTailscaleUsername(username, hostID: hostID)
}
func identity(_ id: Identity.ID) -> Identity? { library.identity(id) }
func identity(for host: Host) -> Identity? { library.identity(for: host) }
func customIdentity(for hostID: Host.ID, purpose: HostCustomIdentityPurpose) -> Identity? {
@@ -132,7 +132,7 @@ final class ExternalAuthCoordinator {
}
sshWaiters[request.id] = continuation
append(.ssh(request), clientName: clientName, runID: runID)
if request.keyboardInteractive?.prompts.isEmpty == true {
if request.keyboardInteractive?.isInformational == true {
Task { [weak self] in
await Task.yield()
self?.respondSSH(.keyboardInteractive([]), requestID: request.id)
+154 -129
View File
@@ -53,6 +53,8 @@ struct HostEditorView: View {
@Environment(VaultWorkEditingSession.self) private var editingSession
@State private var draft: Host
@State private var tailscaleUsername = ""
@State private var originalTailscaleUsername = ""
@State private var original: Host
@State private var originalCustomIdentity = CustomIdentityState(HostCustomIdentityDraft())
@State private var route: Route?
@@ -82,7 +84,7 @@ struct HostEditorView: View {
symbol: "server.rack", close: cancel, closeLabel: AppStrings.Common.cancel
) {
VStack(spacing: 0) {
HostProtocolProfileControls(protocol: $selectedProtocol)
HostProtocolProfileControls(protocol: $selectedProtocol).disabled(draft.isTailscaleManaged)
.padding(.horizontal, Metrics.Space.l)
.padding(.vertical, Metrics.Space.m)
.termindGlassBar()
@@ -149,171 +151,180 @@ struct HostEditorView: View {
}
}
HostConnectionFields(host: $draft, protocol: $selectedProtocol)
HostConnectionFields(host: $draft, protocol: $selectedProtocol).disabled(draft.isTailscaleManaged)
if selectedProtocol == .ssh && draft.sshEnabled {
Section(AppStrings.Vault.authentication.swiftUIKey) {
// One control, because a host says *where* and an identity says
// *who*. The login name and the secret travel together — a
// fleet shares one `deploy` account, and typing that name into
// eight host forms is eight places to fix when it changes.
//
// No SSH Agent option, and there will not be one: reaching
// `SSH_AUTH_SOCK` means opening a UNIX socket outside the
// container, which the App Sandbox does not grant. Offering the
// mode and then failing at connect time would be worse than not
// offering it.
Picker(AppStrings.Vault.identity.swiftUIKey, selection: identityChoice) {
Text(AppStrings.Vault.custom).tag(Choice<Identity.ID>.unset)
Divider()
ForEach(app.hostLibrary.reusableIdentities) { identity in
Text("\(identity.name) · \(identity.username)")
.tag(Choice.existing(identity.id))
}
Divider()
Text(AppStrings.Keychain.newIdentity).tag(Choice<Identity.ID>.new)
if draft.isTailscaleManaged {
Section {
TextField(AppStrings.Vault.username.swiftUIKey, text: $tailscaleUsername)
}
if usesCustomIdentity {
TextField(
AppStrings.Vault.username.swiftUIKey, text: $customIdentity.sshUsername.singleLineText,
axis: .horizontal
)
.singleLinePaste().lineLimit(1)
.padding(.leading, Metrics.Space.xxl)
RevealableSecretField(
AppStrings.Vault.password, text: $customIdentity.sshPassword,
prompt: Text(AppStrings.Common.optional)
)
.padding(.leading, Metrics.Space.xxl)
.disabled(!customIdentity.sshPasswordIsEditable)
Picker(AppStrings.Vault.key.swiftUIKey, selection: $customIdentity.sshKeyID) {
Text(AppStrings.Common.none).tag(SSHKeyRef.ID?.none)
ForEach(app.hostLibrary.keys) { key in
Text(key.label).tag(SSHKeyRef.ID?.some(key.id))
}
}
.padding(.leading, Metrics.Space.xxl)
}
}
Section(AppStrings.Vault.routing.swiftUIKey) {
Picker(AppStrings.Vault.jumpHost.swiftUIKey, selection: $draft.jumpHostID) {
Text(AppStrings.Common.none).tag(Host.ID?.none)
Divider()
ForEach(jumpHostSections) { section in
Section(section.title) {
ForEach(section.hosts) { host in
Text(host.name)
.tag(Host.ID?.some(host.id))
}
}
}
}
Picker(AppStrings.Vault.proxy.swiftUIKey, selection: proxyKind) {
Text(AppStrings.Common.none).tag(HostProxy.ProtocolKind?.none)
ForEach(HostProxy.ProtocolKind.allCases) { kind in
Text(kind.displayName).tag(HostProxy.ProtocolKind?.some(kind))
}
}
if draft.proxy != nil {
TextField(
AppStrings.Vault.hostname.swiftUIKey, text: proxyString(\.hostname).singleLineText,
axis: .horizontal
)
.singleLinePaste().lineLimit(1)
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel(AppStrings.Vault.proxyHostname.swiftUIKey)
TextField(
AppStrings.Common.port.swiftUIKey, value: proxyInt(\.port),
format: .number.grouping(.never)
).lineLimit(1)
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel(AppStrings.Vault.proxyPort.swiftUIKey)
Picker(AppStrings.Vault.identity.swiftUIKey, selection: proxyIdentityChoice) {
Text(AppStrings.Common.none).tag(Choice<Identity.ID>.unset)
Text(AppStrings.Vault.custom).tag(Choice<Identity.ID>.custom)
} else {
Section(AppStrings.Vault.authentication.swiftUIKey) {
// One control, because a host says *where* and an identity says
// *who*. The login name and the secret travel together — a
// fleet shares one `deploy` account, and typing that name into
// eight host forms is eight places to fix when it changes.
//
// No SSH Agent option, and there will not be one: reaching
// `SSH_AUTH_SOCK` means opening a UNIX socket outside the
// container, which the App Sandbox does not grant. Offering the
// mode and then failing at connect time would be worse than not
// offering it.
Picker(AppStrings.Vault.identity.swiftUIKey, selection: identityChoice) {
Text(AppStrings.Vault.custom).tag(Choice<Identity.ID>.unset)
Divider()
ForEach(proxyIdentities) { identity in
ForEach(app.hostLibrary.reusableIdentities) { identity in
Text("\(identity.name) · \(identity.username)")
.tag(Choice.existing(identity.id))
}
Divider()
Text(AppStrings.Keychain.newIdentity).tag(Choice<Identity.ID>.new)
}
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel(AppStrings.Vault.proxyIdentity.swiftUIKey)
if customIdentity.usesCustomProxy {
if usesCustomIdentity {
TextField(
AppStrings.Vault.username.swiftUIKey, text: proxyString(\.username).singleLineText,
AppStrings.Vault.username.swiftUIKey, text: $customIdentity.sshUsername.singleLineText,
axis: .horizontal
)
.singleLinePaste().lineLimit(1)
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel(AppStrings.Vault.proxyUsername.swiftUIKey)
RevealableSecretField(
AppStrings.Vault.password, text: $customIdentity.proxyPassword,
prompt: Text(AppStrings.Vault.required)
AppStrings.Vault.password, text: $customIdentity.sshPassword,
prompt: Text(AppStrings.Common.optional)
)
.padding(.leading, Metrics.Space.xxl)
.disabled(!customIdentity.proxyPasswordIsEditable)
} else if draft.proxy?.identityID != nil {
.disabled(!customIdentity.sshPasswordIsEditable)
Picker(AppStrings.Vault.key.swiftUIKey, selection: $customIdentity.sshKeyID) {
Text(AppStrings.Common.none).tag(SSHKeyRef.ID?.none)
ForEach(app.hostLibrary.keys) { key in
Text(key.label).tag(SSHKeyRef.ID?.some(key.id))
}
}
.padding(.leading, Metrics.Space.xxl)
}
}
Section(AppStrings.Vault.routing.swiftUIKey) {
Picker(AppStrings.Vault.jumpHost.swiftUIKey, selection: $draft.jumpHostID) {
Text(AppStrings.Common.none).tag(Host.ID?.none)
Divider()
ForEach(jumpHostSections) { section in
Section(section.title) {
ForEach(section.hosts) { host in
Text(host.name)
.tag(Host.ID?.some(host.id))
}
}
}
}
Picker(AppStrings.Vault.proxy.swiftUIKey, selection: proxyKind) {
Text(AppStrings.Common.none).tag(HostProxy.ProtocolKind?.none)
ForEach(HostProxy.ProtocolKind.allCases) { kind in
Text(kind.displayName).tag(HostProxy.ProtocolKind?.some(kind))
}
}
if draft.proxy != nil {
TextField(
AppStrings.Vault.username.swiftUIKey, text: proxyString(\.username).singleLineText,
AppStrings.Vault.hostname.swiftUIKey, text: proxyString(\.hostname).singleLineText,
axis: .horizontal
)
.singleLinePaste()
.lineLimit(1)
.singleLinePaste().lineLimit(1)
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel(AppStrings.Vault.proxyUsername.swiftUIKey)
.accessibilityLabel(AppStrings.Vault.proxyHostname.swiftUIKey)
TextField(
AppStrings.Common.port.swiftUIKey, value: proxyInt(\.port),
format: .number.grouping(.never)
).lineLimit(1)
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel(AppStrings.Vault.proxyPort.swiftUIKey)
Picker(AppStrings.Vault.identity.swiftUIKey, selection: proxyIdentityChoice) {
Text(AppStrings.Common.none).tag(Choice<Identity.ID>.unset)
Text(AppStrings.Vault.custom).tag(Choice<Identity.ID>.custom)
Divider()
ForEach(proxyIdentities) { identity in
Text("\(identity.name) · \(identity.username)")
.tag(Choice.existing(identity.id))
}
Divider()
Text(AppStrings.Keychain.newIdentity).tag(Choice<Identity.ID>.new)
}
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel(AppStrings.Vault.proxyIdentity.swiftUIKey)
if customIdentity.usesCustomProxy {
TextField(
AppStrings.Vault.username.swiftUIKey, text: proxyString(\.username).singleLineText,
axis: .horizontal
)
.singleLinePaste().lineLimit(1)
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel(AppStrings.Vault.proxyUsername.swiftUIKey)
RevealableSecretField(
AppStrings.Vault.password, text: $customIdentity.proxyPassword,
prompt: Text(AppStrings.Vault.required)
)
.padding(.leading, Metrics.Space.xxl)
.disabled(!customIdentity.proxyPasswordIsEditable)
} else if draft.proxy?.identityID != nil {
TextField(
AppStrings.Vault.username.swiftUIKey, text: proxyString(\.username).singleLineText,
axis: .horizontal
)
.singleLinePaste()
.lineLimit(1)
.padding(.leading, Metrics.Space.xxl)
.accessibilityLabel(AppStrings.Vault.proxyUsername.swiftUIKey)
}
}
if !draft.host.trimmingCharacters(in: .whitespaces).isEmpty {
LabeledContent(AppStrings.Vault.route.swiftUIKey) {
Text(app.hostLibrary.routeSummary(for: draft))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
}
}
if !draft.host.trimmingCharacters(in: .whitespaces).isEmpty {
LabeledContent(AppStrings.Vault.route.swiftUIKey) {
Text(app.hostLibrary.routeSummary(for: draft))
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.multilineTextAlignment(.trailing)
}
}
}
HostSessionSection(
charset: $draft.sshCharset,
sftpInitialPath: $draft.sftpChannelInitialPath,
startupSnippetID: startupSnippetChoice,
agentForwardingEnabled: $draft.agentForwardingEnabled,
allowsAgentForwarding: !draft.isTailscaleManaged,
environmentVariables: $draft.environmentVariables,
snippets: app.hostLibrary.snippets,
snippetPackagePath: app.hostLibrary.snippetPackagePath,
editEnvironmentVariable: { route = .environmentVariable($0) }
)
Section(AppStrings.Navigation.portForwarding.swiftUIKey) {
Stepper(
value: forwardingConnections,
in: Host.portForwardingMaxConnectionsRange
) {
HStack {
Text(
app.proAccess.canUse(.portForwardingMultiConnection)
? AppStrings.Vault.maxConnections : AppStrings.Pro.maxConnections)
Spacer()
Text(forwardingConnections.wrappedValue, format: .number).monospacedDigit()
if !draft.isTailscaleManaged {
Section(AppStrings.Navigation.portForwarding.swiftUIKey) {
Stepper(
value: forwardingConnections,
in: Host.portForwardingMaxConnectionsRange
) {
HStack {
Text(
app.proAccess.canUse(.portForwardingMultiConnection)
? AppStrings.Vault.maxConnections : AppStrings.Pro.maxConnections)
Spacer()
Text(forwardingConnections.wrappedValue, format: .number).monospacedDigit()
}
}
Text(AppStrings.Vault.forwardingPoolExplanation)
.font(.caption)
.foregroundStyle(.secondary)
}
Text(AppStrings.Vault.forwardingPoolExplanation)
.font(.caption)
.foregroundStyle(.secondary)
}
}
HostIntegrationSection(mode: $draft.shellIntegrationMode)
if !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
if !draft.isTailscaleManaged && !draft.host.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
Section(AppStrings.Vault.sshCommand.swiftUIKey) {
if let command = app.hostLibrary.sshCommand(for: draft) {
HStack(spacing: Metrics.Space.s) {
@@ -770,7 +781,7 @@ struct HostEditorView: View {
private var footer: some View {
HStack(spacing: Metrics.Space.m) {
if hostID != nil {
if hostID != nil && !draft.isTailscaleManaged {
Button(AppStrings.Common.delete.swiftUIKey, role: .destructive) {
confirmsDeletion = true
}
@@ -801,7 +812,7 @@ struct HostEditorView: View {
&& (!draft.sshEnabled || (1...65_535).contains(draft.sshPort))
&& (!draft.telnetEnabled || (1...65_535).contains(draft.telnetPort))
&& customIdentity.isProxyValid(for: draft)
&& customIdentity.isSSHValid(for: draft)
&& (draft.isTailscaleManaged || customIdentity.isSSHValid(for: draft))
&& customIdentity.isTelnetValid(for: draft)
&& customIdentity.isTelnetProxyValid(for: draft)
&& (!draft.sshEnabled || draft.environmentVariablesAreValid)
@@ -821,13 +832,19 @@ struct HostEditorView: View {
guard !didLoad else { return }
didLoad = true
load()
customIdentity = HostCustomIdentityDraft(host: draft, library: app.hostLibrary)
if draft.isTailscaleManaged {
tailscaleUsername = app.hostLibrary.tailscaleUsername(for: draft)
originalTailscaleUsername = tailscaleUsername
} else {
customIdentity = HostCustomIdentityDraft(host: draft, library: app.hostLibrary)
}
}
/// No tag bookkeeping here: `TagField` writes chips straight into
/// `draft.tags`, so there is never a half-typed word left to flush.
private var isDirty: Bool {
draft != original || CustomIdentityState(customIdentity) != originalCustomIdentity
tailscaleUsername != originalTailscaleUsername || draft != original
|| CustomIdentityState(customIdentity) != originalCustomIdentity
}
private func commitChanges() -> Bool {
@@ -839,12 +856,20 @@ struct HostEditorView: View {
let initialPath = draft.sftpChannelInitialPath.trimmingCharacters(in: .whitespacesAndNewlines)
draft.sftpChannelInitialPath = initialPath.isEmpty ? "" : RemotePath.normalized(initialPath)
do {
try customIdentity.save(into: &draft, using: app.hostLibrary)
if draft.isTailscaleManaged {
guard app.hostLibrary.saveTailscaleHost(draft, username: tailscaleUsername) else {
saveError = app.hostLibrary.storageError
return false
}
} else {
try customIdentity.save(into: &draft, using: app.hostLibrary)
}
} catch {
saveError = error.localizedDescription
return false
}
original = draft
originalTailscaleUsername = tailscaleUsername
originalCustomIdentity = CustomIdentityState(customIdentity)
saveError = nil
window.selectVaultItem(.host(draft.id), on: .hosts)
+15 -2
View File
@@ -62,10 +62,16 @@ struct HostContextMenu: View {
Divider()
if hosts.count == 1, let host = hosts.first {
if host.isTailscaleManaged || app.tailscale.peer(for: host) != nil {
Button(AppStrings.Tailscale.title.swiftUIKey, systemImage: "point.3.connected.trianglepath.dotted") {
window.openVaultWork(.tailscaleDetails(host.id), on: .hosts)
}
Divider()
}
Button(AppStrings.Common.edit.swiftUIKey, systemImage: MenuSymbol.edit) {
window.openHostEditor(host: host)
}
if showsDuplicate {
if showsDuplicate && !host.isTailscaleManaged {
Button(AppStrings.Common.duplicate.swiftUIKey, systemImage: MenuSymbol.duplicate) {
var copy = host
if host.identityID.flatMap(app.hostLibrary.identity)?.isHostCustom == true {
@@ -94,7 +100,7 @@ struct HostContextMenu: View {
DestructiveMenuButton(AppStrings.Common.delete) {
requestDelete()
}
.disabled(hosts.isEmpty)
.disabled(hosts.isEmpty || hosts.contains(where: \.isTailscaleManaged))
}
}
@@ -212,6 +218,13 @@ enum HostMenuEntries {
})
}
entries.append(.separator)
if host.isTailscaleManaged || app.tailscale.peer(for: host) != nil {
entries.append(
NativeMenuEntry(title: AppStrings.Tailscale.title, symbol: "point.3.connected.trianglepath.dotted") {
window.openVaultWork(.tailscaleDetails(host.id), on: .hosts)
})
entries.append(.separator)
}
entries.append(
NativeMenuEntry(title: AppStrings.Common.edit, symbol: MenuSymbol.edit) {
window.openHostEditor(host: host)
+71 -8
View File
@@ -19,6 +19,11 @@ struct NewGroupSheet: View {
@Environment(AppModel.self) private var app
@Environment(\.dismiss) private var dismiss
@State private var showsTailscaleLogin = false
@State private var exitNodeID = ""
@State private var hostname = ""
@State private var saveError: String?
@State private var isSaving = false
@State private var name = ""
@State private var parentID: HostGroup.ID?
@@ -58,6 +63,7 @@ struct NewGroupSheet: View {
footer
}
.onAppear(perform: load)
}
// MARK: - Pieces
@@ -78,8 +84,48 @@ struct NewGroupSheet: View {
}
}
}
Section(AppStrings.Tailscale.title.swiftUIKey) {
if let configuration = group?.tailscale {
LabeledContent(AppStrings.Tailscale.network.swiftUIKey, value: configuration.network.dnsSuffix)
if let group {
TailscaleExitNodeField(model: app.tailscale, groupID: group.id, selection: $exitNodeID)
}
if let group, app.tailscale.runningGroups.contains(group.id) {
TailscaleDeviceFields(model: app.tailscale, groupID: group.id, hostname: $hostname) { value in
NSPasteboard.general.clearContents()
NSPasteboard.general.setString(value, forType: .string)
}
} else if !showsTailscaleLogin {
Button(AppStrings.Tailscale.login.swiftUIKey) {
if app.proAccess.require(.tailscale) { showsTailscaleLogin = true }
}
}
} else {
Toggle(AppStrings.Common.enabled.swiftUIKey, isOn: tailscaleEnabled)
}
if showsTailscaleLogin {
TailscaleLoginFields(
model: app.tailscale, name: trimmedName, parentID: parentID, group: group
) { _ in close() }
}
}
}
.formStyle(.grouped)
.disabled(isSaving)
.overlay(alignment: .bottom) { if let saveError { Text(saveError).foregroundStyle(.red) } }
}
private var tailscaleEnabled: Binding<Bool> {
Binding(
get: { showsTailscaleLogin },
set: { enabled in
if enabled {
showsTailscaleLogin = app.proAccess.require(.tailscale)
} else {
app.tailscale.cancelLogin()
showsTailscaleLogin = false
}
})
}
private var footer: some View {
@@ -91,7 +137,7 @@ struct NewGroupSheet: View {
Button(isEditing ? AppStrings.Common.save.swiftUIKey : AppStrings.Common.add.swiftUIKey, action: add)
.keyboardShortcut(.defaultAction)
.disabled(trimmedName.isEmpty)
.disabled(trimmedName.isEmpty || showsTailscaleLogin)
}
.padding(Metrics.Space.l)
}
@@ -110,6 +156,8 @@ struct NewGroupSheet: View {
private func load() {
if let group {
hostname = app.tailscale.savedHostname(groupID: group.id) ?? ""
exitNodeID = app.tailscale.exitNodeID(groupID: group.id)
name = group.name
parentID = group.parentID
} else {
@@ -123,15 +171,30 @@ struct NewGroupSheet: View {
/// which the store then numbers against the new group's siblings — knowledge
/// this sheet does not have.
private func add() {
if var edited = group {
edited.name = trimmedName
edited.parentID = parentID
create(edited)
} else {
create(HostGroup(name: trimmedName, parentID: parentID))
guard !isSaving else { return }
isSaving = true
Task {
defer { isSaving = false }
do {
if var edited = group {
if edited.tailscale != nil, app.tailscale.savedHostname(groupID: edited.id) != nil {
try await app.tailscale.saveHostname(hostname, groupID: edited.id)
}
if edited.tailscale != nil {
try app.tailscale.saveExitNode(exitNodeID, groupID: edited.id)
edited.tailscale = app.hostLibrary.group(edited.id)?.tailscale
}
edited.name = trimmedName
edited.parentID = parentID
create(edited)
} else {
create(HostGroup(name: trimmedName, parentID: parentID))
}
close()
} catch { saveError = error.localizedDescription }
}
close()
}
}
#Preview {
@@ -0,0 +1,33 @@
import SwiftUI
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
struct TailscaleDetailView: View {
let hostID: Host.ID
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
var body: some View {
VaultDetailScaffold(
title: AppStrings.Tailscale.title, symbol: "point.3.connected.trianglepath.dotted",
close: window.closeVaultWork, showsHeaderClose: false
) {
if let host = app.hostLibrary.host(hostID) {
TailscalePeerForm(model: app.tailscale, host: host) { _ = copyToPasteboard($0) }
} else {
Text(AppStrings.Tailscale.unavailable)
}
} footer: {
HStack {
Spacer()
Button(AppStrings.Common.close.swiftUIKey, action: window.closeVaultWork)
.keyboardShortcut(.cancelAction)
.accessibilityIdentifier("tailscale.close")
}
.padding(Metrics.Space.l)
}
.termindInputAwareFocusEffect()
}
}
+31 -3
View File
@@ -338,9 +338,17 @@ final class SFTPChannelModel: Identifiable {
let stagedAuthentication = suppliedAuthentications[host.id]
connectionJourney.begin(
host: host, route: route, purpose: .sftpChannel,
username: stagedAuthentication?.username ?? store.identity(for: host)?.username,
username: stagedAuthentication?.username ?? usernameHint
?? (host.isTailscaleManaged
? store.savedTailscaleUsername(for: host) : store.identity(for: host)?.username),
mode: reconnecting ? .reconnection : .connection
)
if host.isTailscaleManaged, stagedAuthentication == nil, usernameHint == nil,
store.savedTailscaleUsername(for: host) == nil
{
requestAuthentication(host: host, store: store)
return
}
if let proxyHost = suppliedProxyAuthentications.firstHostRequiringProxyPassword(in: route) {
requestProxyAuthentication(host: proxyHost, store: store)
return
@@ -380,7 +388,7 @@ final class SFTPChannelModel: Identifiable {
guard let self, let store else { return }
do {
let configuration: SSHConnectionConfiguration
if let suppliedAuthentication {
if let suppliedAuthentication, !host.isTailscaleManaged {
let expectedHostKeys = try store.expectedHostKeys(
for: host, temporaryHostKeys: temporaryHostKeys,
trustPolicy: .allowChallenge
@@ -444,10 +452,14 @@ final class SFTPChannelModel: Identifiable {
trustPolicy: .allowChallenge
)
}
var routedConfiguration = configuration
routedConfiguration.network = try store.tailscaleNetwork(
for: (connectionRoute?.hosts ?? store.route(for: host)).first ?? host)
routedConfiguration.tailscaleNodeID = host.tailscale?.stableNodeID
let coordinator = self.connectionCoordinator ?? RemoteConnectionCoordinator()
let client = try await coordinator.connectSFTP(
using: SSHConnectionRequest(
configuration: configuration,
configuration: routedConfiguration,
authorizeHostKey: { [weak self] challenge in
guard let self else { throw CancellationError() }
return await self.connection.authorizeHostKey(challenge)
@@ -514,6 +526,18 @@ final class SFTPChannelModel: Identifiable {
self.homeResolution = .unavailable
self.connection.stopRecovery()
self.connectionJourney.fail(String(localized: AppStrings.Connections.authenticationCancelled))
} catch where host.isTailscaleManaged {
guard self.connectionAttemptID == attemptID else { return }
let username = suppliedAuthentication?.username ?? usernameHint ?? store.tailscaleUsername(for: host)
let message = String(
localized: AppStrings.Tailscale.sftpFailure(
username: username, address: host.host, detail: error.localizedDescription))
self.pendingAuthentication = nil
self.connection.stopRecovery()
self.status = .failed(message)
self.homeResolution = .unavailable
self.hint = message
self.connectionJourney.fail(message, at: host.id)
} catch TermindSSHError.missingCredential,
TermindSSHError.missingUsername,
TermindSSHError.keyPassphraseRequired
@@ -1192,6 +1216,10 @@ final class SFTPChannelModel: Identifiable {
allowsSaving: connectionOrigin.allowsPersistence
)
status = .disconnected
if host.isTailscaleManaged {
connectionJourney.waitForTailscaleUsername(at: host.id, failure: failure)
return
}
switch mode {
case .localIdentity:
if pendingAuthentication?.requiresPassphrase == true {
@@ -503,6 +503,9 @@ private struct AppInfoSettings: View {
LabeledContent(AppStrings.Settings.openSSL.swiftUIKey) {
Text(RuntimeVersions.openSSL).foregroundStyle(.secondary).textSelection(.enabled)
}
LabeledContent(AppStrings.Tailscale.title.swiftUIKey) {
Text(RuntimeVersions.tailscale).foregroundStyle(.secondary).textSelection(.enabled)
}
}
Section(AppStrings.Settings.support.swiftUIKey) {
+3 -2
View File
@@ -83,9 +83,10 @@ nonisolated enum VaultDestination: Hashable, Sendable {
}
/// A deliberate task opened in Vault's third column. Card selection remains a
/// middle-column concern; only explicit New, Edit, Export and Execute actions
/// create a work route.
/// middle-column concern; explicit editing, diagnostics, export and execution
/// actions create a work route.
nonisolated enum VaultWorkRoute: Hashable, Sendable {
case tailscaleDetails(Host.ID)
case hostEditor(Host.ID?, groupID: HostGroup.ID? = nil)
case hostGroupEditor(groupID: HostGroup.ID?, parentID: HostGroup.ID?)
case forwardingEditor(PortForwardingRule.ID?)
+10 -2
View File
@@ -411,7 +411,7 @@ final class SSHSessionModel: Identifiable {
let stagedAuthentication = suppliedAuthentications[host.id]
let username =
stagedAuthentication?.username
?? identity?.username
?? (host.isTailscaleManaged ? store.savedTailscaleUsername(for: host) : identity?.username)
?? usernameHint
connectionJourney.begin(
host: host, route: route, purpose: .sshSession, username: username,
@@ -823,13 +823,17 @@ final class SSHSessionModel: Identifiable {
) async throws {
let wasReconnecting = connection.isRecovering
let presentsConnectionJourney = connectionJourney.isPresented
let configuration = SSHConnectionConfiguration(
var configuration = SSHConnectionConfiguration(
hostID: host.id, host: host.host, port: host.port, username: username,
credentials: credentials, expectedHostKeys: expectedHostKeys,
proxy: proxy, jumps: jumps,
charset: host.charset,
authenticationReference: authenticationReference
)
guard let store = statusStore else { throw TailscaleConfigurationError.unavailable }
configuration.network = try store.tailscaleNetwork(
for: (connectionRoute?.hosts ?? store.route(for: host)).first ?? host)
configuration.tailscaleNodeID = host.tailscale?.stableNodeID
let terminalSurface = terminalSurface
commandMonitor.beginConnection()
let actor = (connectionCoordinator ?? RemoteConnectionCoordinator()).makePTYChannel(
@@ -1000,6 +1004,10 @@ final class SSHSessionModel: Identifiable {
allowsSaving: connectionOrigin.allowsPersistence
)
status = .disconnected
if host.isTailscaleManaged {
connectionJourney.waitForTailscaleUsername(at: host.id, failure: failureMessage)
return
}
switch mode {
case .localIdentity:
if requiresPassphrase {
+2
View File
@@ -37,6 +37,8 @@ struct VaultWorkDetailView: View {
@ViewBuilder
var body: some View {
switch route {
case .tailscaleDetails(let hostID):
TailscaleDetailView(hostID: hostID)
case .hostEditor(let id, let groupID):
HostEditorView(hostID: id, initialGroupID: groupID)
case .hostGroupEditor(let groupID, let parentID):
+9 -6
View File
@@ -100,19 +100,22 @@ struct VaultDetailScaffold<Content: View, Footer: View>: View {
let close: () -> Void
var closeLabel: LocalizedStringResource = AppStrings.Common.close
var closeSymbol = "xmark"
var showsHeaderClose = true
@ViewBuilder let content: Content
@ViewBuilder let footer: Footer
var body: some View {
VStack(spacing: 0) {
HStack(spacing: Metrics.Space.m) {
Button(action: close) {
Label(closeLabel, systemImage: closeSymbol)
.labelStyle(.iconOnly)
if showsHeaderClose {
Button(action: close) {
Label(closeLabel, systemImage: closeSymbol)
.labelStyle(.iconOnly)
}
.buttonStyle(.borderless)
.keyboardShortcut(.cancelAction)
.help(closeLabel)
}
.buttonStyle(.borderless)
.keyboardShortcut(.cancelAction)
.help(closeLabel)
Label(title, systemImage: symbol)
.font(.headline)
+19 -1
View File
@@ -16,6 +16,24 @@ import struct TermindDomain.Host
@Suite(.serialized)
@MainActor
struct AgentHostAuthenticationTests {
@Test func tailscaleBrowserApprovalRemainsPendingInAssistantUntilTheUserResponds() async throws {
let conversation = ConversationModel()
let interaction = WeakAgentUserInteraction(conversation).sshInteraction
let challenge = SSHKeyboardInteractiveChallenge(
connectionID: UUID(), hostID: UUID(), hostname: "server.example", port: 22, username: "root",
round: 1, name: "", instruction: "Approve at https://login.example/ssh/check", prompts: [],
isTailscaleApproval: true)
let task = Task { try await interaction.respondToChallenge(challenge, invocationID: UUID()) }
defer { task.cancel() }
try await waitForAuthentication(in: conversation)
for _ in 0..<10 { await Task.yield() }
let pending = try #require(conversation.pendingSSHAuthentication)
#expect(pending.keyboardInteractive?.isTailscaleApproval == true)
conversation.respondToSSHAuthentication(.keyboardInteractive([]), requestID: pending.id)
#expect(try await task.value.isEmpty)
#expect(conversation.pendingSSHAuthentication == nil)
}
@Test func webAuthnSigningUsesTheConversationAuthenticationForm() async throws {
let identity = Identity(name: "Deploy", username: "deploy")
let host = Host(name: "Server", host: "server.example", identityID: identity.id)
@@ -377,7 +395,7 @@ struct AgentHostAuthenticationTests {
username: "deploy", password: "fallback-password",
keyID: key.id, save: false
))
case .keyboardInteractive, .securityKey:
case .keyboardInteractive, .securityKey, .tailscaleUsername:
return .cancelled
case .proxy:
return .cancelled
+7
View File
@@ -3,6 +3,13 @@ import Testing
@Suite
struct ArchitectureBoundaryTests {
@Test
func embeddedTailscaleStaysBehindRemoteInfrastructure() throws {
for module in ["TermindDomain", "TermindApplication", "TermindPresentation", "TermindPersistence"] {
try assertNoImports(in: "../Frameworks/\(module)", forbidden: ["TailscaleKit", "CTailscale"])
}
}
@Test
func revenueCatOwnsCommerceTransactions() throws {
try assertNoImports(in: "../Frameworks/TermindInfrastructure/Commerce", forbidden: ["StoreKit"])
+1
View File
@@ -15,6 +15,7 @@ struct RuntimeVersionsTests {
#expect(!RuntimeVersions.ghostty.isEmpty)
#expect(RuntimeVersions.libssh2 != RuntimeVersions.unknown)
#expect(!RuntimeVersions.libssh2.isEmpty)
#expect(RuntimeVersions.tailscale != RuntimeVersions.unknown)
#expect(RuntimeVersions.openSSL.split(separator: ".").count == 3)
}
+74
View File
@@ -0,0 +1,74 @@
import AppKit
import SwiftUI
import TermindApplication
import TermindDomain
import TermindInfrastructure
import TermindPersistence
import Testing
@testable import Termind
@Suite(.requiresVisibleWindow, .serialized) @MainActor
struct TailscaleDetailLayoutTests {
@Test func openingSwitchingResizingAndClosingKeepsTheWindowResponsive() async throws {
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(network: network)
let peers = ["first", "second"].map {
TailscalePeer(
stableID: $0, name: $0, dnsName: "\($0)-long-hostname.example.ts.net",
online: true, sshHostKeys: ["fixture"],
details: TailscalePeerDetails(
addresses: ["100.64.0.1", "fd7a:115c:a1e0::6235:ac3c"], operatingSystem: "linux"))
}
let store = InMemoryTailscaleLocalStore()
try store.save(TailscaleLocalState(groupID: group.id, profileID: UUID(), peers: peers))
let library = HostLibraryModel(groups: [group])
let app = AppModel(
hosts: library, tailscaleStore: store, proAccess: .testing(status: .verificationFailed))
let model = WindowModel(vaultDestination: .hosts)
let container = try TermindPersistence.makeContainer(inMemory: true)
let controller = NSHostingController(
rootView: RootView(window: model).environment(app).modelContainer(container))
let native = NSWindow(
contentRect: CGRect(x: 232, y: 151, width: 1283, height: 801),
styleMask: [.titled, .closable, .resizable, .fullSizeContentView], backing: .buffered, defer: false)
native.isReleasedWhenClosed = false
native.animationBehavior = .none
native.contentViewController = controller
native.makeKeyAndOrderFront(nil)
defer {
app.shutdownForTermination()
native.orderOut(nil)
native.contentViewController = nil
native.close()
}
try await settle(controller.view)
let hosts = library.hosts
#expect(hosts.count == 2)
let originalDestination = model.selectedDestination
for width in [1283.0, 950.0, 800.0] {
native.setContentSize(CGSize(width: width, height: 801))
for host in hosts {
model.openVaultWork(.tailscaleDetails(host.id), on: .hosts)
try await settle(controller.view)
#expect(model.selectedDestination == originalDestination)
#expect(native.attachedSheet == nil)
#expect(model.vaultWorkRoute == .tailscaleDetails(host.id))
let size = native.frame.size
try await settle(controller.view)
#expect(native.frame.size == size)
}
model.closeVaultWork()
try await settle(controller.view)
#expect(model.vaultWorkRoute == nil)
#expect(model.selectedDestination == originalDestination)
}
}
private func settle(_ view: NSView) async throws {
try await Task.sleep(for: .milliseconds(400))
view.layoutSubtreeIfNeeded()
}
}
@@ -0,0 +1,63 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
import struct TermindDomain.Host
@testable import Termind
@Suite @MainActor
struct TailscaleMetricsDeletionTests {
@Test func deletingOrdinaryHostAlsoRemovesItsMetricsConfiguration() {
let host = Host(name: "Ordinary", host: "example.invalid")
let library = HostLibraryModel(hosts: [host])
let preferences = DeletionMetricsPreferences([MetricsSelection(id: host.id, enabled: false)])
let metrics = ServerMetricsModel(hosts: library, preferences: preferences, makeSampler: { DeletionSampler() })
let app = AppModel(hosts: library, serverMetrics: metrics)
metrics.start()
defer { metrics.shutdown() }
#expect(app.requestHostDeletion(host.id))
#expect(library.host(host.id) == nil)
#expect(metrics.selections.isEmpty)
#expect(preferences.values.isEmpty)
}
@Test func deletingTailnetSubtreeRemovesUnseenMetricsEvenWhenKeepingManualHosts() throws {
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(network: network)
let child = HostGroup(name: "Child", parentID: group.id)
let manual = Host(name: "Manual", host: "server", groupID: child.id)
let library = HostLibraryModel(hosts: [manual], groups: [group, child])
var unseen = MetricsSelection(id: network.hostID(stableNodeID: "n123"), enabled: false)
unseen.tailscaleGroupID = group.id
unseen.displayName = "Unseen"
let preferences = DeletionMetricsPreferences([unseen, MetricsSelection(id: manual.id, enabled: false)])
let metrics = ServerMetricsModel(hosts: library, preferences: preferences, makeSampler: { DeletionSampler() })
let app = AppModel(hosts: library, serverMetrics: metrics)
metrics.start()
defer { metrics.shutdown() }
app.deleteHostGroup(group.id, keepHosts: true)
#expect(library.host(manual.id) != nil)
#expect(library.host(manual.id)?.groupID == nil)
#expect(metrics.selections.map(\.id) == [manual.id])
}
}
@MainActor private final class DeletionMetricsPreferences: MetricsConfigurationStoring {
var values: [MetricsSelection]
init(_ values: [MetricsSelection]) { self.values = values }
func load() throws -> [MetricsSelection] { values }
func save(_ selections: [MetricsSelection]) throws { values = selections }
}
private nonisolated struct DeletionSampler: MetricsSampling {
func connect(_ configuration: SSHConnectionConfiguration) async throws { throw CancellationError() }
func sample() async throws -> MetricsSample { throw CancellationError() }
func gpu() async throws -> NvidiaSnapshot? { nil }
func disconnect() async {}
}
@@ -0,0 +1,91 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import Termind
@testable import TermindRemoteInfrastructure
/// macOS transfer-model integration: saved login, successful SFTP, and actionable failures.
@Suite(
.enabled(
if: ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE"] != nil
|| ProcessInfo.processInfo.environment["TERMIND_SSH_FIXTURE_JSON"] != nil), .serialized
)
nonisolated struct TailscaleSFTPChannelWireTests {
@Test(arguments: [false, true]) @MainActor
func savedUsernameInSFTPModelConnectsOrShowsTheActualFailure(rejected: Bool) async throws {
let fixture = try Fixture.load()
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "test.ts.net")
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(network: network)
let library = HostLibraryModel(groups: [group])
let provider = WireProvider(dialer: LoopbackTailnet(port: fixture.port, fingerprint: fixture.hostFingerprint))
library.tailscaleConnections = provider
library.replaceTailscalePeers(
[
TailscalePeer(
stableID: "fixture-node", name: "Fixture",
dnsName: "fixture.test.ts.net", online: true, sshHostKeys: ["fixture"])
], groupID: group.id)
let host = try #require(library.hosts.first)
let transfers = TransferLibraryModel(library: library)
#expect(transfers.saveTailscaleUsername(rejected ? "reject" : "none", hostID: host.id))
let files = SFTPChannelModel(hostID: host.id)
defer { files.disconnect() }
files.connect(using: transfers)
for _ in 0..<2_000 {
if files.status == .connected || files.pendingAuthentication != nil { break }
if case .failed = files.status { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(files.pendingAuthentication == nil)
if rejected {
guard case .failed(let reason) = files.status else {
Issue.record("Expected an explicit SFTP failure")
return
}
#expect(reason.contains("reject@fixture.test.ts.net"))
#expect(reason.contains("libssh2"))
} else {
#expect(files.status == .connected)
#expect(files.path == "/")
}
}
@MainActor private final class WireProvider: TailscaleConnectionProviding {
let dialer: LoopbackTailnet
init(dialer: LoopbackTailnet) { self.dialer = dialer }
func connection(for groupID: UUID) throws -> any SSHNetworkDialing { dialer }
}
private struct Fixture: Decodable {
var port: Int
var hostFingerprint: String
static func load() throws -> Self {
let environment = ProcessInfo.processInfo.environment
// Passing public endpoint metadata through the test runner also works in the app sandbox.
let data: Data
if let json = environment["TERMIND_SSH_FIXTURE_JSON"] {
data = Data(json.utf8)
} else {
let path = try #require(environment["TERMIND_SSH_FIXTURE"])
data = try Data(contentsOf: URL(fileURLWithPath: path))
}
return try JSONDecoder().decode(Self.self, from: data)
}
}
private struct LoopbackTailnet: SSHNetworkDialing {
var port: Int
var fingerprint: String
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 {
#expect(host == "fixture.test.ts.net")
return try SSHSocketConnector.open(host: "127.0.0.1", port: self.port)
}
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> {
#expect(stableNodeID == "fixture-node")
return [SSHHostKeyIdentity(algorithm: .rsa, fingerprint: fingerprint)]
}
}
}
+103
View File
@@ -0,0 +1,103 @@
import Foundation
import TermindApplication
import TermindDomain
import Testing
@testable import Termind
@Suite @MainActor
struct TailscaleSessionRoutingTests {
@Test(arguments: [false, true])
func savedUsernameSkipsThePromptOnANewSession(sftp: Bool) async throws {
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(network: network)
let library = HostLibraryModel(groups: [group])
let transfers = TransferLibraryModel(library: library)
let provider = RoutingProvider()
library.tailscaleConnections = provider
library.replaceTailscalePeers(
[
TailscalePeer(
stableID: "n123", name: "Server",
dnsName: "server.example.ts.net", online: true, sshHostKeys: ["ssh-ed25519 fixture"])
], groupID: group.id)
let host = try #require(library.hosts.first)
#expect(library.saveTailscaleUsername("operator", hostID: host.id))
let terminal = sftp ? nil : SSHSessionModel(host: host)
let files = sftp ? SFTPChannelModel(hostID: host.id) : nil
defer {
terminal?.disconnect()
files?.disconnect()
}
terminal?.connect(using: library)
files?.connect(using: transfers)
#expect(terminal?.pendingAuthentication == nil)
#expect(files?.pendingAuthentication == nil)
for _ in 0..<200 {
if await provider.dialer.openedHost != nil { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(await provider.dialer.openedHost == host.host)
}
@Test(arguments: [false, true])
func usernameSubmissionKeepsTerminalAndSFTPOnTheManagedTransport(sftp: Bool) async throws {
let network = try TailscaleNetwork(controlURL: TailscaleNetwork.defaultControlURL, dnsSuffix: "example.ts.net")
var group = HostGroup(name: "Tailnet")
group.tailscale = TailscaleGroupConfiguration(network: network)
let library = HostLibraryModel(groups: [group])
let transfers = TransferLibraryModel(library: library)
let provider = RoutingProvider()
library.tailscaleConnections = provider
library.replaceTailscalePeers(
[
TailscalePeer(
stableID: "n123", name: "Server", dnsName: "server.example.ts.net", online: true,
sshHostKeys: ["ssh-ed25519 fixture"])
], groupID: group.id)
let host = try #require(library.hosts.first)
let terminal = sftp ? nil : SSHSessionModel(host: host)
let files = sftp ? SFTPChannelModel(hostID: host.id) : nil
defer {
terminal?.disconnect()
files?.disconnect()
}
terminal?.connect(using: library)
files?.connect(using: transfers)
let prompt = try #require(terminal?.pendingAuthentication ?? files?.pendingAuthentication)
#expect(prompt.usernameOnly && prompt.username == "root")
terminal?.continueAuthentication(
identityID: nil, username: "operator", password: nil, passphrase: nil,
keyID: nil, save: true, using: library)
files?.continueAuthentication(
identityID: nil, username: "operator", password: nil, passphrase: nil,
keyID: nil, save: true, using: transfers)
for _ in 0..<200 {
if await provider.dialer.openedHost != nil { break }
try await Task.sleep(for: .milliseconds(10))
}
#expect(await provider.dialer.openedHost == "server.example.ts.net")
#expect(await provider.dialer.requestedNodeIDs == ["n123"])
#expect(library.identities.isEmpty)
#expect(library.tailscaleUsername(for: host) == "operator")
}
}
@MainActor private final class RoutingProvider: TailscaleConnectionProviding {
let dialer = RoutingDialer()
func connection(for groupID: UUID) throws -> any SSHNetworkDialing { dialer }
}
private actor RoutingDialer: SSHNetworkDialing {
var openedHost: String?
var requestedNodeIDs: [String] = []
func open(host: String, port: Int, timeout: TimeInterval) async throws -> Int32 {
openedHost = host
throw CancellationError()
}
func hostKeys(stableNodeID: String) async throws -> Set<SSHHostKeyIdentity> {
requestedNodeIDs.append(stableNodeID)
return [SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: "SHA256:tailnet")]
}
}
+17
View File
@@ -29,6 +29,7 @@
6DF0C216374DA144B1FC2030 /* TermindApplication.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 10111AE8F047FB59B318813A /* TermindApplication.framework */; };
6FB449BC0FE9518E6E22B442 /* TermindPersistence.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 244632CCDB1CA43F45C0EB54 /* TermindPersistence.framework */; };
76535271991F0D93E7277E6F /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; };
7A115CA1E000000000000001 /* TailscaleKit in Frameworks */ = {isa = PBXBuildFile; productRef = 7A115CA1E000000000000002 /* TailscaleKit */; };
7B23538CFDAF459CA4CB4351 /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; };
7B51DD6FD59A860DDA7F80EE /* AgentKit in Frameworks */ = {isa = PBXBuildFile; productRef = TRMD0000000000000010001B /* AgentKit */; };
7DE0BDBF5E7A76602DDD443D /* TermindDomain.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7A62058825D6607920F1E2A1 /* TermindDomain.framework */; };
@@ -646,6 +647,7 @@
LOGG00000000000000000103 /* Logging in Frameworks */,
ABCDEF020000000000000002 /* AgentKitScrubber in Frameworks */,
0C77A0D7A810A78DA1F6CD93 /* CSSH2 in Frameworks */,
7A115CA1E000000000000001 /* TailscaleKit in Frameworks */,
0D54DF6508C7918D7D5B9D5A /* OpenSSL in Frameworks */,
0861B3AA103F736DEE93983B /* TermindInfrastructure.framework in Frameworks */,
88E7F9CE7BC5631B487E2BC5 /* TermindDomain.framework in Frameworks */,
@@ -1133,6 +1135,7 @@
);
name = TermindRemoteInfrastructure;
packageProductDependencies = (
7A115CA1E000000000000002 /* TailscaleKit */,
LOGG00000000000000000002 /* Logging */,
TRMD0000000000000010001C /* AgentKitScrubber */,
TRMD00000000000000100011 /* CSSH2 */,
@@ -1385,6 +1388,7 @@
mainGroup = TRMD00000000000000000030;
minimizedProjectReferenceProxies = 1;
packageReferences = (
7A115CA1E000000000000003 /* XCRemoteSwiftPackageReference "libtailscale-spm" */,
C0CA00000000000000000001 /* XCRemoteSwiftPackageReference "purchases-ios" */,
F1BA00000000000000000010 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */,
DEVK00000000000000000001 /* XCRemoteSwiftPackageReference "DeviceKit" */,
@@ -2890,6 +2894,14 @@
/* End XCConfigurationList section */
/* Begin XCRemoteSwiftPackageReference section */
7A115CA1E000000000000003 /* XCRemoteSwiftPackageReference "libtailscale-spm" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/AkinoKaede/libtailscale-spm.git";
requirement = {
kind = exactVersion;
version = 1.0.0;
};
};
BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/groue/GRDB.swift.git";
@@ -2997,6 +3009,11 @@
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
7A115CA1E000000000000002 /* TailscaleKit */ = {
isa = XCSwiftPackageProductDependency;
package = 7A115CA1E000000000000003 /* XCRemoteSwiftPackageReference "libtailscale-spm" */;
productName = TailscaleKit;
};
BAAD00000000000000000002 /* GRDB */ = {
isa = XCSwiftPackageProductDependency;
package = BAAD00000000000000000001 /* XCRemoteSwiftPackageReference "GRDB.swift" */;
@@ -180,6 +180,15 @@
"version" : "2.0.3-termind-1"
}
},
{
"identity" : "libtailscale-spm",
"kind" : "remoteSourceControl",
"location" : "https://github.com/AkinoKaede/libtailscale-spm.git",
"state" : {
"revision" : "c4cdb3fb91231ab6997bb00ec69bb74898324ac2",
"version" : "1.0.0"
}
},
{
"identity" : "listviewkit",
"kind" : "remoteSourceControl",
+1
Submodule libtailscale-spm added at c4cdb3fb91.