Files
Termind/Frameworks/TermindRemoteInfrastructure/Telnet/TelnetConnection.swift
T
Kaede Akino d2cce7d2ce feat: make core features free and add host-based Pro limits
Make AI, MCP, Metrics, and Wake-on-LAN free while retaining iCloud sync as a Pro feature. Limit free accounts to three saved hosts and preserve excess hosts with remote-operation locks.

Gate host creation, import, and runtime access; preserve stable host admission across persistence and sync. Add upgrade states on macOS and iOS, open the paywall from locked Wake actions, and use wrench icons for MCP.

Include regression coverage, opt-in UI review fixtures, localized copy, and the updated pricing website revision.
2026-10-17 06:13:38 +08:00

656 lines
24 KiB
Swift

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])
}
}