Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/SSHRoutedSocket.swift
T

579 lines
21 KiB
Swift

import CSSH2
import Darwin
import Foundation
import TermindApplication
import TermindDomain
import struct TermindDomain.Host
/// One authenticated SSH hop used before the destination session.
/// A socket carrying bytes to the final SSH server, either directly or through
/// a chain of authenticated direct-tcpip channels. The caller owns the final
/// libssh2 session; this object owns every preceding hop and its bridge pump.
public nonisolated final class SSHRoutedSocket: @unchecked Sendable {
public let socket: Int32
private var bridges: [SSHChannelSocketBridge]
private var isClosed = false
fileprivate init(socket: Int32, bridges: [SSHChannelSocketBridge]) {
self.socket = socket
self.bridges = bridges
}
deinit { close() }
public static func open(
host: String, port: Int, proxy: SSHTransportProxy?,
jumps: [SSHRouteHopConfiguration], timeout: TimeInterval,
progress: SSHRouteProgressHandler? = nil
) throws -> SSHRoutedSocket {
guard !jumps.isEmpty else {
return SSHRoutedSocket(
socket: try SSHSocketConnector.open(
host: host, port: port, proxy: proxy, timeout: timeout
),
bridges: []
)
}
var bridges: [SSHChannelSocketBridge] = []
var currentSocket = try SSHSocketConnector.open(
host: jumps[0].host, port: jumps[0].port,
proxy: jumps[0].proxy, timeout: timeout
)
do {
for index in jumps.indices {
let hop = jumps[index]
progress?(.connecting(hop.id))
let session = try connectHop(
hop, socket: currentSocket, level: index + 1,
timeout: timeout, progress: progress
)
progress?(.connected(hop.id))
let nextHost: String
let nextPort: Int
let nextProxy: SSHTransportProxy?
if jumps.indices.contains(index + 1) {
let next = jumps[index + 1]
nextHost = next.host
nextPort = next.port
nextProxy = next.proxy
} else {
nextHost = host
nextPort = port
nextProxy = proxy
}
let channelHost = nextProxy?.hostname ?? nextHost
let channelPort = nextProxy?.port ?? nextPort
guard
let channel = channelHost.withCString({ target in
libssh2_channel_direct_tcpip_ex(
session, target, Int32(channelPort), "127.0.0.1", 0
)
})
else {
_ = libssh2_session_disconnect_ex(
session, SSH_DISCONNECT_BY_APPLICATION, "Termind route failed", ""
)
_ = libssh2_session_free(session)
Darwin.close(currentSocket)
throw TermindSSHError.route(
"Jump Host \(index + 1) (\(hop.name)) denied TCP forwarding."
)
}
let bridge = try SSHChannelSocketBridge(
transportSocket: currentSocket, session: session, channel: channel,
timeout: timeout
)
bridges.append(bridge)
currentSocket = bridge.clientSocket
if let nextProxy {
do {
try SSHSocketConnector.negotiate(
socket: currentSocket, host: nextHost, port: nextPort,
proxy: nextProxy
)
} catch {
throw TermindSSHError.route(
"Proxy after Jump Host \(index + 1) (\(hop.name)): \(error.localizedDescription)"
)
}
}
}
return SSHRoutedSocket(socket: currentSocket, bridges: bridges)
} catch {
Darwin.close(currentSocket)
for bridge in bridges.reversed() { bridge.close() }
throw error
}
}
public static func open(
host: String, port: Int, proxy: SSHTransportProxy?,
jumps: [SSHRouteHopConfiguration], timeout: TimeInterval,
progress: SSHRouteProgressHandler? = nil,
authorizeHostKey: SSHHostKeyAuthorizer?
) async throws -> SSHRoutedSocket {
let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.route-opening")
let state = SSHRouteOpeningState()
return try await withTaskCancellationHandler {
do {
try await queue.perform {
try state.openFirst(
destinationHost: host, destinationPort: port,
destinationProxy: proxy, jumps: jumps, timeout: timeout
)
}
for index in jumps.indices {
try Task.checkCancellation()
let hop = jumps[index]
progress?(.connecting(hop.id))
let observed: SSHHostKeyObservation
do {
observed = try await queue.perform {
try state.beginHop(
hop, level: index + 1, timeout: timeout
)
}
let requiredApproval = !hop.expectedHostKeys.contains(observed.identity)
try await SSHSessionWorker.authorizeHostKey(
hostID: hop.id, observed: observed,
expected: hop.expectedHostKeys,
authorizer: authorizeHostKey
)
try Task.checkCancellation()
try await queue.perform {
try state.completeHop(
hop, level: index + 1,
destinationHost: host, destinationPort: port,
destinationProxy: proxy, jumps: jumps,
index: index, timeout: timeout,
afterHostKeyApproval: requiredApproval,
progress: progress
)
}
} catch is CancellationError {
throw CancellationError()
} catch {
throw routeFailure(error, hop: hop, level: index + 1)
}
progress?(.connected(hop.id))
}
return try await queue.perform { try state.takeResult() }
} catch {
await queue.perform { state.cleanup() }
throw error
}
} onCancel: {
state.cancel()
queue.async { state.cleanup() }
}
}
public func close() {
guard !isClosed else { return }
isClosed = true
Darwin.close(socket)
for bridge in bridges.reversed() { bridge.close() }
bridges.removeAll()
}
private static func connectHop(
_ hop: SSHRouteHopConfiguration, socket: Int32, level: Int,
timeout: TimeInterval,
progress: SSHRouteProgressHandler?
) throws -> OpaquePointer {
guard let session = libssh2_session_init_ex(nil, nil, nil, nil) else {
throw TermindSSHError.route("Jump Host \(level) (\(hop.name)): unable to create SSH session.")
}
do {
libssh2_session_set_blocking(session, 1)
try SSHSessionWorker.performHandshake(
session, socket: socket,
peer: SSHSocketConnector.peerDescription(
socket: socket, fallbackHost: hop.host, port: hop.port
),
timeout: timeout
)
let observed = try SSHSessionWorker.observation(
host: hop.host, port: hop.port, session: session
)
try SSHSessionWorker.validateHostKey(observed, expected: hop.expectedHostKeys)
progress?(.authenticating(hop.id))
try SSHSessionWorker.authenticate(
username: hop.username, credentials: hop.credentials, session: session
)
progress?(.authenticated(hop.id))
return session
} catch {
_ = libssh2_session_disconnect_ex(
session, SSH_DISCONNECT_BY_APPLICATION, "Termind route failed", ""
)
_ = libssh2_session_free(session)
throw TermindSSHError.routeHost(
hostID: hop.id,
authentication: (error as? TermindSSHError)?.shouldRequestAuthentication == true,
serverChallenge: (error as? TermindSSHError)?.isServerAuthenticationChallenge == true,
hostKey: (error as? TermindSSHError)?.isHostKeyChanged == true,
message: "Jump Host \(level) (\(hop.name)): \(error.localizedDescription)"
)
}
}
private static func routeFailure(
_ error: any Error, hop: SSHRouteHopConfiguration, level: Int
) -> TermindSSHError {
let ssh = error as? TermindSSHError
let hostKeyFailure: Bool
switch ssh {
case .untrustedHost, .hostKeyChanged, .mismatchedHostKey:
hostKeyFailure = true
default:
hostKeyFailure = false
}
return TermindSSHError.routeHost(
hostID: hop.id,
authentication: ssh?.shouldRequestAuthentication == true,
serverChallenge: ssh?.isServerAuthenticationChallenge == true,
hostKey: hostKeyFailure,
message: "Jump Host \(level) (\(hop.name)): \(error.localizedDescription)"
)
}
}
private nonisolated final class SSHRouteOpeningState: @unchecked Sendable {
private let cancellationLock = NSLock()
private var currentSocket: Int32 = -1
private var cancellableSocket: Int32 = -1
private var bridges: [SSHChannelSocketBridge] = []
private var pendingSession: OpaquePointer?
func openFirst(
destinationHost: String, destinationPort: Int,
destinationProxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration],
timeout: TimeInterval
) throws {
if let first = jumps.first {
currentSocket = try SSHSocketConnector.open(
host: first.host, port: first.port,
proxy: first.proxy, timeout: timeout
)
} else {
currentSocket = try SSHSocketConnector.open(
host: destinationHost, port: destinationPort,
proxy: destinationProxy, timeout: timeout
)
}
setCancellableSocket(currentSocket)
}
func beginHop(
_ hop: SSHRouteHopConfiguration, level: Int,
timeout: TimeInterval
) throws -> SSHHostKeyObservation {
guard pendingSession == nil else { throw TermindSSHError.disconnected }
guard let session = libssh2_session_init_ex(nil, nil, nil, nil) else {
throw TermindSSHError.route(
"Jump Host \(level) (\(hop.name)): unable to create SSH session."
)
}
pendingSession = session
libssh2_session_set_blocking(session, 1)
try SSHSessionWorker.performHandshake(
session, socket: currentSocket,
peer: SSHSocketConnector.peerDescription(
socket: currentSocket, fallbackHost: hop.host, port: hop.port
),
timeout: timeout
)
return try SSHSessionWorker.observation(
host: hop.host, port: hop.port, session: session
)
}
func completeHop(
_ hop: SSHRouteHopConfiguration, level: Int,
destinationHost: String, destinationPort: Int,
destinationProxy: SSHTransportProxy?, jumps: [SSHRouteHopConfiguration],
index: Int, timeout: TimeInterval,
afterHostKeyApproval: Bool,
progress: SSHRouteProgressHandler?
) throws {
guard let session = pendingSession else { throw TermindSSHError.disconnected }
progress?(.authenticating(hop.id))
try SSHSessionWorker.authenticate(
username: hop.username, credentials: hop.credentials,
session: session,
afterHostKeyApproval: afterHostKeyApproval
)
progress?(.authenticated(hop.id))
let nextHost: String
let nextPort: Int
let nextProxy: SSHTransportProxy?
if jumps.indices.contains(index + 1) {
let next = jumps[index + 1]
nextHost = next.host
nextPort = next.port
nextProxy = next.proxy
} else {
nextHost = destinationHost
nextPort = destinationPort
nextProxy = destinationProxy
}
let channelHost = nextProxy?.hostname ?? nextHost
let channelPort = nextProxy?.port ?? nextPort
guard
let channel = channelHost.withCString({ target in
libssh2_channel_direct_tcpip_ex(
session, target, Int32(channelPort), "127.0.0.1", 0
)
})
else {
throw TermindSSHError.route(
"Jump Host \(level) (\(hop.name)) denied TCP forwarding."
)
}
let transportSocket = currentSocket
pendingSession = nil
currentSocket = -1
setCancellableSocket(-1)
let bridge = try SSHChannelSocketBridge(
transportSocket: transportSocket, session: session,
channel: channel, timeout: timeout
)
bridges.append(bridge)
currentSocket = bridge.clientSocket
setCancellableSocket(currentSocket)
if let nextProxy {
do {
try SSHSocketConnector.negotiate(
socket: currentSocket, host: nextHost,
port: nextPort, proxy: nextProxy
)
} catch {
throw TermindSSHError.route(
"Proxy after Jump Host \(level) (\(hop.name)): \(error.localizedDescription)"
)
}
}
}
func takeResult() throws -> SSHRoutedSocket {
guard currentSocket >= 0, pendingSession == nil else {
throw TermindSSHError.disconnected
}
let result = SSHRoutedSocket(socket: currentSocket, bridges: bridges)
currentSocket = -1
setCancellableSocket(-1)
bridges = []
return result
}
func cleanup() {
if let pendingSession {
_ = libssh2_session_disconnect_ex(
pendingSession, SSH_DISCONNECT_BY_APPLICATION,
"Termind route cancelled", ""
)
_ = libssh2_session_free(pendingSession)
self.pendingSession = nil
}
if currentSocket >= 0 {
Darwin.close(currentSocket)
currentSocket = -1
}
setCancellableSocket(-1)
for bridge in bridges.reversed() { bridge.close() }
bridges.removeAll()
}
func cancel() {
cancellationLock.withLock {
if cancellableSocket >= 0 {
_ = Darwin.shutdown(cancellableSocket, SHUT_RDWR)
}
}
}
private func setCancellableSocket(_ socket: Int32) {
cancellationLock.withLock { cancellableSocket = socket }
}
}
/// Bridges a direct-tcpip channel into a normal file descriptor for the next
/// libssh2 session. All calls touching the hop's libssh2 session are confined
/// to this queue.
private nonisolated final class SSHChannelSocketBridge: @unchecked Sendable {
public let clientSocket: Int32
private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.route-bridge")
private var transportSocket: Int32
private var pumpSocket: Int32
private var session: OpaquePointer?
private var channel: OpaquePointer?
private var timer: (any DispatchSourceTimer)?
private var toChannel = Data()
private var toSocket = Data()
private var socketEOF = false
private var channelEOF = false
private var channelWriteClosed = false
private var isClosed = false
public init(
transportSocket: Int32, session: OpaquePointer, channel: OpaquePointer,
timeout: TimeInterval
) throws {
var pair: [Int32] = [-1, -1]
guard Darwin.socketpair(AF_UNIX, SOCK_STREAM, 0, &pair) == 0 else {
_ = libssh2_channel_free(channel)
_ = libssh2_session_free(session)
Darwin.close(transportSocket)
throw TermindSSHError.socket(String(cString: strerror(errno)))
}
self.transportSocket = transportSocket
self.session = session
self.channel = channel
pumpSocket = pair[0]
clientSocket = pair[1]
var yes: Int32 = 1
for descriptor in pair {
setsockopt(
descriptor, SOL_SOCKET, SO_NOSIGPIPE, &yes,
socklen_t(MemoryLayout<Int32>.size)
)
}
var interval = timeval(tv_sec: Int(timeout), tv_usec: 0)
setsockopt(
clientSocket, SOL_SOCKET, SO_RCVTIMEO, &interval,
socklen_t(MemoryLayout<timeval>.size)
)
setsockopt(
clientSocket, SOL_SOCKET, SO_SNDTIMEO, &interval,
socklen_t(MemoryLayout<timeval>.size)
)
let flags = fcntl(pumpSocket, F_GETFL, 0)
if flags >= 0 { _ = fcntl(pumpSocket, F_SETFL, flags | O_NONBLOCK) }
libssh2_session_set_blocking(session, 0)
let source = DispatchSource.makeTimerSource(queue: queue)
source.schedule(deadline: .now(), repeating: .milliseconds(2), leeway: .milliseconds(1))
source.setEventHandler { [weak self] in self?.pump() }
timer = source
source.resume()
}
public func close() {
queue.sync { closeOnQueue() }
}
private func pump() {
guard !isClosed, let session, let channel else { return }
let highWaterMark = 2 * 1024 * 1024
if !socketEOF, toChannel.count < highWaterMark {
var bytes = [UInt8](repeating: 0, count: 32 * 1024)
let count = Darwin.recv(pumpSocket, &bytes, bytes.count, 0)
if count > 0 {
toChannel.append(contentsOf: bytes.prefix(count))
} else if count == 0 {
socketEOF = true
} else if errno != EAGAIN && errno != EWOULDBLOCK {
closeOnQueue()
return
}
}
while !toChannel.isEmpty {
let written = toChannel.withUnsafeBytes { bytes -> Int in
guard let base = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 }
return libssh2_channel_write_ex(channel, 0, base, bytes.count)
}
if written > 0 {
toChannel.removeFirst(written)
} else if written == LIBSSH2_ERROR_EAGAIN {
break
} else {
closeOnQueue()
return
}
}
if socketEOF, toChannel.isEmpty, !channelWriteClosed {
let result = libssh2_channel_send_eof(channel)
if result == 0 {
channelWriteClosed = true
} else if result != LIBSSH2_ERROR_EAGAIN {
closeOnQueue()
return
}
}
if toSocket.count < highWaterMark {
var bytes = [CChar](repeating: 0, count: 32 * 1024)
while toSocket.count < highWaterMark {
let count = libssh2_channel_read_ex(channel, 0, &bytes, bytes.count)
if count > 0 {
toSocket.append(Data(bytes: bytes, count: count))
} else if count == LIBSSH2_ERROR_EAGAIN || count == 0 {
break
} else {
closeOnQueue()
return
}
}
}
if libssh2_channel_eof(channel) == 1 { channelEOF = true }
while !toSocket.isEmpty {
let sent = toSocket.withUnsafeBytes { bytes -> Int in
guard let base = bytes.baseAddress else { return 0 }
return Darwin.send(pumpSocket, base, bytes.count, 0)
}
if sent > 0 {
toSocket.removeFirst(sent)
} else if errno == EAGAIN || errno == EWOULDBLOCK {
break
} else {
closeOnQueue()
return
}
}
if channelEOF && toSocket.isEmpty {
_ = Darwin.shutdown(pumpSocket, SHUT_WR)
}
var next = Int32(0)
_ = libssh2_keepalive_send(session, &next)
}
private func closeOnQueue() {
guard !isClosed else { return }
isClosed = true
timer?.cancel()
timer = nil
if pumpSocket >= 0 {
Darwin.close(pumpSocket)
pumpSocket = -1
}
if let channel {
_ = libssh2_channel_close(channel)
_ = libssh2_channel_free(channel)
self.channel = nil
}
if let session {
_ = libssh2_session_disconnect_ex(
session, SSH_DISCONNECT_BY_APPLICATION, "Termind closed the jump route", ""
)
_ = libssh2_session_free(session)
self.session = nil
}
if transportSocket >= 0 {
Darwin.close(transportSocket)
transportSocket = -1
}
}
}