659 lines
24 KiB
Swift
659 lines
24 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Darwin
|
|
import Foundation
|
|
@preconcurrency import Network
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
public nonisolated final class TelnetConnection: @unchecked Sendable {
|
|
public typealias OutputHandler = @Sendable (Data) -> Void
|
|
public typealias EventHandler = @Sendable (TelnetConnectionEvent) -> Void
|
|
public typealias CloseHandler = @Sendable (String?) -> Void
|
|
|
|
private let worker: TelnetConnectionWorker
|
|
|
|
public init(
|
|
output: @escaping OutputHandler,
|
|
event: @escaping EventHandler = { _ in },
|
|
closed: @escaping CloseHandler = { _ in }
|
|
) {
|
|
worker = TelnetConnectionWorker(output: output, event: event, closed: closed)
|
|
}
|
|
|
|
public func connect(
|
|
using configuration: TelnetConnectionConfiguration,
|
|
columns: UInt16 = 80, rows: UInt16 = 24
|
|
) async throws {
|
|
try await worker.connect(using: configuration, columns: columns, rows: rows)
|
|
}
|
|
|
|
public func write(_ data: Data) { worker.write(data) }
|
|
|
|
public func resize(columns: UInt16, rows: UInt16) {
|
|
worker.resize(columns: columns, rows: rows)
|
|
}
|
|
|
|
public func disconnect() { worker.disconnect() }
|
|
}
|
|
|
|
private nonisolated final class TelnetConnectionWorker: @unchecked Sendable {
|
|
private let queue = DispatchQueue(label: "com.akinokaede.termind.telnet")
|
|
private let output: TelnetConnection.OutputHandler
|
|
private let event: TelnetConnection.EventHandler
|
|
private let closed: TelnetConnection.CloseHandler
|
|
|
|
private var connection: NWConnection?
|
|
private var socket: Int32 = -1
|
|
private var socketReadSource: (any DispatchSourceRead)?
|
|
private var parser = TelnetProtocolParser()
|
|
private var inputConverter: RemoteCharsetStreamConverter?
|
|
private var outputConverter: RemoteCharsetStreamConverter?
|
|
private var configuration: TelnetConnectionConfiguration?
|
|
private var columns: UInt16 = 80
|
|
private var rows: UInt16 = 24
|
|
private var generation = UUID()
|
|
private var promptTail = ""
|
|
private var sentUsername = false
|
|
private var sentPassword = false
|
|
private var didBecomeReady = false
|
|
private var didClose = false
|
|
private var pendingConnect: CheckedContinuation<Void, any Error>?
|
|
|
|
init(
|
|
output: @escaping TelnetConnection.OutputHandler,
|
|
event: @escaping TelnetConnection.EventHandler,
|
|
closed: @escaping TelnetConnection.CloseHandler
|
|
) {
|
|
self.output = output
|
|
self.event = event
|
|
self.closed = closed
|
|
}
|
|
|
|
func connect(
|
|
using configuration: TelnetConnectionConfiguration,
|
|
columns: UInt16, rows: UInt16
|
|
) async throws {
|
|
guard (1...65_535).contains(configuration.port), !configuration.host.isEmpty,
|
|
let port = NWEndpoint.Port(rawValue: UInt16(configuration.port))
|
|
else {
|
|
configuration.clearSecret()
|
|
throw TelnetConnectionError.invalidConfiguration
|
|
}
|
|
|
|
do {
|
|
try Task.checkCancellation()
|
|
try configuration.accessCheck?()
|
|
await configuration.beforeConnect?()
|
|
try Task.checkCancellation()
|
|
} catch {
|
|
configuration.clearSecret()
|
|
throw error
|
|
}
|
|
|
|
if configuration.proxy != nil {
|
|
try await connectThroughProxy(
|
|
using: configuration, columns: columns, rows: rows)
|
|
return
|
|
}
|
|
|
|
let token = UUID()
|
|
try await withTaskCancellationHandler {
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
|
queue.async { [self] in
|
|
generation = token
|
|
cleanup(notify: false)
|
|
didClose = false
|
|
pendingConnect = continuation
|
|
self.configuration = configuration
|
|
self.columns = max(columns, 1)
|
|
self.rows = max(rows, 1)
|
|
do {
|
|
event(.configuringCharset)
|
|
inputConverter = try RemoteCharsetStreamConverter(
|
|
charset: configuration.charset, direction: .encoding)
|
|
outputConverter = try RemoteCharsetStreamConverter(
|
|
charset: configuration.charset, direction: .decoding)
|
|
} catch {
|
|
configuration.clearSecret()
|
|
resumeConnect(.failure(error))
|
|
return
|
|
}
|
|
|
|
event(.openingTransport)
|
|
let connection = NWConnection(
|
|
host: NWEndpoint.Host(configuration.host), port: port, using: .tcp)
|
|
self.connection = connection
|
|
connection.stateUpdateHandler = { [weak self] state in
|
|
guard let self else { return }
|
|
self.queue.async {
|
|
guard self.generation == token else { return }
|
|
switch state {
|
|
case .ready:
|
|
self.resumeConnect(.success(()))
|
|
self.didOpen(token: token)
|
|
case .failed(let error):
|
|
self.resumeConnect(.failure(error))
|
|
self.finish(message: error.localizedDescription)
|
|
case .cancelled:
|
|
self.resumeConnect(.failure(CancellationError()))
|
|
default:
|
|
break
|
|
}
|
|
}
|
|
}
|
|
connection.start(queue: queue)
|
|
}
|
|
}
|
|
} onCancel: {
|
|
self.queue.async { [weak self] in
|
|
guard let self, self.generation == token else { return }
|
|
self.resumeConnect(.failure(CancellationError()))
|
|
self.cleanup(notify: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func connectThroughProxy(
|
|
using configuration: TelnetConnectionConfiguration,
|
|
columns: UInt16, rows: UInt16
|
|
) async throws {
|
|
let token = UUID()
|
|
try await withTaskCancellationHandler {
|
|
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, any Error>) in
|
|
queue.async { [self] in
|
|
generation = token
|
|
cleanup(notify: false)
|
|
didClose = false
|
|
pendingConnect = continuation
|
|
self.configuration = configuration
|
|
self.columns = max(columns, 1)
|
|
self.rows = max(rows, 1)
|
|
do {
|
|
event(.configuringCharset)
|
|
inputConverter = try RemoteCharsetStreamConverter(
|
|
charset: configuration.charset, direction: .encoding)
|
|
outputConverter = try RemoteCharsetStreamConverter(
|
|
charset: configuration.charset, direction: .decoding)
|
|
} catch {
|
|
configuration.clearSecret()
|
|
resumeConnect(.failure(error))
|
|
return
|
|
}
|
|
event(.openingTransport)
|
|
let opening = Task.detached {
|
|
Result {
|
|
try TerminalSocketConnector.open(
|
|
host: configuration.host, port: configuration.port,
|
|
proxy: configuration.proxy, timeout: 20)
|
|
}
|
|
}
|
|
Task {
|
|
let result = await opening.value
|
|
self.queue.async {
|
|
guard self.generation == token else {
|
|
if case .success(let opened) = result { Darwin.close(opened) }
|
|
return
|
|
}
|
|
switch result {
|
|
case .success(let opened):
|
|
self.socket = opened
|
|
self.installSocketReader(token: token)
|
|
self.resumeConnect(.success(()))
|
|
self.didOpen(token: token)
|
|
case .failure(let error):
|
|
self.resumeConnect(.failure(error))
|
|
self.finish(message: error.localizedDescription)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} onCancel: {
|
|
self.queue.async { [weak self] in
|
|
guard let self, self.generation == token else { return }
|
|
self.resumeConnect(.failure(CancellationError()))
|
|
self.cleanup(notify: false)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func installSocketReader(token: UUID) {
|
|
guard socket >= 0 else { return }
|
|
let source = DispatchSource.makeReadSource(fileDescriptor: socket, queue: queue)
|
|
source.setEventHandler { [weak self] in
|
|
guard let self, self.generation == token, self.socket >= 0 else { return }
|
|
var bytes = [UInt8](repeating: 0, count: 64 * 1_024)
|
|
let count = Darwin.read(self.socket, &bytes, bytes.count)
|
|
if count > 0 {
|
|
self.receiveWire(Data(bytes.prefix(count)))
|
|
} else if count == 0 {
|
|
self.finish(message: nil)
|
|
} else if errno != EINTR && errno != EAGAIN && errno != EWOULDBLOCK {
|
|
self.finish(message: POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO).localizedDescription)
|
|
}
|
|
}
|
|
source.setCancelHandler {}
|
|
source.resume()
|
|
socketReadSource = source
|
|
}
|
|
|
|
func write(_ data: Data) {
|
|
guard !data.isEmpty else { return }
|
|
queue.async { [self] in
|
|
guard connection != nil else { return }
|
|
let encoded = (try? inputConverter?.convert(data).data) ?? data
|
|
sendApplicationData(encoded)
|
|
}
|
|
}
|
|
|
|
func resize(columns: UInt16, rows: UInt16) {
|
|
guard columns > 0, rows > 0 else { return }
|
|
queue.async { [self] in
|
|
self.columns = columns
|
|
self.rows = rows
|
|
guard parser.nawsEnabled else { return }
|
|
sendProtocol(parser.windowSize(columns: columns, rows: rows))
|
|
}
|
|
}
|
|
|
|
func disconnect() {
|
|
queue.async { [self] in cleanup(notify: false) }
|
|
}
|
|
|
|
private func didOpen(token: UUID) {
|
|
guard generation == token, connection != nil else { return }
|
|
event(.negotiatingOptions)
|
|
sendProtocol(parser.initialNegotiation)
|
|
receive(token: token)
|
|
if configuration?.username?.isEmpty == false {
|
|
event(.waitingForUsernamePrompt)
|
|
queue.asyncAfter(deadline: .now() + 60) { [weak self] in
|
|
guard let self, self.generation == token, !self.didBecomeReady else { return }
|
|
self.configuration?.clearSecret()
|
|
self.event(.continuingManually)
|
|
self.markReady()
|
|
}
|
|
} else {
|
|
markReady()
|
|
}
|
|
}
|
|
|
|
private func receive(token: UUID) {
|
|
connection?.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1_024) {
|
|
[weak self] data, _, complete, error in
|
|
guard let self else { return }
|
|
self.queue.async {
|
|
guard self.generation == token else { return }
|
|
if let data, !data.isEmpty { self.receiveWire(data) }
|
|
if let error {
|
|
self.finish(message: error.localizedDescription)
|
|
} else if complete {
|
|
self.finish(message: nil)
|
|
} else {
|
|
self.receive(token: token)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func receiveWire(_ data: Data) {
|
|
let result = parser.consume(data)
|
|
if !result.reply.isEmpty { sendProtocol(result.reply) }
|
|
if result.enabledNAWS {
|
|
sendProtocol(parser.windowSize(columns: columns, rows: rows))
|
|
}
|
|
guard !result.applicationData.isEmpty else { return }
|
|
do {
|
|
let converted =
|
|
try outputConverter?.convert(result.applicationData)
|
|
?? .init(data: result.applicationData, rejectedInput: false)
|
|
guard !converted.data.isEmpty else { return }
|
|
output(converted.data)
|
|
inspectPrompts(in: converted.data)
|
|
} catch {
|
|
output(result.applicationData)
|
|
inspectPrompts(in: result.applicationData)
|
|
}
|
|
}
|
|
|
|
private func inspectPrompts(in data: Data) {
|
|
promptTail.append(String(decoding: data, as: UTF8.self).lowercased())
|
|
if promptTail.count > 512 { promptTail = String(promptTail.suffix(512)) }
|
|
let trimmed = promptTail.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
|
|
if !sentUsername, let username = configuration?.username,
|
|
["login:", "username:", "user name:"].contains(where: trimmed.hasSuffix)
|
|
{
|
|
do {
|
|
try sendAutomatedText(username)
|
|
sentUsername = true
|
|
promptTail = ""
|
|
event(.usernameSubmitted)
|
|
if configuration?.password != nil {
|
|
event(.waitingForPasswordPrompt)
|
|
} else {
|
|
event(.continuingManually)
|
|
markReady()
|
|
}
|
|
} catch {
|
|
failAutomaticLogin(error)
|
|
}
|
|
return
|
|
}
|
|
|
|
guard trimmed.hasSuffix("password:") else { return }
|
|
guard !sentPassword else {
|
|
configuration?.clearSecret()
|
|
event(.continuingManually)
|
|
markReady()
|
|
return
|
|
}
|
|
guard let secret = configuration?.password else {
|
|
markReady()
|
|
return
|
|
}
|
|
do {
|
|
try sendAutomatedSecret(secret)
|
|
sentPassword = true
|
|
promptTail = ""
|
|
event(.passwordSubmitted)
|
|
markReady()
|
|
} catch {
|
|
failAutomaticLogin(error)
|
|
}
|
|
}
|
|
|
|
private func sendAutomatedText(_ value: String) throws {
|
|
guard let inputConverter else { throw TelnetConnectionError.closed }
|
|
var utf8 = Data(value.utf8)
|
|
defer { SecretMemory.clear(&utf8) }
|
|
let encoded = try inputConverter.convert(utf8).data
|
|
sendApplicationLine(encoded)
|
|
}
|
|
|
|
private func sendAutomatedSecret(_ secret: SSHSecret) throws {
|
|
guard let inputConverter else { throw TelnetConnectionError.closed }
|
|
var utf8 = try secret.withUnsafeCStringAndConsume { pointer, count in
|
|
Data(bytes: pointer, count: count)
|
|
}
|
|
defer { SecretMemory.clear(&utf8) }
|
|
var encoded = try inputConverter.convert(utf8).data
|
|
defer { SecretMemory.clear(&encoded) }
|
|
sendApplicationLine(encoded)
|
|
}
|
|
|
|
private func failAutomaticLogin(_: any Error) {
|
|
configuration?.clearSecret()
|
|
event(.continuingManually)
|
|
markReady()
|
|
}
|
|
|
|
private func markReady() {
|
|
guard !didBecomeReady else { return }
|
|
didBecomeReady = true
|
|
event(.connected)
|
|
}
|
|
|
|
private func sendApplicationLine(_ data: Data) {
|
|
var line = data
|
|
line.append(contentsOf: [0x0D, 0x0A])
|
|
sendApplicationData(line)
|
|
}
|
|
|
|
private func sendApplicationData(_ data: Data) {
|
|
sendWire(TelnetProtocolParser.escapeApplicationData(TelnetProtocolParser.encodeNVT(data)))
|
|
}
|
|
|
|
private func sendProtocol(_ data: Data) { sendWire(data) }
|
|
|
|
private func sendWire(_ data: Data) {
|
|
guard !data.isEmpty else { return }
|
|
if socket >= 0 {
|
|
do {
|
|
try data.withUnsafeBytes { raw in
|
|
guard let base = raw.baseAddress else { return }
|
|
var offset = 0
|
|
while offset < raw.count {
|
|
let count = Darwin.write(socket, base.advanced(by: offset), raw.count - offset)
|
|
guard count > 0 else { throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) }
|
|
offset += count
|
|
}
|
|
}
|
|
} catch {
|
|
finish(message: error.localizedDescription)
|
|
}
|
|
return
|
|
}
|
|
guard let connection else { return }
|
|
connection.send(
|
|
content: data,
|
|
completion: .contentProcessed { [weak self] error in
|
|
guard let self, let error else { return }
|
|
let message = error.localizedDescription
|
|
self.queue.async { [weak self] in self?.finish(message: message) }
|
|
})
|
|
}
|
|
|
|
private func finish(message: String?) {
|
|
guard !didClose else { return }
|
|
didClose = true
|
|
cleanup(notify: false)
|
|
closed(message)
|
|
}
|
|
|
|
private func resumeConnect(_ result: Result<Void, any Error>) {
|
|
guard let continuation = pendingConnect else { return }
|
|
pendingConnect = nil
|
|
continuation.resume(with: result)
|
|
}
|
|
|
|
private func cleanup(notify: Bool) {
|
|
if let outputConverter {
|
|
do {
|
|
let tail = try outputConverter.finish()
|
|
if !tail.data.isEmpty { output(tail.data) }
|
|
} catch {}
|
|
}
|
|
configuration?.clearSecret()
|
|
connection?.stateUpdateHandler = nil
|
|
connection?.cancel()
|
|
connection = nil
|
|
socketReadSource?.cancel()
|
|
socketReadSource = nil
|
|
if socket >= 0 { Darwin.close(socket) }
|
|
socket = -1
|
|
configuration = nil
|
|
inputConverter = nil
|
|
outputConverter = nil
|
|
parser = TelnetProtocolParser()
|
|
promptTail = ""
|
|
sentUsername = false
|
|
sentPassword = false
|
|
didBecomeReady = false
|
|
if notify, !didClose { closed(nil) }
|
|
}
|
|
}
|
|
|
|
nonisolated struct TelnetProtocolParser {
|
|
private enum State {
|
|
case data
|
|
case iac
|
|
case optionCommand(UInt8)
|
|
case subOption
|
|
case subData
|
|
case subIAC
|
|
}
|
|
|
|
struct Result {
|
|
var applicationData = Data()
|
|
var reply = Data()
|
|
var enabledNAWS = false
|
|
}
|
|
|
|
private static let iac: UInt8 = 255
|
|
private static let dont: UInt8 = 254
|
|
private static let `do`: UInt8 = 253
|
|
private static let wont: UInt8 = 252
|
|
private static let will: UInt8 = 251
|
|
private static let sb: UInt8 = 250
|
|
private static let se: UInt8 = 240
|
|
private static let binary: UInt8 = 0
|
|
private static let echo: UInt8 = 1
|
|
private static let suppressGoAhead: UInt8 = 3
|
|
private static let terminalType: UInt8 = 24
|
|
private static let windowSize: UInt8 = 31
|
|
|
|
private var state: State = .data
|
|
private var subOption: UInt8?
|
|
private var subData = Data()
|
|
private var pendingCR = false
|
|
private(set) var nawsEnabled = false
|
|
|
|
var initialNegotiation: Data {
|
|
Data([
|
|
Self.iac, Self.will, Self.terminalType,
|
|
Self.iac, Self.will, Self.windowSize,
|
|
Self.iac, Self.will, Self.binary,
|
|
Self.iac, Self.do, Self.binary,
|
|
Self.iac, Self.do, Self.suppressGoAhead,
|
|
Self.iac, Self.do, Self.echo,
|
|
])
|
|
}
|
|
|
|
mutating func consume(_ data: Data) -> Result {
|
|
var result = Result()
|
|
for byte in data {
|
|
switch state {
|
|
case .data:
|
|
if byte == Self.iac {
|
|
state = .iac
|
|
} else {
|
|
appendNVT(byte, to: &result.applicationData)
|
|
}
|
|
case .iac:
|
|
switch byte {
|
|
case Self.iac:
|
|
appendNVT(byte, to: &result.applicationData)
|
|
state = .data
|
|
case Self.do, Self.dont, Self.will, Self.wont:
|
|
state = .optionCommand(byte)
|
|
case Self.sb:
|
|
state = .subOption
|
|
default:
|
|
state = .data
|
|
}
|
|
case .optionCommand(let command):
|
|
let reply = negotiate(command: command, option: byte)
|
|
result.reply.append(reply.data)
|
|
result.enabledNAWS = reply.enabledNAWS
|
|
state = .data
|
|
case .subOption:
|
|
subOption = byte
|
|
subData.removeAll(keepingCapacity: true)
|
|
state = .subData
|
|
case .subData:
|
|
if byte == Self.iac { state = .subIAC } else { subData.append(byte) }
|
|
case .subIAC:
|
|
if byte == Self.se {
|
|
result.reply.append(subnegotiationReply())
|
|
subOption = nil
|
|
subData.removeAll(keepingCapacity: true)
|
|
state = .data
|
|
} else if byte == Self.iac {
|
|
subData.append(byte)
|
|
state = .subData
|
|
} else {
|
|
state = .subData
|
|
}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
mutating func windowSize(columns: UInt16, rows: UInt16) -> Data {
|
|
var payload = Data([
|
|
UInt8(columns >> 8), UInt8(columns & 0xFF),
|
|
UInt8(rows >> 8), UInt8(rows & 0xFF),
|
|
])
|
|
payload = Self.escapeApplicationData(payload)
|
|
return Data([Self.iac, Self.sb, Self.windowSize]) + payload + Data([Self.iac, Self.se])
|
|
}
|
|
|
|
static func escapeApplicationData(_ data: Data) -> Data {
|
|
var result = Data()
|
|
result.reserveCapacity(data.count + 8)
|
|
for byte in data {
|
|
if byte == iac { result.append(iac) }
|
|
result.append(byte)
|
|
}
|
|
return result
|
|
}
|
|
|
|
static func encodeNVT(_ data: Data) -> Data {
|
|
let bytes = Array(data)
|
|
var result = Data()
|
|
result.reserveCapacity(bytes.count + 8)
|
|
var index = 0
|
|
while index < bytes.count {
|
|
let byte = bytes[index]
|
|
if byte == 0x0D {
|
|
result.append(0x0D)
|
|
if index + 1 < bytes.count, bytes[index + 1] == 0x0A {
|
|
result.append(0x0A)
|
|
index += 2
|
|
continue
|
|
}
|
|
result.append(0x0A)
|
|
} else if byte == 0x0A {
|
|
result.append(contentsOf: [0x0D, 0x0A])
|
|
} else {
|
|
result.append(byte)
|
|
}
|
|
index += 1
|
|
}
|
|
return result
|
|
}
|
|
|
|
private mutating func appendNVT(_ byte: UInt8, to output: inout Data) {
|
|
if pendingCR {
|
|
pendingCR = false
|
|
if byte == 0 {
|
|
output.append(0x0D)
|
|
return
|
|
}
|
|
output.append(0x0D)
|
|
}
|
|
if byte == 0x0D { pendingCR = true } else { output.append(byte) }
|
|
}
|
|
|
|
private mutating func negotiate(command: UInt8, option: UInt8) -> (data: Data, enabledNAWS: Bool) {
|
|
var enabledNAWS = false
|
|
let response: UInt8
|
|
switch command {
|
|
case Self.will:
|
|
response = [Self.binary, Self.echo, Self.suppressGoAhead].contains(option) ? Self.do : Self.dont
|
|
case Self.do:
|
|
let supported = [Self.binary, Self.suppressGoAhead, Self.terminalType, Self.windowSize].contains(option)
|
|
response = supported ? Self.will : Self.wont
|
|
if option == Self.windowSize, supported, !nawsEnabled {
|
|
nawsEnabled = true
|
|
enabledNAWS = true
|
|
}
|
|
case Self.wont:
|
|
response = Self.dont
|
|
default:
|
|
response = Self.wont
|
|
}
|
|
return (Data([Self.iac, response, option]), enabledNAWS)
|
|
}
|
|
|
|
private func subnegotiationReply() -> Data {
|
|
guard subOption == Self.terminalType, subData.first == 1 else { return Data() }
|
|
return Data([Self.iac, Self.sb, Self.terminalType, 0])
|
|
+ Data("xterm-256color".utf8)
|
|
+ Data([Self.iac, Self.se])
|
|
}
|
|
}
|