From 6eafa29369bc920df1fa9f2a215686233a07faab Mon Sep 17 00:00:00 2001 From: Kaede Akino Date: Sat, 3 Oct 2026 03:12:12 +0800 Subject: [PATCH] feat: add Pro Tailscale SSH and SFTP integration --- .gitmodules | 4 + .../SSHKeyboardInteractiveQueueTests.swift | 21 + .../TailscaleIntegrationTests.swift | 598 ++++++++++++++ FrameworkTests/TailscaleSSHWireTests.swift | 72 ++ Frameworks/Resources/Localizable.xcstrings | 748 ++++++++++++++++++ .../Agent/AgentSSHInteraction.swift | 3 +- .../Agent/AssistantNotifications.swift | 2 +- .../Agent/ConversationModel.swift | 2 +- .../Connections/ConnectionJourney.swift | 6 + .../Connections/RemoteSessionHostStore.swift | 39 +- .../SSHAdditionalConnectionSnapshot.swift | 4 + .../Connections/SSHAuthenticationPrompt.swift | 12 +- .../SSHConnectionPreparation.swift | 20 +- .../SSHKeyboardInteractiveChallenge.swift | 18 +- .../Connections/TailscaleConnections.swift | 42 + .../Connections/TailscalePingResult.swift | 16 + .../FeatureModels/HostLibraryModel.swift | 237 +++++- .../FeatureModels/TailscaleModel.swift | 466 +++++++++++ .../Metrics/ServerMetricsModel.swift | 63 +- .../SSHConnectionTypes.swift | 2 + .../Commerce/ProAccessTypes.swift | 1 + Frameworks/TermindDomain/Hosts/Host.swift | 8 +- .../TermindDomain/Hosts/TailscaleHost.swift | 200 +++++ .../Metrics/MetricsSyncTypes.swift | 4 +- .../TermindDomain/Metrics/ServerMetrics.swift | 2 + .../Vault/SyncRecordFormats.swift | 3 +- .../TermindPersistence/HostPersistence.swift | 21 +- .../TermindPersistence/MetricsSyncStore.swift | 14 + .../SwiftDataHostRepository.swift | 3 +- .../SwiftDataVaultStore.swift | 8 +- .../TailscalePersistence.swift | 90 +++ .../TermindPersistence.swift | 2 +- .../TermindSchemaV47TailscaleModels.swift | 17 + .../TermindSchemaV48TailscaleModels.swift | 19 + .../VaultSyncPersistence.swift | 9 + .../TermindPersistence/VaultWireV2.swift | 28 + .../ConnectionDecisionForms.swift | 33 +- .../ConnectionJourneyRouteView.swift | 2 +- .../HostConnectionFields.swift | 3 +- .../HostIntegrationSection.swift | 7 +- .../SSHKeyboardInteractiveForm.swift | 15 +- .../Strings/Tailscale.swift | 50 ++ .../Tailscale/TailscaleCopyField.swift | 37 + .../Tailscale/TailscaleDeviceFields.swift | 57 ++ .../Tailscale/TailscaleExitNodeField.swift | 32 + .../Tailscale/TailscaleLoginFields.swift | 79 ++ .../Tailscale/TailscalePeerForm.swift | 84 ++ .../Tailscale/TailscalePeerView.swift | 37 + .../Tailscale/TailscalePingSection.swift | 117 +++ .../Agent/AgentConnections.swift | 17 +- .../SSH/SSHPTYChannel.swift | 97 ++- .../SSH/SSHRoutedSocket.swift | 34 +- .../Tailscale/LibTailscaleNodeSession.swift | 247 ++++++ TFI/App/MobileAppModel.swift | 29 +- TFI/App/MobileRuntimeVersions.swift | 1 + .../HostEditor/MobileHostEditor.swift | 208 ++--- .../Settings/MobileAppSettingsView.swift | 1 + TFI/Features/Vault/MobileVaultView.swift | 75 +- TFI/Features/Workspaces/MobileWorkspace.swift | 39 +- TFITests/MobileVaultFeatureTests.swift | 3 +- TFM/App/AppModel.swift | 39 +- TFM/App/RuntimeVersions.swift | 1 + TFM/App/TermindApp.swift | 1 + .../FeatureModels/TransferLibraryModel.swift | 9 + .../ExternalAuthCoordinator.swift | 2 +- TFM/Features/HostEditor/HostEditorView.swift | 283 ++++--- TFM/Features/Hosts/HostActions.swift | 17 +- TFM/Features/Hosts/NewGroupSheet.swift | 79 +- TFM/Features/Hosts/TailscaleDetailView.swift | 33 + TFM/Features/SFTP/SFTPChannelModel.swift | 34 +- TFM/Features/Settings/SettingsPageView.swift | 3 + TFM/Features/Shell/WindowRoute.swift | 5 +- TFM/Features/Terminal/SSHSessionModel.swift | 12 +- TFM/Features/Vault/VaultView.swift | 2 + TFM/UI/Components/ChromeComponents.swift | 15 +- TFMTests/AgentHostAuthenticationTests.swift | 20 +- TFMTests/ArchitectureBoundaryTests.swift | 7 + TFMTests/RuntimeVersionsTests.swift | 1 + TFMTests/TailscaleDetailLayoutTests.swift | 74 ++ TFMTests/TailscaleMetricsDeletionTests.swift | 63 ++ TFMTests/TailscaleSFTPChannelWireTests.swift | 91 +++ TFMTests/TailscaleSessionRoutingTests.swift | 103 +++ Termind.xcodeproj/project.pbxproj | 17 + .../xcshareddata/swiftpm/Package.resolved | 9 + libtailscale-spm | 1 + 85 files changed, 4698 insertions(+), 331 deletions(-) create mode 100644 FrameworkTests/TailscaleIntegrationTests.swift create mode 100644 FrameworkTests/TailscaleSSHWireTests.swift create mode 100644 Frameworks/TermindApplication/Connections/TailscaleConnections.swift create mode 100644 Frameworks/TermindApplication/Connections/TailscalePingResult.swift create mode 100644 Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift create mode 100644 Frameworks/TermindDomain/Hosts/TailscaleHost.swift create mode 100644 Frameworks/TermindPersistence/TailscalePersistence.swift create mode 100644 Frameworks/TermindPersistence/TermindSchemaV47TailscaleModels.swift create mode 100644 Frameworks/TermindPersistence/TermindSchemaV48TailscaleModels.swift create mode 100644 Frameworks/TermindPresentation/Strings/Tailscale.swift create mode 100644 Frameworks/TermindPresentation/Tailscale/TailscaleCopyField.swift create mode 100644 Frameworks/TermindPresentation/Tailscale/TailscaleDeviceFields.swift create mode 100644 Frameworks/TermindPresentation/Tailscale/TailscaleExitNodeField.swift create mode 100644 Frameworks/TermindPresentation/Tailscale/TailscaleLoginFields.swift create mode 100644 Frameworks/TermindPresentation/Tailscale/TailscalePeerForm.swift create mode 100644 Frameworks/TermindPresentation/Tailscale/TailscalePeerView.swift create mode 100644 Frameworks/TermindPresentation/Tailscale/TailscalePingSection.swift create mode 100644 Frameworks/TermindRemoteInfrastructure/Tailscale/LibTailscaleNodeSession.swift create mode 100644 TFM/Features/Hosts/TailscaleDetailView.swift create mode 100644 TFMTests/TailscaleDetailLayoutTests.swift create mode 100644 TFMTests/TailscaleMetricsDeletionTests.swift create mode 100644 TFMTests/TailscaleSFTPChannelWireTests.swift create mode 100644 TFMTests/TailscaleSessionRoutingTests.swift create mode 160000 libtailscale-spm diff --git a/.gitmodules b/.gitmodules index b0692538..b2998d64 100644 --- a/.gitmodules +++ b/.gitmodules @@ -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 diff --git a/FrameworkTests/SSHKeyboardInteractiveQueueTests.swift b/FrameworkTests/SSHKeyboardInteractiveQueueTests.swift index a130fe94..36a511a2 100644 --- a/FrameworkTests/SSHKeyboardInteractiveQueueTests.swift +++ b/FrameworkTests/SSHKeyboardInteractiveQueueTests.swift @@ -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() diff --git a/FrameworkTests/TailscaleIntegrationTests.swift b/FrameworkTests/TailscaleIntegrationTests.swift new file mode 100644 index 00000000..142190e5 --- /dev/null +++ b/FrameworkTests/TailscaleIntegrationTests.swift @@ -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 { [] } +} + +@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 { [] } +} diff --git a/FrameworkTests/TailscaleSSHWireTests.swift b/FrameworkTests/TailscaleSSHWireTests.swift new file mode 100644 index 00000000..5d2a28b7 --- /dev/null +++ b/FrameworkTests/TailscaleSSHWireTests.swift @@ -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 { + #expect(stableNodeID == "fixture-node") + return [SSHHostKeyIdentity(algorithm: .rsa, fingerprint: fingerprint)] + } + } +} diff --git a/Frameworks/Resources/Localizable.xcstrings b/Frameworks/Resources/Localizable.xcstrings index 77875c52..fd7707a2 100644 --- a/Frameworks/Resources/Localizable.xcstrings +++ b/Frameworks/Resources/Localizable.xcstrings @@ -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" : { diff --git a/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift b/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift index 0b1c6331..99aa17fa 100644 --- a/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift +++ b/Frameworks/TermindApplication/Agent/AgentSSHInteraction.swift @@ -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 ) } diff --git a/Frameworks/TermindApplication/Agent/AssistantNotifications.swift b/Frameworks/TermindApplication/Agent/AssistantNotifications.swift index b688fc85..841009f7 100644 --- a/Frameworks/TermindApplication/Agent/AssistantNotifications.swift +++ b/Frameworks/TermindApplication/Agent/AssistantNotifications.swift @@ -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) diff --git a/Frameworks/TermindApplication/Agent/ConversationModel.swift b/Frameworks/TermindApplication/Agent/ConversationModel.swift index 2da34a66..2f2a1ba8 100644 --- a/Frameworks/TermindApplication/Agent/ConversationModel.swift +++ b/Frameworks/TermindApplication/Agent/ConversationModel.swift @@ -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) diff --git a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift index 33ff20f6..6406dd85 100644 --- a/Frameworks/TermindApplication/Connections/ConnectionJourney.swift +++ b/Frameworks/TermindApplication/Connections/ConnectionJourney.swift @@ -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 diff --git a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift index c8fb4459..c7868025 100644 --- a/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift +++ b/Frameworks/TermindApplication/Connections/RemoteSessionHostStore.swift @@ -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 diff --git a/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift b/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift index 4cb7f43e..6f1cf391 100644 --- a/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift +++ b/Frameworks/TermindApplication/Connections/SSHAdditionalConnectionSnapshot.swift @@ -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( diff --git a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift index b34a6101..4e9e0434 100644 --- a/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift +++ b/Frameworks/TermindApplication/Connections/SSHAuthenticationPrompt.swift @@ -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 ?? "" diff --git a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift index 372c3662..fa056c22 100644 --- a/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift +++ b/Frameworks/TermindApplication/Connections/SSHConnectionPreparation.swift @@ -14,6 +14,8 @@ public nonisolated struct SSHConnectionPreparation: Sendable { public var trustedKeys: Set 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, 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 } diff --git a/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift b/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift index c63bad6b..993f7be7 100644 --- a/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift +++ b/Frameworks/TermindApplication/Connections/SSHKeyboardInteractiveChallenge.swift @@ -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: []) diff --git a/Frameworks/TermindApplication/Connections/TailscaleConnections.swift b/Frameworks/TermindApplication/Connections/TailscaleConnections.swift new file mode 100644 index 00000000..b484f063 --- /dev/null +++ b/Frameworks/TermindApplication/Connections/TailscaleConnections.swift @@ -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 +} + +@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 } +} diff --git a/Frameworks/TermindApplication/Connections/TailscalePingResult.swift b/Frameworks/TermindApplication/Connections/TailscalePingResult.swift new file mode 100644 index 00000000..d73e5f3e --- /dev/null +++ b/Frameworks/TermindApplication/Connections/TailscalePingResult.swift @@ -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 + } +} diff --git a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift index 31f52466..7ed1b6bd 100644 --- a/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift +++ b/Frameworks/TermindApplication/FeatureModels/HostLibraryModel.swift @@ -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, Set) -> 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 = [] 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() + 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 } } diff --git a/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift b/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift new file mode 100644 index 00000000..32d7e022 --- /dev/null +++ b/Frameworks/TermindApplication/FeatureModels/TailscaleModel.swift @@ -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 + 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 = [] + 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] = [:] + @ObservationIgnored private var starting: Set = [] + @ObservationIgnored private var loginTask: Task? + @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 { + try await check() + return try await node.hostKeys(stableNodeID: stableNodeID) + } +} diff --git a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift index 4181c22a..2b45a80c 100644 --- a/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift +++ b/Frameworks/TermindApplication/Metrics/ServerMetricsModel.swift @@ -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) { + 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 { diff --git a/Frameworks/TermindApplication/SSHConnectionTypes.swift b/Frameworks/TermindApplication/SSHConnectionTypes.swift index dc49ff90..73bc071e 100644 --- a/Frameworks/TermindApplication/SSHConnectionTypes.swift +++ b/Frameworks/TermindApplication/SSHConnectionTypes.swift @@ -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 diff --git a/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift b/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift index eb449216..5b1da728 100644 --- a/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift +++ b/Frameworks/TermindDomain/Commerce/ProAccessTypes.swift @@ -1,6 +1,7 @@ import Foundation public nonisolated enum ProFeature: String, CaseIterable, Hashable, Sendable { + case tailscale case assistant case remoteMCP case mcpServer diff --git a/Frameworks/TermindDomain/Hosts/Host.swift b/Frameworks/TermindDomain/Hosts/Host.swift index 5a3298cd..6c39add4 100644 --- a/Frameworks/TermindDomain/Hosts/Host.swift +++ b/Frameworks/TermindDomain/Hosts/Host.swift @@ -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 ) { diff --git a/Frameworks/TermindDomain/Hosts/TailscaleHost.swift b/Frameworks/TermindDomain/Hosts/TailscaleHost.swift new file mode 100644 index 00000000..82f75b8e --- /dev/null +++ b/Frameworks/TermindDomain/Hosts/TailscaleHost.swift @@ -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 + } +} diff --git a/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift b/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift index bec47fcd..ffac72eb 100644 --- a/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift +++ b/Frameworks/TermindDomain/Metrics/MetricsSyncTypes.swift @@ -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) } diff --git a/Frameworks/TermindDomain/Metrics/ServerMetrics.swift b/Frameworks/TermindDomain/Metrics/ServerMetrics.swift index 78374f01..5efe11fa 100644 --- a/Frameworks/TermindDomain/Metrics/ServerMetrics.swift +++ b/Frameworks/TermindDomain/Metrics/ServerMetrics.swift @@ -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? diff --git a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift index 285f7aea..db1969f7 100644 --- a/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift +++ b/Frameworks/TermindDomain/Vault/SyncRecordFormats.swift @@ -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, diff --git a/Frameworks/TermindPersistence/HostPersistence.swift b/Frameworks/TermindPersistence/HostPersistence.swift index a35f2860..775055bd 100644 --- a/Frameworks/TermindPersistence/HostPersistence.swift +++ b/Frameworks/TermindPersistence/HostPersistence.swift @@ -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), ] } } diff --git a/Frameworks/TermindPersistence/MetricsSyncStore.swift b/Frameworks/TermindPersistence/MetricsSyncStore.swift index e559d4e0..ce04079e 100644 --- a/Frameworks/TermindPersistence/MetricsSyncStore.swift +++ b/Frameworks/TermindPersistence/MetricsSyncStore.swift @@ -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) diff --git a/Frameworks/TermindPersistence/SwiftDataHostRepository.swift b/Frameworks/TermindPersistence/SwiftDataHostRepository.swift index 976fe861..82313bff 100644 --- a/Frameworks/TermindPersistence/SwiftDataHostRepository.swift +++ b/Frameworks/TermindPersistence/SwiftDataHostRepository.swift @@ -27,7 +27,7 @@ public final class SwiftDataHostRepository: HostRepository { let hosts = try context.fetch(FetchDescriptor()) .map(\.value) .filter { !hidden.contains($0.id) } - let groups = try context.fetch(FetchDescriptor()).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(predicate: #Predicate { $0.id == hostID }) descriptor.fetchLimit = 1 diff --git a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift index 059eb6f7..429ebb91 100644 --- a/Frameworks/TermindPersistence/SwiftDataVaultStore.swift +++ b/Frameworks/TermindPersistence/SwiftDataVaultStore.swift @@ -23,7 +23,7 @@ public final class HostPersistence { private func fetchSnapshot() throws -> VaultSnapshot { let hosts = try context.fetch(FetchDescriptor()).map(\.value) - let groups = try context.fetch(FetchDescriptor()).map(\.value) + let groups = try Self.groups(in: context) let identities = try context.fetch(FetchDescriptor()).map(\.value) let keys = try context.fetch(FetchDescriptor()).map(\.value) let knownHosts = try context.fetch(FetchDescriptor()).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()), with: snapshot.hosts, rowID: \.id, valueID: \.id, diff --git a/Frameworks/TermindPersistence/TailscalePersistence.swift b/Frameworks/TermindPersistence/TailscalePersistence.swift new file mode 100644 index 00000000..9eccb899 --- /dev/null +++ b/Frameworks/TermindPersistence/TailscalePersistence.swift @@ -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()).map { ($0.id, try $0.configuration()) }) + return try context.fetch(FetchDescriptor()).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()) + 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()).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()).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()) where row.groupID == groupID + { + context.delete(row) + } + } + } +} diff --git a/Frameworks/TermindPersistence/TermindPersistence.swift b/Frameworks/TermindPersistence/TermindPersistence.swift index 0d469747..b1e1723c 100644 --- a/Frameworks/TermindPersistence/TermindPersistence.swift +++ b/Frameworks/TermindPersistence/TermindPersistence.swift @@ -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") diff --git a/Frameworks/TermindPersistence/TermindSchemaV47TailscaleModels.swift b/Frameworks/TermindPersistence/TermindSchemaV47TailscaleModels.swift new file mode 100644 index 00000000..30025e0a --- /dev/null +++ b/Frameworks/TermindPersistence/TermindSchemaV47TailscaleModels.swift @@ -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 + } + } +} diff --git a/Frameworks/TermindPersistence/TermindSchemaV48TailscaleModels.swift b/Frameworks/TermindPersistence/TermindSchemaV48TailscaleModels.swift new file mode 100644 index 00000000..ea43a992 --- /dev/null +++ b/Frameworks/TermindPersistence/TermindSchemaV48TailscaleModels.swift @@ -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 + } + } +} diff --git a/Frameworks/TermindPersistence/VaultSyncPersistence.swift b/Frameworks/TermindPersistence/VaultSyncPersistence.swift index 208a0f59..d43f1e4c 100644 --- a/Frameworks/TermindPersistence/VaultSyncPersistence.swift +++ b/Frameworks/TermindPersistence/VaultSyncPersistence.swift @@ -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) }() diff --git a/Frameworks/TermindPersistence/VaultWireV2.swift b/Frameworks/TermindPersistence/VaultWireV2.swift index f15b3dc4..6d82acc5 100644 --- a/Frameworks/TermindPersistence/VaultWireV2.swift +++ b/Frameworks/TermindPersistence/VaultWireV2.swift @@ -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 } diff --git a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift index f01773f4..3c4caa37 100644 --- a/Frameworks/TermindPresentation/ConnectionDecisionForms.swift +++ b/Frameworks/TermindPresentation/ConnectionDecisionForms.swift @@ -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 = "" diff --git a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift index b0eea737..f9232487 100644 --- a/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift +++ b/Frameworks/TermindPresentation/ConnectionJourneyRouteView.swift @@ -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" diff --git a/Frameworks/TermindPresentation/HostConnectionFields.swift b/Frameworks/TermindPresentation/HostConnectionFields.swift index ad333a51..00b168c1 100644 --- a/Frameworks/TermindPresentation/HostConnectionFields.swift +++ b/Frameworks/TermindPresentation/HostConnectionFields.swift @@ -22,7 +22,8 @@ public struct HostOrganizationSection: 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) } diff --git a/Frameworks/TermindPresentation/HostIntegrationSection.swift b/Frameworks/TermindPresentation/HostIntegrationSection.swift index 22c285ad..b517e8b4 100644 --- a/Frameworks/TermindPresentation/HostIntegrationSection.swift +++ b/Frameworks/TermindPresentation/HostIntegrationSection.swift @@ -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, startupSnippetID: Binding = .constant(nil), agentForwardingEnabled: Binding = .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("~")) diff --git a/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift b/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift index abf7c376..b6b55257 100644 --- a/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift +++ b/Frameworks/TermindPresentation/SSHKeyboardInteractiveForm.swift @@ -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) diff --git a/Frameworks/TermindPresentation/Strings/Tailscale.swift b/Frameworks/TermindPresentation/Strings/Tailscale.swift new file mode 100644 index 00000000..25b747aa --- /dev/null +++ b/Frameworks/TermindPresentation/Strings/Tailscale.swift @@ -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." } + } +} diff --git a/Frameworks/TermindPresentation/Tailscale/TailscaleCopyField.swift b/Frameworks/TermindPresentation/Tailscale/TailscaleCopyField.swift new file mode 100644 index 00000000..360ecb12 --- /dev/null +++ b/Frameworks/TermindPresentation/Tailscale/TailscaleCopyField.swift @@ -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) + } + } + } +} diff --git a/Frameworks/TermindPresentation/Tailscale/TailscaleDeviceFields.swift b/Frameworks/TermindPresentation/Tailscale/TailscaleDeviceFields.swift new file mode 100644 index 00000000..9c51e91b --- /dev/null +++ b/Frameworks/TermindPresentation/Tailscale/TailscaleDeviceFields.swift @@ -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, 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) + } +} diff --git a/Frameworks/TermindPresentation/Tailscale/TailscaleExitNodeField.swift b/Frameworks/TermindPresentation/Tailscale/TailscaleExitNodeField.swift new file mode 100644 index 00000000..1e4a1ca6 --- /dev/null +++ b/Frameworks/TermindPresentation/Tailscale/TailscaleExitNodeField.swift @@ -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) { + 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) } + } +} diff --git a/Frameworks/TermindPresentation/Tailscale/TailscaleLoginFields.swift b/Frameworks/TermindPresentation/Tailscale/TailscaleLoginFields.swift new file mode 100644 index 00000000..26b62074 --- /dev/null +++ b/Frameworks/TermindPresentation/Tailscale/TailscaleLoginFields.swift @@ -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)) + } + } + } +} diff --git a/Frameworks/TermindPresentation/Tailscale/TailscalePeerForm.swift b/Frameworks/TermindPresentation/Tailscale/TailscalePeerForm.swift new file mode 100644 index 00000000..a9b1a024 --- /dev/null +++ b/Frameworks/TermindPresentation/Tailscale/TailscalePeerForm.swift @@ -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 } + } + } + } + +} diff --git a/Frameworks/TermindPresentation/Tailscale/TailscalePeerView.swift b/Frameworks/TermindPresentation/Tailscale/TailscalePeerView.swift new file mode 100644 index 00000000..0af9bbc3 --- /dev/null +++ b/Frameworks/TermindPresentation/Tailscale/TailscalePeerView.swift @@ -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 diff --git a/Frameworks/TermindPresentation/Tailscale/TailscalePingSection.swift b/Frameworks/TermindPresentation/Tailscale/TailscalePingSection.swift new file mode 100644 index 00000000..3485d089 --- /dev/null +++ b/Frameworks/TermindPresentation/Tailscale/TailscalePingSection.swift @@ -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) + } +} diff --git a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift index 5fe21317..ae0668b6 100644 --- a/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift +++ b/Frameworks/TermindRemoteInfrastructure/Agent/AgentConnections.swift @@ -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 diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift index e7860b4c..dfd39f5e 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHPTYChannel.swift @@ -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 = [] + 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? + var length: Int32 = 0 + _ = libssh2_session_last_error(session, &message, &length, 0) + let failureDetail = message.map { String(cString: $0) } + var banner: UnsafeMutablePointer? + _ = 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( diff --git a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift index 8b722b4d..14c8402a 100644 --- a/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift +++ b/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift @@ -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], diff --git a/Frameworks/TermindRemoteInfrastructure/Tailscale/LibTailscaleNodeSession.swift b/Frameworks/TermindRemoteInfrastructure/Tailscale/LibTailscaleNodeSession.swift new file mode 100644 index 00000000..3d800380 --- /dev/null +++ b/Frameworks/TermindRemoteInfrastructure/Tailscale/LibTailscaleNodeSession.swift @@ -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 { + 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" + } + } + } +} diff --git a/TFI/App/MobileAppModel.swift b/TFI/App/MobileAppModel.swift index 068cbfc2..1b542a8a 100644 --- a/TFI/App/MobileAppModel.swift +++ b/TFI/App/MobileAppModel.swift @@ -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) { + 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, diff --git a/TFI/App/MobileRuntimeVersions.swift b/TFI/App/MobileRuntimeVersions.swift index ec80bcc6..aca26639 100644 --- a/TFI/App/MobileRuntimeVersions.swift +++ b/TFI/App/MobileRuntimeVersions.swift @@ -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 } diff --git a/TFI/Features/HostEditor/MobileHostEditor.swift b/TFI/Features/HostEditor/MobileHostEditor.swift index 4547e2a2..7ea57e04 100644 --- a/TFI/Features/HostEditor/MobileHostEditor.swift +++ b/TFI/Features/HostEditor/MobileHostEditor.swift @@ -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.unset) - Divider() - ForEach(library.reusableIdentities) { identity in - Text(identity.name).tag(Choice.existing(identity.id)) - } - Divider() - Text(AppStrings.Keychain.newIdentity).tag(Choice.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.unset) - Text(AppStrings.Vault.custom).tag(Choice.custom) + } else { + Section(AppStrings.Vault.authentication.swiftUIKey) { + Picker(AppStrings.Vault.identity.swiftUIKey, selection: identityChoice) { + Text(AppStrings.Vault.custom).tag(Choice.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.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.unset) + Text(AppStrings.Vault.custom).tag(Choice.custom) + Divider() + ForEach(proxyIdentities) { identity in + Text("\(identity.name) · \(identity.username)") + .tag(Choice.existing(identity.id)) + } + Divider() + Text(AppStrings.Keychain.newIdentity).tag(Choice.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) + } } } diff --git a/TFI/Features/Settings/MobileAppSettingsView.swift b/TFI/Features/Settings/MobileAppSettingsView.swift index f991eecf..9115835d 100644 --- a/TFI/Features/Settings/MobileAppSettingsView.swift +++ b/TFI/Features/Settings/MobileAppSettingsView.swift @@ -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) { diff --git a/TFI/Features/Vault/MobileVaultView.swift b/TFI/Features/Vault/MobileVaultView.swift index 844c190a..970260ce 100644 --- a/TFI/Features/Vault/MobileVaultView.swift +++ b/TFI/Features/Vault/MobileVaultView.swift @@ -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 { + 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) } diff --git a/TFI/Features/Workspaces/MobileWorkspace.swift b/TFI/Features/Workspaces/MobileWorkspace.swift index 4e308a74..224450ba 100644 --- a/TFI/Features/Workspaces/MobileWorkspace.swift +++ b/TFI/Features/Workspaces/MobileWorkspace.swift @@ -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) diff --git a/TFITests/MobileVaultFeatureTests.swift b/TFITests/MobileVaultFeatureTests.swift index 4faf7043..7b53de45 100644 --- a/TFITests/MobileVaultFeatureTests.swift +++ b/TFITests/MobileVaultFeatureTests.swift @@ -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 diff --git a/TFM/App/AppModel.swift b/TFM/App/AppModel.swift index 250e1b32..548ce36e 100644 --- a/TFM/App/AppModel.swift +++ b/TFM/App/AppModel.swift @@ -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 ) { + 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() diff --git a/TFM/App/RuntimeVersions.swift b/TFM/App/RuntimeVersions.swift index 54443c7c..f755aff4 100644 --- a/TFM/App/RuntimeVersions.swift +++ b/TFM/App/RuntimeVersions.swift @@ -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 } diff --git a/TFM/App/TermindApp.swift b/TFM/App/TermindApp.swift index 690417e0..fbc0f87f 100644 --- a/TFM/App/TermindApp.swift +++ b/TFM/App/TermindApp.swift @@ -178,6 +178,7 @@ struct TermindApp: App { sourceDeviceID: connectionHistoryDeviceID ), devices: devices, + tailscaleStore: SwiftDataTailscaleLocalStore(context: ModelContext(container)), externalToolCalls: externalToolCalls, dependencies: AppDependencies( hostRepository: repository, diff --git a/TFM/Application/FeatureModels/TransferLibraryModel.swift b/TFM/Application/FeatureModels/TransferLibraryModel.swift index 3ca6a7ac..734a88bd 100644 --- a/TFM/Application/FeatureModels/TransferLibraryModel.swift +++ b/TFM/Application/FeatureModels/TransferLibraryModel.swift @@ -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? { diff --git a/TFM/Features/ExternalRequests/ExternalAuthCoordinator.swift b/TFM/Features/ExternalRequests/ExternalAuthCoordinator.swift index 848f010f..d47b0e6a 100644 --- a/TFM/Features/ExternalRequests/ExternalAuthCoordinator.swift +++ b/TFM/Features/ExternalRequests/ExternalAuthCoordinator.swift @@ -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) diff --git a/TFM/Features/HostEditor/HostEditorView.swift b/TFM/Features/HostEditor/HostEditorView.swift index b6662cd8..685db5c6 100644 --- a/TFM/Features/HostEditor/HostEditorView.swift +++ b/TFM/Features/HostEditor/HostEditorView.swift @@ -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.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.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.unset) - Text(AppStrings.Vault.custom).tag(Choice.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.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.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.unset) + Text(AppStrings.Vault.custom).tag(Choice.custom) + Divider() + ForEach(proxyIdentities) { identity in + Text("\(identity.name) · \(identity.username)") + .tag(Choice.existing(identity.id)) + } + Divider() + Text(AppStrings.Keychain.newIdentity).tag(Choice.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) diff --git a/TFM/Features/Hosts/HostActions.swift b/TFM/Features/Hosts/HostActions.swift index cf28bce1..453f5f4a 100644 --- a/TFM/Features/Hosts/HostActions.swift +++ b/TFM/Features/Hosts/HostActions.swift @@ -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) diff --git a/TFM/Features/Hosts/NewGroupSheet.swift b/TFM/Features/Hosts/NewGroupSheet.swift index f8af0330..6f93574b 100644 --- a/TFM/Features/Hosts/NewGroupSheet.swift +++ b/TFM/Features/Hosts/NewGroupSheet.swift @@ -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 { + 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 { diff --git a/TFM/Features/Hosts/TailscaleDetailView.swift b/TFM/Features/Hosts/TailscaleDetailView.swift new file mode 100644 index 00000000..a2bf9e4b --- /dev/null +++ b/TFM/Features/Hosts/TailscaleDetailView.swift @@ -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() + } +} diff --git a/TFM/Features/SFTP/SFTPChannelModel.swift b/TFM/Features/SFTP/SFTPChannelModel.swift index f41341c7..39c38774 100644 --- a/TFM/Features/SFTP/SFTPChannelModel.swift +++ b/TFM/Features/SFTP/SFTPChannelModel.swift @@ -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 { diff --git a/TFM/Features/Settings/SettingsPageView.swift b/TFM/Features/Settings/SettingsPageView.swift index 2d16dbf5..a3fd6087 100644 --- a/TFM/Features/Settings/SettingsPageView.swift +++ b/TFM/Features/Settings/SettingsPageView.swift @@ -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) { diff --git a/TFM/Features/Shell/WindowRoute.swift b/TFM/Features/Shell/WindowRoute.swift index 1c23feca..6669e1d2 100644 --- a/TFM/Features/Shell/WindowRoute.swift +++ b/TFM/Features/Shell/WindowRoute.swift @@ -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?) diff --git a/TFM/Features/Terminal/SSHSessionModel.swift b/TFM/Features/Terminal/SSHSessionModel.swift index 852dca91..b46e039e 100644 --- a/TFM/Features/Terminal/SSHSessionModel.swift +++ b/TFM/Features/Terminal/SSHSessionModel.swift @@ -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 { diff --git a/TFM/Features/Vault/VaultView.swift b/TFM/Features/Vault/VaultView.swift index 17f201ca..e5d09c5b 100644 --- a/TFM/Features/Vault/VaultView.swift +++ b/TFM/Features/Vault/VaultView.swift @@ -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): diff --git a/TFM/UI/Components/ChromeComponents.swift b/TFM/UI/Components/ChromeComponents.swift index dc1d5e5e..95748264 100644 --- a/TFM/UI/Components/ChromeComponents.swift +++ b/TFM/UI/Components/ChromeComponents.swift @@ -100,19 +100,22 @@ struct VaultDetailScaffold: 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) diff --git a/TFMTests/AgentHostAuthenticationTests.swift b/TFMTests/AgentHostAuthenticationTests.swift index 5010fbba..f422d71a 100644 --- a/TFMTests/AgentHostAuthenticationTests.swift +++ b/TFMTests/AgentHostAuthenticationTests.swift @@ -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 diff --git a/TFMTests/ArchitectureBoundaryTests.swift b/TFMTests/ArchitectureBoundaryTests.swift index 0c167eff..280badec 100644 --- a/TFMTests/ArchitectureBoundaryTests.swift +++ b/TFMTests/ArchitectureBoundaryTests.swift @@ -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"]) diff --git a/TFMTests/RuntimeVersionsTests.swift b/TFMTests/RuntimeVersionsTests.swift index c4c10441..2d1e2ef4 100644 --- a/TFMTests/RuntimeVersionsTests.swift +++ b/TFMTests/RuntimeVersionsTests.swift @@ -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) } diff --git a/TFMTests/TailscaleDetailLayoutTests.swift b/TFMTests/TailscaleDetailLayoutTests.swift new file mode 100644 index 00000000..9147e10f --- /dev/null +++ b/TFMTests/TailscaleDetailLayoutTests.swift @@ -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() + } + +} diff --git a/TFMTests/TailscaleMetricsDeletionTests.swift b/TFMTests/TailscaleMetricsDeletionTests.swift new file mode 100644 index 00000000..bc2f402a --- /dev/null +++ b/TFMTests/TailscaleMetricsDeletionTests.swift @@ -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 {} +} diff --git a/TFMTests/TailscaleSFTPChannelWireTests.swift b/TFMTests/TailscaleSFTPChannelWireTests.swift new file mode 100644 index 00000000..66e71061 --- /dev/null +++ b/TFMTests/TailscaleSFTPChannelWireTests.swift @@ -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 { + #expect(stableNodeID == "fixture-node") + return [SSHHostKeyIdentity(algorithm: .rsa, fingerprint: fingerprint)] + } + } +} diff --git a/TFMTests/TailscaleSessionRoutingTests.swift b/TFMTests/TailscaleSessionRoutingTests.swift new file mode 100644 index 00000000..c8281f35 --- /dev/null +++ b/TFMTests/TailscaleSessionRoutingTests.swift @@ -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 { + requestedNodeIDs.append(stableNodeID) + return [SSHHostKeyIdentity(algorithm: .ed25519, fingerprint: "SHA256:tailnet")] + } +} diff --git a/Termind.xcodeproj/project.pbxproj b/Termind.xcodeproj/project.pbxproj index bb69ba88..e4e6cf8b 100644 --- a/Termind.xcodeproj/project.pbxproj +++ b/Termind.xcodeproj/project.pbxproj @@ -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" */; diff --git a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 6ddd46b3..243a1c66 100644 --- a/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Termind.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -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", diff --git a/libtailscale-spm b/libtailscale-spm new file mode 160000 index 00000000..c4cdb3fb --- /dev/null +++ b/libtailscale-spm @@ -0,0 +1 @@ +Subproject commit c4cdb3fb91231ab6997bb00ec69bb74898324ac2