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.
656 lines
24 KiB
Swift
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])
|
|
}
|
|
}
|