177 lines
9.2 KiB
Swift
177 lines
9.2 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import Testing
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
@testable import TermindRemoteInfrastructure
|
|
|
|
/// Uses disposable localhost sshd servers and keys from the companion script.
|
|
@Suite(.enabled(if: ProcessInfo.processInfo.environment["TERMIND_AGENT_FORWARDING_FIXTURE"] != nil), .serialized)
|
|
@MainActor
|
|
struct SSHAgentForwardingWireTests {
|
|
private struct Fixture: Decodable {
|
|
var port: Int
|
|
var targetPort: Int
|
|
var deniedPort: Int
|
|
var username: String
|
|
var loginPrivateKey: String
|
|
var forwardedPrivateKey: String
|
|
var forwardedPublicKey: String
|
|
var fingerprint: String
|
|
var gitPath: String
|
|
}
|
|
private func fixture() throws -> Fixture {
|
|
let path = try #require(ProcessInfo.processInfo.environment["TERMIND_AGENT_FORWARDING_FIXTURE"])
|
|
return try JSONDecoder().decode(Fixture.self, from: Data(contentsOf: URL(fileURLWithPath: path)))
|
|
}
|
|
private func setup(_ fixture: Fixture) throws -> (Host, HostLibraryModel, RemoteConnectionCoordinator) {
|
|
let host = Host(name: "Agent fixture", host: "127.0.0.1", port: fixture.port, agentForwardingEnabled: true)
|
|
let material = try SSHKeyMaterial.inspect(privateKey: fixture.forwardedPrivateKey, passphrase: nil)
|
|
let key = SSHKeyRef(
|
|
label: "Forwarded fixture", algorithm: material.algorithm,
|
|
publicKey: material.publicKey, fingerprint: material.fingerprint, credentialRef: "fixture-key",
|
|
storage: .thisDevice)
|
|
let secrets = InMemorySecretStore()
|
|
try secrets.setKeyCredential(
|
|
.software(.init(privateKey: fixture.forwardedPrivateKey)), for: key.credentialRef, storage: key.storage)
|
|
let library = HostLibraryModel(hosts: [host], keys: [key], secrets: secrets)
|
|
let coordinator = RemoteConnectionCoordinator()
|
|
coordinator.updateAgentForwarding(VaultSSHAgent.factory(library: library))
|
|
return (host, library, coordinator)
|
|
}
|
|
private func request(_ host: Host, _ fixture: Fixture, port: Int? = nil) -> SSHConnectionRequest {
|
|
SSHConnectionRequest(
|
|
configuration: .init(
|
|
hostID: host.id, host: host.host, port: port ?? host.sshPort, username: fixture.username,
|
|
credentials: [.privateKey(privateKey: fixture.loginPrivateKey, publicKey: nil, passphrase: nil)],
|
|
expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: fixture.fingerprint)]))
|
|
}
|
|
private func ssh(_ fixture: Fixture) -> String {
|
|
"/usr/bin/ssh -F /dev/null -o BatchMode=yes -o IdentityFile=/dev/null -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no -p \(fixture.targetPort) \(fixture.username)@127.0.0.1"
|
|
}
|
|
@Test func execListsKeysAuthenticatesSecondHopAndReadsGit() async throws {
|
|
let fixture = try fixture()
|
|
let (host, library, coordinator) = try setup(fixture)
|
|
let connection = try await coordinator.connectConnection(using: request(host, fixture))
|
|
do {
|
|
let command =
|
|
"test -n \"$SSH_AUTH_SOCK\" && /usr/bin/ssh-add -L && \(ssh(fixture)) 'printf forwarded-ok' && \(ssh(fixture)) 'git-upload-pack \(fixture.gitPath)' </dev/null"
|
|
let result = try await connection.execute(
|
|
command, workingDirectory: nil, stdin: nil, timeout: 15, outputLimit: 8192, onOutput: nil)
|
|
#expect(result.stdout.contains("forwarded-ok"))
|
|
#expect(result.stdout.contains(library.keys[0].publicKey.split(separator: " ")[1]))
|
|
// git-upload-pack without a request can return nonzero after advertising;
|
|
// ls-remote is the complete Git protocol check below.
|
|
let git = try await connection.execute(
|
|
"GIT_SSH_COMMAND='/usr/bin/ssh -F /dev/null -o BatchMode=yes -o IdentityFile=/dev/null -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no' /usr/bin/git ls-remote ssh://\(fixture.username)@127.0.0.1:\(fixture.targetPort)\(fixture.gitPath)",
|
|
workingDirectory: nil, stdin: nil, timeout: 15, outputLimit: 4096, onOutput: nil)
|
|
#expect(git.exitCode == 0)
|
|
} catch {
|
|
await connection.disconnect()
|
|
throw error
|
|
}
|
|
await connection.disconnect()
|
|
}
|
|
@Test func forwardingWorksThroughAJumpHost() async throws {
|
|
let fixture = try fixture()
|
|
let (host, _, coordinator) = try setup(fixture)
|
|
var routed = request(host, fixture, port: fixture.targetPort)
|
|
routed.configuration.jumps = [
|
|
.init(
|
|
id: UUID(), name: "Jump fixture", host: "127.0.0.1", port: fixture.port,
|
|
username: fixture.username,
|
|
credentials: [.privateKey(privateKey: fixture.loginPrivateKey, publicKey: nil, passphrase: nil)],
|
|
expectedHostKeys: [.init(algorithm: .ed25519, fingerprint: fixture.fingerprint)])
|
|
]
|
|
let connection = try await coordinator.connectConnection(using: routed)
|
|
do {
|
|
let result = try await connection.execute(
|
|
"test -n \"$SSH_AUTH_SOCK\" && /usr/bin/ssh-add -L",
|
|
workingDirectory: nil, stdin: nil, timeout: 10, outputLimit: 4096, onOutput: nil)
|
|
#expect(result.exitCode == 0)
|
|
#expect(result.stdout.contains("ssh-ed25519"))
|
|
} catch {
|
|
await connection.disconnect()
|
|
throw error
|
|
}
|
|
await connection.disconnect()
|
|
}
|
|
|
|
@Test func assistantAndMCPSharedServicesForwardWithinTheirExecLease() async throws {
|
|
let fixture = try fixture()
|
|
let (initialHost, library, coordinator) = try setup(fixture)
|
|
let material = try SSHKeyMaterial.inspect(privateKey: fixture.loginPrivateKey, passphrase: nil)
|
|
let key = SSHKeyRef(
|
|
label: "Login fixture", algorithm: material.algorithm,
|
|
publicKey: material.publicKey, fingerprint: material.fingerprint,
|
|
credentialRef: "fixture-login", storage: .thisDevice)
|
|
try library.upsert(key, credential: .software(.init(privateKey: fixture.loginPrivateKey)))
|
|
let identity = Identity(name: "Fixture", username: fixture.username, keyID: key.id)
|
|
_ = library.upsert(identity)
|
|
var host = initialHost
|
|
host.identityID = identity.id
|
|
library.upsert(host)
|
|
library.saveKnownHost(
|
|
KnownHostEntry(
|
|
hostname: host.host, port: host.sshPort, fingerprint: fixture.fingerprint))
|
|
let services = AgentHostServices.live(store: library, coordinator: coordinator)
|
|
let context = try await services.authentication.connectionContext(
|
|
hostID: host.id, invocationID: UUID(), interaction: .unavailable, pool: services.connections)
|
|
let connection = context.connection
|
|
let command = "test -n \"$SSH_AUTH_SOCK\" && \(ssh(fixture)) 'printf shared-services-ok'"
|
|
let result = try await services.connections.withExecLease(hostID: host.id, connection: connection) {
|
|
try await connection.execute(
|
|
command, workingDirectory: nil, stdin: nil, timeout: 15, outputLimit: 4096, onOutput: nil)
|
|
}
|
|
#expect(result.exitCode == 0)
|
|
#expect(result.stdout.contains("shared-services-ok"))
|
|
}
|
|
|
|
@Test func serverDenialFailsBeforeExecutingCommand() async throws {
|
|
let fixture = try fixture()
|
|
let (host, _, coordinator) = try setup(fixture)
|
|
let connection = try await coordinator.connectConnection(
|
|
using: request(host, fixture, port: fixture.deniedPort))
|
|
await #expect(throws: (any Error).self) {
|
|
try await connection.execute(
|
|
"printf must-not-run", workingDirectory: nil, stdin: nil, timeout: 5, outputLimit: 1024, onOutput: nil)
|
|
}
|
|
await connection.disconnect()
|
|
}
|
|
@Test func terminalProvidesAgentAndRevocationStopsSigning() async throws {
|
|
let fixture = try fixture()
|
|
let (initialHost, library, coordinator) = try setup(fixture)
|
|
var host = initialHost
|
|
let output = Output()
|
|
let pty = coordinator.makePTYChannel(output: { output.append($0) }, exit: { _ in })
|
|
try await pty.connect(using: request(host, fixture), shellIntegrationEnabled: false, columns: 80, rows: 24)
|
|
await pty.write(Data("\(ssh(fixture)) 'printf wire-pty-%s ok'\n".utf8))
|
|
for _ in 0..<150 {
|
|
if output.text.contains("wire-pty-ok") { break }
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
}
|
|
#expect(output.text.contains("wire-pty-ok"))
|
|
host.agentForwardingEnabled = false
|
|
library.upsert(host)
|
|
await pty.write(Data("/usr/bin/ssh-add -L >/dev/null 2>&1; printf 'revoked-%s' $?\n".utf8))
|
|
for _ in 0..<50 {
|
|
if output.text.contains("revoked-1") { break }
|
|
try await Task.sleep(for: .milliseconds(100))
|
|
}
|
|
#expect(output.text.contains("revoked-1"))
|
|
await pty.disconnect()
|
|
}
|
|
}
|
|
|
|
private nonisolated final class Output: @unchecked Sendable {
|
|
private let lock = NSLock()
|
|
private var bytes = Data()
|
|
func append(_ data: Data) { lock.withLock { bytes.append(data) } }
|
|
var text: String { lock.withLock { String(decoding: bytes, as: UTF8.self) } }
|
|
}
|