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