329 lines
15 KiB
Swift
329 lines
15 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import CSSH2
|
|
import Darwin
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
@Test @MainActor func freeOrdinaryHostUsesItsSavedUsernameWithoutAnEmbeddedTailnet() async throws {
|
|
let fixture = try Fixture.load()
|
|
let identity = Identity(name: "Username only", username: "none")
|
|
let host = Host(name: "Tailscale SSH", host: "127.0.0.1", port: fixture.tailscalePort, identityID: identity.id)
|
|
let library = HostLibraryModel(
|
|
hosts: [host], identities: [identity],
|
|
knownHosts: [
|
|
KnownHostEntry(
|
|
hostname: host.host, port: host.port, algorithm: .rsa, fingerprint: fixture.hostFingerprint)
|
|
])
|
|
library.hostAccess = HostAccessPolicy(entitlement: .free)
|
|
let configuration = try await library.prepareSSHConnection(
|
|
for: SSHConnectionRoute(hosts: [host]), submissions: [:]
|
|
).resolve()
|
|
#expect(configuration.username == identity.username)
|
|
#expect(configuration.credentials.isEmpty)
|
|
#expect(configuration.network == nil && configuration.tailscaleNodeID == nil)
|
|
let connection = SSHConnection()
|
|
try await connection.connect(
|
|
using: SSHConnectionRequest(configuration: configuration, interactionPolicy: .forbidden))
|
|
do {
|
|
let result = try await connection.execute(
|
|
"fixture", workingDirectory: nil, stdin: nil, timeout: 5, outputLimit: 1024, onOutput: nil)
|
|
#expect(result.stdout == "fixture\n")
|
|
} catch {
|
|
await connection.disconnect()
|
|
throw error
|
|
}
|
|
await connection.disconnect()
|
|
}
|
|
|
|
@Test(arguments: [false, true]) @MainActor
|
|
func approvalAutomaticallyContinuesOnTheSameTransport(managed: Bool) async throws {
|
|
let fixture = try Fixture.load()
|
|
let queue = SSHKeyboardInteractiveQueue()
|
|
let username = "check-\(UUID().uuidString)"
|
|
var configuration = fixture.configuration(username: username)
|
|
if managed {
|
|
configuration.host = "fixture.test.ts.net"
|
|
configuration.port = 22
|
|
configuration.network = LoopbackTailnet(port: fixture.tailscalePort, fingerprint: fixture.hostFingerprint)
|
|
configuration.tailscaleNodeID = "fixture-node"
|
|
}
|
|
let connection = SSHConnection()
|
|
let request = SSHConnectionRequest(configuration: configuration, keyboardInteractive: queue.handler)
|
|
let opening = Task { try await connection.connect(using: request) }
|
|
defer { opening.cancel() }
|
|
do {
|
|
let challenge = try await waitForApproval(queue)
|
|
#expect(challenge.username == username && challenge.isTailscaleApproval)
|
|
try await fixture.control("repeat", username: username)
|
|
try await fixture.control("accept", username: username)
|
|
try await opening.value
|
|
#expect(queue.pending.isEmpty)
|
|
let files = try await connection.openSFTPChannel()
|
|
#expect(try await files.readFile(at: "/fixture.txt", maximumBytes: 1024) == Data("fixture\n".utf8))
|
|
await files.disconnect()
|
|
} catch {
|
|
opening.cancel()
|
|
await connection.disconnect()
|
|
throw error
|
|
}
|
|
await connection.disconnect()
|
|
}
|
|
|
|
@Test @MainActor func terminalAndJumpUseTheSameAutomaticApproval() async throws {
|
|
let fixture = try Fixture.load()
|
|
let queue = SSHKeyboardInteractiveQueue()
|
|
let jumpUser = "check-\(UUID().uuidString)"
|
|
let targetUser = "check-\(UUID().uuidString)"
|
|
let jumpID = UUID()
|
|
var configuration = fixture.configuration(username: targetUser)
|
|
configuration.jumps = [
|
|
.init(
|
|
id: jumpID, name: "Jump", host: "127.0.0.1", port: fixture.tailscalePort,
|
|
username: jumpUser, credentials: [], expectedHostKeys: configuration.expectedHostKeys)
|
|
]
|
|
let channel = SSHPTYChannel(output: { _ in }, exit: { _ in })
|
|
let request = SSHConnectionRequest(configuration: configuration, keyboardInteractive: queue.handler)
|
|
let opening = Task {
|
|
try await channel.connect(using: request, shellIntegrationEnabled: false, columns: 80, rows: 24)
|
|
}
|
|
defer { opening.cancel() }
|
|
do {
|
|
let jump = try await waitForApproval(queue)
|
|
#expect(jump.hostID == jumpID)
|
|
try await fixture.control("accept", username: jumpUser)
|
|
let target = try await waitForApproval(queue, excluding: jump.id)
|
|
#expect(target.hostID == configuration.hostID)
|
|
#expect(target.connectionID != jump.connectionID)
|
|
try await fixture.control("accept", username: targetUser)
|
|
try await opening.value
|
|
#expect(queue.pending.isEmpty)
|
|
} catch {
|
|
opening.cancel()
|
|
await channel.disconnect()
|
|
throw error
|
|
}
|
|
await channel.disconnect()
|
|
}
|
|
|
|
@Test(arguments: ["reject", "disconnect", "cancel"]) @MainActor
|
|
func failedOrCancelledApprovalRemovesThePrompt(action: String) async throws {
|
|
let fixture = try Fixture.load()
|
|
let queue = SSHKeyboardInteractiveQueue()
|
|
let username = "check-\(UUID().uuidString)"
|
|
let request = SSHConnectionRequest(
|
|
configuration: fixture.configuration(username: username), keyboardInteractive: queue.handler)
|
|
let connection = SSHConnection()
|
|
let opening = Task { try await connection.connect(using: request) }
|
|
defer { opening.cancel() }
|
|
do {
|
|
let challenge = try await waitForApproval(queue)
|
|
if action == "cancel" {
|
|
queue.cancel(challenge.id)
|
|
await #expect(throws: CancellationError.self) { try await opening.value }
|
|
} else {
|
|
try await fixture.control(action, username: username)
|
|
await #expect(throws: TermindSSHError.self) { try await opening.value }
|
|
}
|
|
#expect(queue.pending.isEmpty)
|
|
} catch {
|
|
opening.cancel()
|
|
await connection.disconnect()
|
|
throw error
|
|
}
|
|
await connection.disconnect()
|
|
}
|
|
|
|
@Test func forbiddenApprovalNeverInvokesTheResponder() async throws {
|
|
let fixture = try Fixture.load()
|
|
let connection = SSHConnection()
|
|
await #expect(throws: SSHInteractionRequired.self) {
|
|
try await connection.connect(
|
|
using: SSHConnectionRequest(
|
|
configuration: fixture.configuration(username: "check-\(UUID().uuidString)"),
|
|
keyboardInteractive: { _ in
|
|
Issue.record("Unexpected approval form")
|
|
return []
|
|
},
|
|
interactionPolicy: .forbidden))
|
|
}
|
|
await connection.disconnect()
|
|
}
|
|
|
|
@Test(arguments: [false, true])
|
|
func passwordFallbackAndOrdinaryBannersRetainNormalAuthentication(tailscale: Bool) async throws {
|
|
let fixture = try Fixture.load()
|
|
var configuration = fixture.configuration(username: tailscale ? "password" : "banner-password")
|
|
if !tailscale { configuration.port = fixture.port }
|
|
configuration.credentials = [.password("fixture-password")]
|
|
let connection = SSHConnection()
|
|
try await connection.connect(
|
|
using: SSHConnectionRequest(
|
|
configuration: configuration,
|
|
keyboardInteractive: { _ in
|
|
Issue.record("An accepted banner needs no form")
|
|
return []
|
|
}))
|
|
await connection.disconnect()
|
|
}
|
|
|
|
@Test(arguments: [false, true]) func hostKeyMismatchNeverReachesApproval(managed: Bool) async throws {
|
|
let fixture = try Fixture.load()
|
|
var configuration = fixture.configuration(username: "check-\(UUID().uuidString)")
|
|
configuration.expectedHostKeys = [.init(algorithm: .rsa, fingerprint: "SHA256:wrong")]
|
|
if managed {
|
|
configuration.host = "fixture.test.ts.net"
|
|
configuration.network = LoopbackTailnet(port: fixture.tailscalePort, fingerprint: "SHA256:wrong")
|
|
configuration.tailscaleNodeID = "fixture-node"
|
|
}
|
|
let connection = SSHConnection()
|
|
await #expect(throws: (any Error).self) {
|
|
try await connection.connect(
|
|
using: SSHConnectionRequest(
|
|
configuration: configuration,
|
|
keyboardInteractive: { _ in
|
|
Issue.record("Host key must be verified first")
|
|
return []
|
|
}))
|
|
}
|
|
await connection.disconnect()
|
|
}
|
|
|
|
@Test func managedHostDoesNotFallBackToConfiguredPassword() async throws {
|
|
let fixture = try Fixture.load()
|
|
var configuration = fixture.configuration(username: "password")
|
|
configuration.host = "fixture.test.ts.net"
|
|
configuration.network = LoopbackTailnet(port: fixture.tailscalePort, fingerprint: fixture.hostFingerprint)
|
|
configuration.tailscaleNodeID = "fixture-node"
|
|
configuration.credentials = [.password("fixture-password")]
|
|
let connection = SSHConnection()
|
|
await #expect(throws: TermindSSHError.self) {
|
|
try await connection.connect(using: SSHConnectionRequest(configuration: configuration))
|
|
}
|
|
await connection.disconnect()
|
|
}
|
|
|
|
@Test(arguments: [false, true], ["none", "password"])
|
|
func queryRestoresSessionSettings(blocking: Bool, username: String) async throws {
|
|
let fixture = try Fixture.load()
|
|
try await Task.detached {
|
|
#expect(libssh2_init(0) == 0)
|
|
let socket = try SSHSocketConnector.open(host: "127.0.0.1", port: fixture.tailscalePort)
|
|
defer { Darwin.close(socket) }
|
|
let session = try #require(libssh2_session_init_ex(nil, nil, nil, nil))
|
|
defer { _ = libssh2_session_free(session) }
|
|
libssh2_session_set_blocking(session, 1)
|
|
try SSHConnectionOpener.performHandshake(session, socket: socket, peer: "fixture", timeout: 5)
|
|
libssh2_session_set_blocking(session, blocking ? 1 : 0)
|
|
libssh2_session_set_read_timeout(session, 3)
|
|
let result = try SSHTailscaleAuthentication.query(username: username, session: session, exchange: nil)
|
|
#expect(result == (username == "none" ? .authenticated : .methods(["password"])))
|
|
#expect(libssh2_session_get_blocking(session) == (blocking ? 1 : 0))
|
|
#expect(libssh2_session_get_read_timeout(session) == 3)
|
|
#expect(libssh2_session_get_timeout(session) == 5_000)
|
|
}.value
|
|
}
|
|
|
|
@MainActor private func waitForApproval(
|
|
_ queue: SSHKeyboardInteractiveQueue, excluding: UUID? = nil
|
|
) async throws -> SSHKeyboardInteractiveChallenge {
|
|
for _ in 0..<500 {
|
|
if let challenge = queue.current, challenge.id != excluding { return challenge }
|
|
try await Task.sleep(for: .milliseconds(10))
|
|
}
|
|
throw NSError(domain: "Missing wire approval", code: 1)
|
|
}
|
|
|
|
private struct Fixture: Decodable {
|
|
var port: Int
|
|
var tailscalePort: Int
|
|
var approvalPort: Int
|
|
var hostFingerprint: String
|
|
|
|
func configuration(username: String) -> SSHConnectionConfiguration {
|
|
SSHConnectionConfiguration(
|
|
hostID: UUID(), host: "127.0.0.1", port: tailscalePort, username: username,
|
|
credentials: [], expectedHostKeys: [.init(algorithm: .rsa, fingerprint: hostFingerprint)])
|
|
}
|
|
|
|
func control(_ action: String, username: String) async throws {
|
|
try await Task.detached {
|
|
let socket = try SSHSocketConnector.open(host: "127.0.0.1", port: approvalPort)
|
|
defer { Darwin.close(socket) }
|
|
let command = Array("\(action) \(username)\n".utf8)
|
|
let sent = command.withUnsafeBytes { Darwin.send(socket, $0.baseAddress, $0.count, 0) }
|
|
#expect(sent == command.count)
|
|
var response = [UInt8](repeating: 0, count: 16)
|
|
let received = Darwin.recv(socket, &response, response.count, 0)
|
|
#expect(received > 0)
|
|
#expect(String(decoding: response.prefix(max(0, received)), as: UTF8.self) == "ok\n")
|
|
}.value
|
|
}
|
|
|
|
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)]
|
|
}
|
|
}
|
|
}
|