215 lines
7.8 KiB
Swift
215 lines
7.8 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Darwin
|
|
import Foundation
|
|
import TermindApplication
|
|
|
|
/// Adapts the ET library's TCP-only endpoint API to our routed socket owner.
|
|
/// Socket ownership and bounded queues stay on one serial queue, including cancellation.
|
|
nonisolated final class TerminalTCPBridge: @unchecked Sendable {
|
|
typealias Open = ResumableTerminalClient.RouteFactory
|
|
let port: UInt16
|
|
private let queue = DispatchQueue(label: "com.akinokaede.termind.terminal-tcp-bridge")
|
|
private let open: Open
|
|
private let reporter: TerminalRouteReporter?
|
|
private var routeAttemptID: UUID?
|
|
private var listener: Int32
|
|
private var local: Int32 = -1
|
|
private var remote: SSHRoutedSocket?
|
|
private var timer: (any DispatchSourceTimer)?
|
|
private var opening: Task<Void, Never>?
|
|
private var generation = UUID()
|
|
private var toLocal = Data()
|
|
private var toRemote = Data()
|
|
private var localEOF = false
|
|
private var remoteEOF = false
|
|
private var closed = false
|
|
private var openingBlocked = false
|
|
|
|
init(
|
|
open: @escaping Open, initial: SSHRoutedSocket? = nil,
|
|
reporter: TerminalRouteReporter? = nil, initialAttemptID: UUID? = nil
|
|
) throws {
|
|
self.open = open
|
|
self.reporter = reporter
|
|
self.routeAttemptID = initialAttemptID
|
|
self.remote = initial
|
|
if let initial { Self.configure(initial.socket) }
|
|
listener = Darwin.socket(AF_INET, SOCK_STREAM, 0)
|
|
guard listener >= 0 else { throw POSIXError(.ENOBUFS) }
|
|
var address = sockaddr_in()
|
|
address.sin_len = UInt8(MemoryLayout<sockaddr_in>.size)
|
|
address.sin_family = sa_family_t(AF_INET)
|
|
address.sin_addr.s_addr = inet_addr("127.0.0.1")
|
|
let fd = listener
|
|
let bound = withUnsafePointer(to: &address) {
|
|
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) {
|
|
Darwin.bind(fd, $0, socklen_t(MemoryLayout<sockaddr_in>.size))
|
|
}
|
|
}
|
|
var length = socklen_t(MemoryLayout<sockaddr_in>.size)
|
|
let named = withUnsafeMutablePointer(to: &address) {
|
|
$0.withMemoryRebound(to: sockaddr.self, capacity: 1) { getsockname(fd, $0, &length) }
|
|
}
|
|
guard bound == 0, named == 0, listen(fd, 1) == 0 else {
|
|
Darwin.close(fd)
|
|
throw POSIXError(.EADDRNOTAVAIL)
|
|
}
|
|
port = UInt16(bigEndian: address.sin_port)
|
|
Self.configure(fd)
|
|
let timer = DispatchSource.makeTimerSource(queue: queue)
|
|
self.timer = timer
|
|
timer.schedule(deadline: .now(), repeating: .milliseconds(20))
|
|
timer.setEventHandler { [weak self] in self?.pump() }
|
|
timer.resume()
|
|
}
|
|
|
|
deinit {
|
|
if let routeAttemptID { reporter?.retire(attemptID: routeAttemptID) }
|
|
timer?.cancel()
|
|
opening?.cancel()
|
|
if listener >= 0 { Darwin.close(listener) }
|
|
if local >= 0 { Darwin.close(local) }
|
|
remote?.close()
|
|
}
|
|
|
|
func close() async {
|
|
await withCheckedContinuation { continuation in
|
|
queue.async { [self] in
|
|
closed = true
|
|
timer?.cancel()
|
|
timer = nil
|
|
retire()
|
|
if listener >= 0 {
|
|
Darwin.close(listener)
|
|
listener = -1
|
|
}
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
func retry() async {
|
|
await withCheckedContinuation { continuation in
|
|
queue.async {
|
|
self.openingBlocked = false
|
|
continuation.resume()
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func configure(_ fd: Int32) {
|
|
_ = fcntl(fd, F_SETFL, fcntl(fd, F_GETFL) | O_NONBLOCK)
|
|
var one: Int32 = 1
|
|
_ = setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &one, socklen_t(MemoryLayout<Int32>.size))
|
|
}
|
|
|
|
private func retire() {
|
|
generation = UUID()
|
|
if let routeAttemptID { reporter?.retire(attemptID: routeAttemptID) }
|
|
routeAttemptID = nil
|
|
opening?.cancel()
|
|
opening = nil
|
|
if local >= 0 {
|
|
Darwin.close(local)
|
|
local = -1
|
|
}
|
|
remote?.close()
|
|
remote = nil
|
|
toLocal.removeAll()
|
|
toRemote.removeAll()
|
|
localEOF = false
|
|
remoteEOF = false
|
|
}
|
|
|
|
private func pump() {
|
|
guard !closed else { return }
|
|
let accepted = Darwin.accept(listener, nil, nil)
|
|
if accepted >= 0 {
|
|
if openingBlocked {
|
|
Darwin.close(accepted)
|
|
return
|
|
}
|
|
if local >= 0, remote != nil { retire() }
|
|
if local >= 0 { Darwin.close(local) }
|
|
local = accepted
|
|
Self.configure(accepted)
|
|
toLocal.removeAll()
|
|
toRemote.removeAll()
|
|
if remote == nil, opening == nil { startOpening() }
|
|
}
|
|
guard local >= 0, let remote else { return }
|
|
guard Self.move(from: local, to: remote.socket, buffer: &toRemote, eof: &localEOF),
|
|
Self.move(from: remote.socket, to: local, buffer: &toLocal, eof: &remoteEOF)
|
|
else {
|
|
retire()
|
|
return
|
|
}
|
|
if localEOF && remoteEOF { retire() }
|
|
}
|
|
|
|
private func startOpening() {
|
|
let token = generation
|
|
let attemptID = reporter?.begin()
|
|
routeAttemptID = attemptID
|
|
let progress: SSHRouteProgressHandler = { [weak self] event in
|
|
guard let self else { return }
|
|
queue.async {
|
|
guard !self.closed, self.generation == token, let attemptID else { return }
|
|
self.reporter?.receive(event, attemptID: attemptID)
|
|
}
|
|
}
|
|
opening = Task { [weak self, open] in
|
|
do {
|
|
let socket = try await open(progress)
|
|
guard let self else {
|
|
socket.close()
|
|
return
|
|
}
|
|
queue.async {
|
|
guard !self.closed, self.generation == token else {
|
|
socket.close()
|
|
return
|
|
}
|
|
Self.configure(socket.socket)
|
|
self.remote = socket
|
|
self.opening = nil
|
|
if let attemptID { self.reporter?.ready(attemptID: attemptID) }
|
|
}
|
|
} catch {
|
|
guard let self else { return }
|
|
let sshError = error as? TermindSSHError
|
|
let blocksRetry =
|
|
error is CancellationError || error is SSHInteractionRequired
|
|
|| sshError?.isLocalAuthenticationRequirement == true
|
|
|| sshError?.shouldRequestAuthentication == true
|
|
|| sshError?.shouldRequestHostKey == true || sshError?.isHostKeyChanged == true
|
|
queue.async {
|
|
guard self.generation == token else { return }
|
|
self.openingBlocked = blocksRetry
|
|
self.retire()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private static func move(from input: Int32, to output: Int32, buffer: inout Data, eof: inout Bool) -> Bool {
|
|
guard !eof else { return true }
|
|
if buffer.isEmpty {
|
|
var bytes = [UInt8](repeating: 0, count: 32_768)
|
|
let count = Darwin.recv(input, &bytes, bytes.count, 0)
|
|
if count == 0 {
|
|
eof = true
|
|
_ = Darwin.shutdown(output, SHUT_WR)
|
|
return true
|
|
}
|
|
if count < 0 { return errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR }
|
|
buffer.append(contentsOf: bytes.prefix(count))
|
|
}
|
|
let sent = buffer.withUnsafeBytes { Darwin.send(output, $0.baseAddress, $0.count, 0) }
|
|
if sent > 0 { buffer.removeFirst(sent) }
|
|
return sent >= 0 || errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR
|
|
}
|
|
}
|