176 lines
6.0 KiB
Swift
176 lines
6.0 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Darwin
|
|
import Foundation
|
|
|
|
/// Forwarded target connections must never block the queue that services SSH.
|
|
/// Only DNS uses blocking workers (at most four); connect and its overall
|
|
/// five-second deadline use readiness events on the owning session queue.
|
|
nonisolated final class SSHForwardingConnector: @unchecked Sendable {
|
|
private struct Address: Sendable {
|
|
let family: Int32
|
|
let type: Int32
|
|
let proto: Int32
|
|
let bytes: Data
|
|
}
|
|
|
|
private static let resolver: OperationQueue = {
|
|
let queue = OperationQueue()
|
|
queue.name = "com.akinokaede.termind.forwarding-dns"
|
|
queue.maxConcurrentOperationCount = 4
|
|
return queue
|
|
}()
|
|
|
|
private let queue: DispatchQueue
|
|
private var completion: (@Sendable (Result<Int32, any Error>) -> Void)?
|
|
private var addresses: [Address] = []
|
|
private var socket: Int32 = -1
|
|
private var events: SSHSocketEvents?
|
|
private var deadline: (any DispatchSourceTimer)?
|
|
private var resolution: BlockOperation?
|
|
private var finished = false
|
|
private var cancelled = false
|
|
|
|
init(queue: DispatchQueue, completion: @escaping @Sendable (Result<Int32, any Error>) -> Void) {
|
|
self.queue = queue
|
|
self.completion = completion
|
|
}
|
|
|
|
func start(host: String, port: Int) {
|
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
|
timer.schedule(deadline: .now() + 5)
|
|
timer.setEventHandler { [weak self] in self?.finish(.failure(POSIXError(.ETIMEDOUT))) }
|
|
timer.resume()
|
|
deadline = timer
|
|
// Numeric targets do not need a resolver worker or a thread-pool hop.
|
|
let numeric = Self.resolve(host: host, port: port, flags: AI_NUMERICHOST)
|
|
if !numeric.isEmpty {
|
|
addresses = numeric
|
|
tryNext()
|
|
return
|
|
}
|
|
let operation = BlockOperation { [weak self] in
|
|
let resolved = Self.resolve(host: host, port: port, flags: 0)
|
|
guard let self else { return }
|
|
self.queue.async {
|
|
guard !self.finished else { return }
|
|
self.resolution = nil
|
|
self.addresses = resolved
|
|
self.tryNext()
|
|
}
|
|
}
|
|
resolution = operation
|
|
Self.resolver.addOperation(operation)
|
|
}
|
|
|
|
private static func resolve(host: String, port: Int, flags: Int32) -> [Address] {
|
|
var hints = addrinfo()
|
|
hints.ai_flags = flags
|
|
hints.ai_family = AF_UNSPEC
|
|
hints.ai_socktype = SOCK_STREAM
|
|
hints.ai_protocol = IPPROTO_TCP
|
|
var result: UnsafeMutablePointer<addrinfo>?
|
|
guard getaddrinfo(host, String(port), &hints, &result) == 0, let first = result else { return [] }
|
|
defer { freeaddrinfo(first) }
|
|
var addresses: [Address] = []
|
|
var cursor: UnsafeMutablePointer<addrinfo>? = first
|
|
while let info = cursor?.pointee {
|
|
addresses.append(
|
|
Address(
|
|
family: info.ai_family, type: info.ai_socktype, proto: info.ai_protocol,
|
|
bytes: Data(bytes: info.ai_addr, count: Int(info.ai_addrlen))))
|
|
cursor = info.ai_next
|
|
}
|
|
return addresses
|
|
}
|
|
|
|
func cancel() {
|
|
cancelled = true
|
|
completion = nil
|
|
finish(.failure(CancellationError()))
|
|
}
|
|
|
|
private func tryNext() {
|
|
guard !finished else { return }
|
|
guard !addresses.isEmpty else {
|
|
finish(.failure(POSIXError(.ECONNREFUSED)))
|
|
return
|
|
}
|
|
let address = addresses.removeFirst()
|
|
let candidate = Darwin.socket(address.family, address.type, address.proto)
|
|
guard candidate >= 0 else {
|
|
tryNext()
|
|
return
|
|
}
|
|
socket = candidate
|
|
var yes: Int32 = 1
|
|
_ = setsockopt(candidate, SOL_SOCKET, SO_NOSIGPIPE, &yes, socklen_t(MemoryLayout<Int32>.size))
|
|
let flags = fcntl(candidate, F_GETFL, 0)
|
|
guard flags >= 0, fcntl(candidate, F_SETFL, flags | O_NONBLOCK) == 0 else {
|
|
Darwin.close(candidate)
|
|
socket = -1
|
|
tryNext()
|
|
return
|
|
}
|
|
let result = address.bytes.withUnsafeBytes {
|
|
Darwin.connect(candidate, $0.baseAddress!.assumingMemoryBound(to: sockaddr.self), socklen_t($0.count))
|
|
}
|
|
if result == 0 {
|
|
finish(.success(candidate))
|
|
return
|
|
}
|
|
guard errno == EINPROGRESS else {
|
|
Darwin.close(candidate)
|
|
socket = -1
|
|
tryNext()
|
|
return
|
|
}
|
|
events = SSHSocketEvents(socket: candidate, queue: queue) { [weak self] in self?.connected() }
|
|
events?.update(read: false, write: true)
|
|
}
|
|
|
|
private func connected() {
|
|
guard !finished else { return }
|
|
var error: Int32 = 0
|
|
var size = socklen_t(MemoryLayout<Int32>.size)
|
|
if getsockopt(socket, SOL_SOCKET, SO_ERROR, &error, &size) == 0 && error == 0 {
|
|
finish(.success(socket))
|
|
} else {
|
|
let descriptor = socket
|
|
socket = -1
|
|
events?.cancel { [self] in
|
|
Darwin.close(descriptor)
|
|
tryNext()
|
|
}
|
|
events = nil
|
|
}
|
|
}
|
|
|
|
private func finish(_ result: Result<Int32, any Error>) {
|
|
guard !finished else { return }
|
|
finished = true
|
|
resolution?.cancel()
|
|
resolution = nil
|
|
deadline?.cancel()
|
|
deadline = nil
|
|
let descriptor = socket
|
|
socket = -1
|
|
let deliver: @Sendable () -> Void = { [self] in
|
|
if case .failure = result, descriptor >= 0 { Darwin.close(descriptor) }
|
|
if cancelled {
|
|
if case .success(let opened) = result { Darwin.close(opened) }
|
|
} else {
|
|
completion?(result)
|
|
}
|
|
completion = nil
|
|
}
|
|
if let events {
|
|
events.cancel(then: deliver)
|
|
self.events = nil
|
|
} else {
|
|
deliver()
|
|
}
|
|
}
|
|
}
|