import CSSH2 import Darwin import Foundation import Logging import TermindApplication import TermindDomain import struct TermindDomain.Host /// Installs a small cwd-reporting hook before remote output reaches Ghostty. /// The probe, its echo, and the installation command stay inside this state /// machine; only the login banner and the final integrated prompt are emitted. public nonisolated struct RemoteShellIntegrationBootstrap: Sendable { enum Diagnostic: Sendable, Equatable { enum TimeoutStage: String, Sendable { case lateProbe = "late_probe" case installation } enum SkipReason: String, Sendable { case unsupportedShell = "unsupported_shell" case userInput = "user_input" } case probeDelayed case installed(late: Bool, homeResolved: Bool, systemInfoAvailable: Bool) case skipped(SkipReason) case timedOut(stage: TimeoutStage, late: Bool) } public nonisolated struct Update: Sendable { public var command: Data? public var visibleOutput: Data? public var isComplete = false public var integrationInstalled = false public var shell: String? public var systemInfo: RemoteSystemInfo? public var homeDirectory: String? public var homeResolutionUnavailable = false var diagnostic: Diagnostic? var rejectedCharsetInput = false } private enum Phase: Sendable { case probing case scanningForLateProbe case suppressingLateProbe case installing case complete } private static let responseTimeout: UInt64 = 3_000_000_000 private static let quietTimeout: UInt64 = 500_000_000 public let token: String public let startCommand: Data private let charset: RemoteCharset private var deadline: UInt64 private var phase: Phase = .probing private var buffer = Data() private var preservedOutput = Data() private var lastActivity: UInt64? private var userDidWrite = false private var installationStartedLate = false private var detectedShell: String? private var detectedSystemInfo: RemoteSystemInfo? private var rejectedCharsetInput = false private var probeEchoMarker: Data { Data("__TRMD_\(token)_PROBE__".utf8) } public init( token: String = UUID().uuidString.replacingOccurrences(of: "-", with: ""), charset: RemoteCharset = .utf8, now: UInt64 = DispatchTime.now().uptimeNanoseconds ) { self.token = token self.charset = charset // The POSIX helper reads os-release as inert assignment text instead of // sourcing it. Its output precedes the shell marker, so a missing `sh` // still leaves the original shell-only bootstrap path usable. // Put a token near the start of the line so the bootstrap echo can still // be recognized when zsh or fish redraws the remaining command with // syntax-highlighting escape sequences. `true` accepts the inert token // in all supported shells and produces no output. var probeCommand = " command true '__TRMD_\(token)_PROBE__'; " probeCommand += "command sh -c 'f=; did=; dlike=; dver=; " probeCommand += "[ -r /etc/os-release ] && f=/etc/os-release; " probeCommand += "[ -z \"$f\" ] && [ -r /usr/lib/os-release ] && f=/usr/lib/os-release; " probeCommand += "if [ -n \"$f\" ]; then while IFS= read -r line; do case \"$line\" in " probeCommand += "ID=*) did=${line#ID=} ;; ID_LIKE=*) dlike=${line#ID_LIKE=} ;; " probeCommand += "VERSION_ID=*) dver=${line#VERSION_ID=} ;; esac; " probeCommand += "done < \"$f\"; fi; kn=$(uname -s 2>/dev/null); " probeCommand += "kr=$(uname -r 2>/dev/null); ka=$(uname -m 2>/dev/null); " probeCommand += "printf \"\\n__TRMD_%s_SYSTEM__%s|%s|%s|%s|%s|%s__\\n\" " probeCommand += "\"$1\" \"$did\" \"$dlike\" \"$dver\" \"$kn\" \"$kr\" \"$ka\"' sh '\(token)'; " probeCommand += "printf '\\n__TRMD_%s_SHELL__%s__\\n' '\(token)' \"$SHELL\"\n" startCommand = Data(probeCommand.utf8) deadline = now + Self.responseTimeout } public mutating func noteUserInput() { userDidWrite = true } public mutating func receive( _ data: Data, now: UInt64 = DispatchTime.now().uptimeNanoseconds ) -> Update { guard phase != .complete else { return Update(visibleOutput: data, isComplete: true) } buffer.append(data) switch phase { case .probing: return receiveProbe(now: now, isLate: false) case .scanningForLateProbe: return scanForLateProbe(now: now) case .suppressingLateProbe: lastActivity = now return receiveProbe(now: now, isLate: true) case .installing: return receiveInstallation() case .complete: return Update(visibleOutput: data, isComplete: true) } } /// Advances time-dependent bootstrap work. The first probe timeout restores /// the terminal but deliberately keeps a streaming signature scanner alive: /// a slow remote may echo the probe only after the UI is interactive again. public mutating func poll( now: UInt64 = DispatchTime.now().uptimeNanoseconds ) -> Update { switch phase { case .probing where now >= deadline: let isEchoingProbe = buffer.range(of: probeEchoMarker) != nil let visible = cleanBufferedOutput() phase = isEchoingProbe ? .suppressingLateProbe : .scanningForLateProbe if isEchoingProbe { deadline = now + Self.responseTimeout } lastActivity = nil return Update( visibleOutput: visible, homeResolutionUnavailable: true, diagnostic: .probeDelayed ) case .scanningForLateProbe: guard let lastActivity, now >= lastActivity + Self.quietTimeout, !buffer.isEmpty else { return Update() } let visible = buffer buffer.removeAll(keepingCapacity: false) self.lastActivity = nil return Update(visibleOutput: visible) case .suppressingLateProbe: guard now >= deadline || lastActivity.map({ now >= $0 + Self.quietTimeout }) == true else { return Update() } let visible = outputAfterLastLineBreak(in: buffer) finish() return Update( visibleOutput: visible, isComplete: true, diagnostic: .timedOut(stage: .lateProbe, late: true) ) case .installing where now >= deadline: let visible = installationStartedLate ? outputAfterLastLineBreak(in: buffer) : cleanBufferedOutput() finish() return Update( visibleOutput: visible, isComplete: true, diagnostic: .timedOut(stage: .installation, late: installationStartedLate) ) case .probing, .installing, .complete: return Update() } } private mutating func receiveProbe(now: UInt64, isLate: Bool) -> Update { let prefix = Data("__TRMD_\(token)_SHELL__".utf8) guard let prefixRange = buffer.range(of: prefix) else { return Update() } let shellStart = prefixRange.upperBound let terminator = Data("__".utf8) guard let terminatorRange = buffer.range( of: terminator, in: shellStart.. Update { let marker = Data("__TRMD_\(token)_DONE__".utf8) guard let markerRange = buffer.range(of: marker), let lineEnd = buffer[markerRange.upperBound...].firstIndex(of: 0x0A) else { return Update() } var visible = preservedOutput visible.append(Data(buffer[buffer.index(after: lineEnd)...])) let homeDirectory = parseHomeDirectory(in: buffer) finish() return Update( visibleOutput: visible, isComplete: true, integrationInstalled: true, shell: detectedShell, systemInfo: detectedSystemInfo, homeDirectory: homeDirectory, diagnostic: .installed( late: installationStartedLate, homeResolved: homeDirectory != nil, systemInfoAvailable: detectedSystemInfo != nil ), rejectedCharsetInput: rejectedCharsetInput ) } private mutating func parseHomeDirectory(in data: Data) -> String? { let prefix = Data("__TRMD_\(token)_HOME__".utf8) guard let prefixRange = data.range(of: prefix), let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) else { return nil } var payload = Data(data[prefixRange.upperBound.. RemoteSystemInfo? { let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8) guard let prefixRange = data.range(of: prefix), let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) else { return nil } var payload = Data(data[prefixRange.upperBound..= 2, payload.suffix(2).elementsEqual(Data("__".utf8)) else { return nil } payload.removeLast(2) guard let fields = try? RemoteCharsetStreamConverter.decode(payload, charset: charset) else { rejectedCharsetInput = true return nil } return RemoteSystemInfo(probeFields: fields) } private func removingSystemInfoMarker(from data: Data) -> Data { let prefix = Data("__TRMD_\(token)_SYSTEM__".utf8) guard let prefixRange = data.range(of: prefix), let lineEnd = data[prefixRange.upperBound...].firstIndex(of: 0x0A) else { return data } var result = Data(data[.. Update { let echo = Data(startCommand.dropFirst().dropLast()) let marker = Data("__TRMD_\(token)_SHELL__".utf8) let targets = [echo, probeEchoMarker, marker] if let match = earliestMatch(of: targets, in: buffer) { var suppressionStart: Data.Index if match.targetIndex == 1 { suppressionStart = startOfLine(containing: match.range.lowerBound, in: buffer) } else { suppressionStart = match.range.lowerBound } if match.targetIndex == 0, suppressionStart > buffer.startIndex { let preceding = buffer.index(before: suppressionStart) if buffer[preceding] == 0x20 { suppressionStart = preceding } } let visible = Data(buffer[.. 0, buffer.count > retainedCount { let retainedStart = buffer.index(buffer.endIndex, offsetBy: -retainedCount) let preceding = buffer.index(before: retainedStart) if buffer[preceding] == 0x20 { retainedCount += 1 } } let visibleEnd = buffer.index(buffer.endIndex, offsetBy: -retainedCount) let visible = Data(buffer[.. (range: Range, targetIndex: Int)? { targets.enumerated().compactMap { index, target in data.range(of: target).map { (range: $0, targetIndex: index) } }.min { lhs, rhs in lhs.range.lowerBound < rhs.range.lowerBound } } private func longestSuffixMatchingPrefix(of targets: [Data], in data: Data) -> Int { let maximum = targets.map(\.count).max() ?? 0 guard !data.isEmpty, maximum > 1 else { return 0 } for count in stride(from: min(data.count, maximum - 1), through: 1, by: -1) { if targets.contains(where: { target in target.count >= count && data.suffix(count).elementsEqual(target.prefix(count)) }) { return count } } return 0 } private func mergingVisible(_ visible: Data, into update: Update) -> Update { guard !visible.isEmpty else { return update } var combined = visible if let following = update.visibleOutput { combined.append(following) } return Update( command: update.command, visibleOutput: combined, isComplete: update.isComplete, integrationInstalled: update.integrationInstalled, shell: update.shell, systemInfo: update.systemInfo, homeDirectory: update.homeDirectory, homeResolutionUnavailable: update.homeResolutionUnavailable, diagnostic: update.diagnostic ) } private mutating func cleanBufferedOutput() -> Data { var visible = preservedOutput if visible.isEmpty { visible = outputBeforeCommand(in: buffer) } let suffix: Data if let markerRange = buffer.range(of: probeEchoMarker), buffer[markerRange.upperBound...].firstIndex(of: 0x0A) == nil { // The line editor has drawn the probe but the shell has not yet // accepted the line. Keeping the unfinished line would reveal it. suffix = Data() } else { suffix = outputAfterLastLineBreak(in: buffer) } if !suffix.isEmpty { visible.append(suffix) } buffer.removeAll(keepingCapacity: false) preservedOutput.removeAll(keepingCapacity: false) return visible } private func outputAfterLastLineBreak(in data: Data) -> Data { guard let lineEnd = data.lastIndex(of: 0x0A), data.index(after: lineEnd) < data.endIndex else { return Data() } return Data(data[data.index(after: lineEnd)...]) } private mutating func finish() { phase = .complete buffer.removeAll(keepingCapacity: false) preservedOutput.removeAll(keepingCapacity: false) lastActivity = nil } private func outputBeforeCommand(in data: Data) -> Data { let echo = Data(startCommand.dropLast()) let boundary: Data.Index if let echoRange = data.range(of: echo) { boundary = echoRange.lowerBound } else if let markerRange = data.range(of: probeEchoMarker) { boundary = startOfLine(containing: markerRange.lowerBound, in: data) } else { boundary = data.endIndex } let prefix = data[.. Data.Index { data[.. Data? { let marker = "builtin printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' \"$(__termind_encode \"$HOME\")\"; builtin printf '\\n__TRMD_%s_DONE__\\n' '\(token)'" let command: String switch shell { case "bash": // The fallback DEBUG trap also observes commands run by PROMPT_COMMAND. // Keep capture closed around the existing prompt hooks, then report // the saved user-command status only after those hooks finish. command = [ "if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then", "__termind_integration_token=\"\(token)\";", "__termind_encode() { builtin local s=\"$1\" out= c hex i LC_ALL=C; for ((i=0;i<${#s};i++)); do c=${s:i:1}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; };", "__termind_report_cwd() { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; };", "__termind_report_prompt() { builtin local status=$? duration=0; if (( $# > 0 )); then status=$1; fi; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$status\" \"$duration\"; builtin unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; __termind_command_ready=1; return \"$status\"; };", "__termind_prompt_begin() { __termind_prompt_status=$?; __termind_command_ready=0; return \"$__termind_prompt_status\"; };", "__termind_prompt_end() { builtin local status=\"${__termind_prompt_status:-$?}\"; builtin unset __termind_prompt_status; __termind_report_prompt \"$status\"; };", "__termind_report_command() { builtin local status=$? cmd=\"${1:-}\"; case \"$cmd\" in __termind_*) return \"$status\";; esac; [[ \"${__termind_command_ready:-0}\" == 1 ]] || return \"$status\"; __termind_command_ready=0; [[ -n \"${__termind_busy:-}\" ]] && return \"$status\"; __termind_busy=1; if [[ -z \"$cmd\" ]]; then cmd=\"$(builtin fc -ln -0)\"; cmd=\"${cmd#\"${cmd%%[![:space:]]*}\"}\"; fi; if [[ -n \"$cmd\" ]]; then builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$cmd\")\"; __termind_command_active=1; __termind_command_started_seconds=$SECONDS; fi; builtin unset __termind_busy; return \"$status\"; };", "__termind_get_trap() { builtin local -a terms; builtin eval \"terms=( $(builtin trap -p \"${1:-DEBUG}\") )\"; builtin printf %s \"${terms[2]:-}\"; };", "__termind_compose_draft() { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$READLINE_LINE\")\"; };", "__termind_compose_clear() { READLINE_LINE=; READLINE_POINT=0; };", "for __termind_map in emacs-standard vi-insertion vi-command; do builtin bind -m \"$__termind_map\" -x '\"\\e[777;1~\":__termind_compose_draft'; builtin bind -m \"$__termind_map\" -x '\"\\e[777;2~\":__termind_compose_clear'; done;", "builtin unset __termind_map;", "if [[ -n \"${bash_preexec_imported:-}\" ]]; then", "[[ \" ${preexec_functions[*]} \" == *\" __termind_report_command \"* ]] || preexec_functions+=(__termind_report_command);", "[[ \" ${precmd_functions[*]} \" == *\" __termind_report_prompt \"* ]] || precmd_functions=(__termind_report_prompt \"${precmd_functions[@]}\");", "else", "__termind_debug_trap=\"$(__termind_get_trap DEBUG)\";", "if [[ \"$__termind_debug_trap\" != *\"__termind_report_command\"* ]]; then if [[ -n \"$__termind_debug_trap\" ]]; then builtin trap \"$__termind_debug_trap\"$'\\n''__termind_report_command \"$BASH_COMMAND\"' DEBUG; else builtin trap '__termind_report_command \"$BASH_COMMAND\"' DEBUG; fi; fi;", "if [[ $(builtin declare -p PROMPT_COMMAND 2>/dev/null) == \"declare -a \"* ]]; then", "[[ \" ${PROMPT_COMMAND[*]} \" == *\" __termind_prompt_begin \"* ]] || PROMPT_COMMAND=(__termind_prompt_begin \"${PROMPT_COMMAND[@]}\" __termind_prompt_end);", "elif [[ \"${PROMPT_COMMAND:-}\" != *\"__termind_prompt_begin\"* ]]; then", "PROMPT_COMMAND=\"__termind_prompt_begin\"$'\\n'\"${PROMPT_COMMAND:-}\"$'\\n'\"__termind_prompt_end\";", "fi;", "fi;", "fi;", "\(marker);", "__termind_report_prompt", ].joined(separator: " ") + "\n" case "zsh": // Unlike bash, zsh reserves `status` as a read-only special parameter. // Assigning it aborts this function and the remaining prompt hooks. command = " if [[ \"${__termind_integration_token:-}\" != \"\(token)\" ]]; then typeset -g __termind_integration_token=\"\(token)\"; function __termind_encode { builtin local s=\"$1\" out= c hex i; builtin local LC_ALL=C; for ((i=1;i<=${#s};i++)); do c=${s[i]}; case \"$c\" in [a-zA-Z0-9.~_/-]) out+=\"$c\";; *) builtin printf -v hex '%%%02X' \"'$c\"; out+=\"$hex\";; esac; done; builtin printf %s \"$out\"; }; function __termind_report_cwd { builtin printf '\\e]7;file://localhost%s\\a' \"$(__termind_encode \"$PWD\")\"; }; function __termind_report_prompt { builtin local command_status=$? duration=0; if [[ -n \"${__termind_command_active:-}\" ]]; then if (( SECONDS >= ${__termind_command_started_seconds:-0} )); then duration=$(( (SECONDS - __termind_command_started_seconds) * 1000 )); fi; builtin printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' \"$command_status\" \"$duration\"; unset __termind_command_active __termind_command_started_seconds; fi; __termind_report_cwd; builtin printf '\\e]777;TermindPrompt=\(token)\\a'; return \"$command_status\"; }; function __termind_report_command { builtin printf '\\e]777;TermindCommand=\(token);%s\\a' \"$(__termind_encode \"$1\")\"; typeset -g __termind_command_active=1; typeset -g __termind_command_started_seconds=$SECONDS; }; function __termind_compose_draft { builtin printf '\\e]777;TermindDraft=\(token);%s\\a' \"$(__termind_encode \"$BUFFER\")\"; zle redisplay; }; function __termind_compose_clear { BUFFER=; CURSOR=0; zle redisplay; }; zle -N __termind_compose_draft; zle -N __termind_compose_clear; for __termind_map in emacs viins vicmd; do bindkey -M $__termind_map '\\e[777;1~' __termind_compose_draft 2>/dev/null; bindkey -M $__termind_map '\\e[777;2~' __termind_compose_clear 2>/dev/null; done; unset __termind_map; autoload -Uz add-zsh-hook; (( ${preexec_functions[(Ie)__termind_report_command]} )) || add-zsh-hook preexec __termind_report_command; precmd_functions=(__termind_report_prompt ${precmd_functions:#__termind_report_prompt}); (( ${chpwd_functions[(Ie)__termind_report_cwd]} )) || add-zsh-hook chpwd __termind_report_cwd; fi; \(marker); __termind_report_prompt\n" case "fish": command = " if not set -q __termind_integration_token; or test \"$__termind_integration_token\" != \"\(token)\"; set -g __termind_integration_token \"\(token)\"; function __termind_report_cwd --on-variable PWD; printf '\\e]7;file://localhost%s\\a' (string escape --style=url $PWD); end; function __termind_report_prompt --on-event fish_prompt; __termind_report_cwd; printf '\\e]777;TermindPrompt=\(token)\\a'; end; function __termind_report_command --on-event fish_preexec; printf '\\e]777;TermindCommand=\(token);%s\\a' (string escape --style=url -- $argv[1]); set -g __termind_command_active 1; end; function __termind_report_finished --on-event fish_postexec; set -l command_status $status; set -l command_duration $CMD_DURATION; if set -q __termind_command_active; printf '\\e]777;TermindCommandFinished=\(token);%s;%s\\a' $command_status $command_duration; set -e __termind_command_active; end; end; function __termind_compose_draft; set -l encoded (string escape --style=url -- (commandline)); printf '\\e]777;TermindDraft=\(token);%s\\a' \"$encoded\"; end; function __termind_compose_clear; commandline -r ''; commandline -C 0; end; for __termind_map in default insert visual; bind -M $__termind_map \\e'[777;1~' __termind_compose_draft 2>/dev/null; bind -M $__termind_map \\e'[777;2~' __termind_compose_clear 2>/dev/null; end; set -e __termind_map; end; printf '\\n__TRMD_%s_HOME__%s\\n' '\(token)' (string escape --style=url $HOME); printf '\\n__TRMD_%s_DONE__\\n' '\(token)'; __termind_report_prompt\n" default: return nil } return Data(command.utf8) } } /// Removes authenticated command-report OSC sequences before output reaches /// Ghostty and emits their decoded payloads separately. The session token makes /// ordinary OSC output in a remote program ineligible to forge local history. public nonisolated struct RemoteCommandEventFilter: Sendable { public static let maximumPayloadBytes = 64 * 1024 public nonisolated enum Event: Sendable, Equatable { case commandStarted(String) case commandFinished(Int32, duration: TimeInterval?) case promptReady case draft(String) } public nonisolated struct Update: Sendable { public var visibleOutput = Data() public var events: [Event] = [] public var rejectedCharsetInput = false } private let commandPrefix: Data private let commandFinishedPrefix: Data private let promptPrefix: Data private let draftPrefix: Data private let charset: RemoteCharset private var buffer = Data() public init(token: String, charset: RemoteCharset = .utf8) { commandPrefix = Data("\u{1B}]777;TermindCommand=\(token);".utf8) commandFinishedPrefix = Data("\u{1B}]777;TermindCommandFinished=\(token);".utf8) promptPrefix = Data("\u{1B}]777;TermindPrompt=\(token)".utf8) draftPrefix = Data("\u{1B}]777;TermindDraft=\(token);".utf8) self.charset = charset } public mutating func receive(_ data: Data) -> Update { buffer.append(data) var update = Update() while let match = earliestMarker() { let marker = match.range if marker.lowerBound > buffer.startIndex { update.visibleOutput.append(buffer[.. Self.maximumPayloadBytes { // A forged or truncated authenticated marker must not retain // the terminal stream forever. Drop the malformed frame and // resume with the next received bytes. buffer.removeAll(keepingCapacity: false) } else { buffer = Data(buffer[marker.lowerBound...]) } return update } let payload = Data(buffer[marker.upperBound.. Int { let prefixes = [commandPrefix, commandFinishedPrefix, promptPrefix, draftPrefix] guard !buffer.isEmpty else { return 0 } let maximum = prefixes.map(\.count).max() ?? 0 guard maximum > 1 else { return 0 } for count in stride(from: min(buffer.count, maximum - 1), through: 1, by: -1) { if prefixes.contains(where: { $0.count >= count && buffer.suffix(count).elementsEqual($0.prefix(count)) }) { return count } } return 0 } private enum MarkerKind { case command, commandFinished, prompt, draft } private func earliestMarker() -> (range: Range, kind: MarkerKind)? { let candidates: [(Range, MarkerKind)] = [ buffer.range(of: commandPrefix).map { ($0, .command) }, buffer.range(of: commandFinishedPrefix).map { ($0, .commandFinished) }, buffer.range(of: promptPrefix).map { ($0, .prompt) }, buffer.range(of: draftPrefix).map { ($0, .draft) }, ].compactMap { $0 } return candidates.min { $0.0.lowerBound < $1.0.lowerBound } } public static func decodePercentEncoded(_ data: Data, charset: RemoteCharset = .utf8) -> String? { let bytes = Array(data) var decoded: [UInt8] = [] decoded.reserveCapacity(bytes.count) var index = 0 while index < bytes.count { if bytes[index] == 0x25, index + 2 < bytes.count, let high = hex(bytes[index + 1]), let low = hex(bytes[index + 2]) { decoded.append(high << 4 | low) index += 3 } else { decoded.append(bytes[index]) index += 1 } } return try? RemoteCharsetStreamConverter.decode(Data(decoded), charset: charset) } private static func hex(_ byte: UInt8) -> UInt8? { switch byte { case 0x30...0x39: byte - 0x30 case 0x41...0x46: byte - 0x41 + 10 case 0x61...0x66: byte - 0x61 + 10 default: nil } } private static func duration(milliseconds text: Substring) -> TimeInterval? { guard let milliseconds = UInt64(text), milliseconds <= 31_536_000_000 else { return nil } return Double(milliseconds) / 1_000 } } public actor SSHPTYChannel { public typealias OutputHandler = @Sendable (Data) -> Void public typealias ExitHandler = @Sendable (Result) -> Void public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void public typealias EnvironmentRejectionHandler = @Sendable ([String]) -> Void public typealias CharsetErrorHandler = @Sendable (String) -> Void private nonisolated let worker: SSHPTYChannelWorker private let keyboardInteractive: SSHKeyboardInteractiveHandler? private var environmentVariables: [HostEnvironmentVariable] = [] private var environmentRejection: EnvironmentRejectionHandler = { _ in } public init( output: @escaping OutputHandler, exit: @escaping ExitHandler, shellIntegration: @escaping ShellIntegrationHandler = { _ in }, keyboardInteractive: SSHKeyboardInteractiveHandler? = nil, logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-pty-channel"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) { self.keyboardInteractive = keyboardInteractive worker = SSHPTYChannelWorker( output: output, exit: exit, shellIntegrationHandler: shellIntegration, logger: logger, maxSessionsPolicy: maxSessionsPolicy) } /// Appends a named public key to a remote account's `authorized_keys`, unless /// the exact key line is already present. The generated comment and key travel /// over the PTY Channel's stdin, never as shell syntax. public static func installAuthorizedKey( _ publicKey: String, named keyName: String, using configuration: SSHConnectionConfiguration, maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) async throws { try await installAuthorizedKey( publicKey, named: keyName, using: SSHConnectionRequest(configuration: configuration), maxSessionsPolicy: maxSessionsPolicy) } public static func installAuthorizedKey( _ publicKey: String, named keyName: String, using request: SSHConnectionRequest, logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) async throws { try await installAuthorizedKeys( [SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)], using: request, logger: logger, maxSessionsPolicy: maxSessionsPolicy ) } public static func installAuthorizedKeys( _ keys: [SSHAuthorizedKeyInstall], using request: SSHConnectionRequest, logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) async throws { try await SSHConnectionOpener.installAuthorizedKeys( keys, using: request, logger: logger, maxSessionsPolicy: maxSessionsPolicy) } public func connect( hostID: Host.ID, host: String, port: Int, username: String, credentials: [SSHCredentials], expectedHostKeys: Set, proxy: SSHTransportProxy? = nil, jumps: [SSHRouteHopConfiguration] = [], shellIntegrationEnabled: Bool = true, columns: UInt16, rows: UInt16, progress: SSHRouteProgressHandler? = nil ) async throws { let configuration = SSHConnectionConfiguration( hostID: hostID, host: host, port: port, username: username, credentials: credentials, expectedHostKeys: expectedHostKeys, proxy: proxy, jumps: jumps ) try await connect( using: SSHConnectionRequest(configuration: configuration), shellIntegrationEnabled: shellIntegrationEnabled, columns: columns, rows: rows, progress: progress ) } public func connect( using request: SSHConnectionRequest, shellIntegrationEnabled: Bool = true, columns: UInt16, rows: UInt16, progress: SSHRouteProgressHandler? = nil ) async throws { defer { request.configuration.clearSecrets() } try await worker.connect( using: request.withKeyboardInteractiveFallback(keyboardInteractive), environmentVariables: environmentVariables, environmentRejection: environmentRejection, shellIntegrationEnabled: shellIntegrationEnabled, columns: columns, rows: rows, progress: progress ) } public func configureEnvironment( _ variables: [HostEnvironmentVariable], rejection: @escaping EnvironmentRejectionHandler = { _ in } ) { environmentVariables = variables environmentRejection = rejection } public func configureCharsetErrors(_ handler: @escaping CharsetErrorHandler) { worker.setCharsetErrorHandler(handler) } public func write(_ data: Data) { worker.write(data) } /// Opens an SFTP subsystem Channel on the PTY Channel's authenticated connection. /// This is a fallback only: callers must first attempt their ordinary /// independent SFTP connection. public func openSFTPChannel() async throws -> any SFTPChannelProtocol { try await worker.openSFTPChannel() } public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) { worker.resize( columns: columns, rows: rows, widthPixels: widthPixels, heightPixels: heightPixels ) } public func disconnect() async { await worker.disconnect() } } nonisolated final class SSHPTYChannelWorker: @unchecked Sendable { private static let bracketedPasteStart = Data("\u{1B}[200~".utf8) private static let bracketedPasteEnd = Data("\u{1B}[201~".utf8) private static let initialization: Void = { precondition(libssh2_init(0) == 0, "libssh2 initialization failed") }() private let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.pty-channel") private let output: SSHPTYChannel.OutputHandler private let exit: SSHPTYChannel.ExitHandler private let shellIntegrationHandler: SSHPTYChannel.ShellIntegrationHandler private let logger: Logging.Logger private let maxSessionsPolicy: SSHMaxSessionsPolicy private var connection: SSHAuthenticatedConnection? private var ptyChannel: OpaquePointer? private var hostID: Host.ID? private var charset: RemoteCharset = .utf8 private var pumpTimer: (any DispatchSourceTimer)? private var buffer: SSHConnectionBuffer? private var channelBudget: SSHChannelBudget? private var ptyChannelLease: SSHChannelLease? private var socketEvents: SSHSocketEvents? private let pendingWrites = SSHByteQueue(chunkSize: 32_700) private var ptyResize = SSHPTYResizeState() private var shellIntegration: RemoteShellIntegrationBootstrap? private var shellIntegrationLifecycleLogger: RemoteShellIntegrationLifecycleLogger private var commandEventFilter: RemoteCommandEventFilter? private var inputConverter: RemoteCharsetStreamConverter? private var outputConverter: RemoteCharsetStreamConverter? private var workingDirectoryFilter: RemoteOSC7CharsetFilter? private var charsetErrorHandler: SSHPTYChannel.CharsetErrorHandler = { _ in } private var didReportExit = false private var sftpChannelOwner: SSHMultiplexedSFTPChannelOwner! init( output: @escaping SSHPTYChannel.OutputHandler, exit: @escaping SSHPTYChannel.ExitHandler, shellIntegrationHandler: @escaping SSHPTYChannel.ShellIntegrationHandler, logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-pty-channel"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) { _ = Self.initialization self.output = output self.exit = exit self.shellIntegrationHandler = shellIntegrationHandler self.logger = logger shellIntegrationLifecycleLogger = RemoteShellIntegrationLifecycleLogger(logger: logger) self.maxSessionsPolicy = maxSessionsPolicy sftpChannelOwner = SSHMultiplexedSFTPChannelOwner( queue: queue, context: { [weak self] in guard let self, let connection = self.connection, let buffer = self.buffer, let channelBudget = self.channelBudget else { return nil } return .init( connection: connection, buffer: buffer, channelBudget: channelBudget, charset: self.charset) }, updateEvents: { [weak self] session, buffer in self?.updateSharedConnectionEvents(session: session, buffer: buffer) }, didCloseLastChannel: { [weak self] in guard let self, self.ptyChannel == nil else { return } self.cleanup() }) } /// Opens a byte transport, handshakes, validates the host key, and authenticates — /// the identical prologue every non-PTY entry point needs before it can open /// a Channel. On any failure it frees what it created and rethrows, so a /// caller only owns the resulting connection once it has been handed one. /// /// The connection's libssh2 handle is left in blocking mode. Callers that want the /// non-blocking pump switch it themselves once their Channel is running. public static func openAuthenticatedConnection( using configuration: SSHConnectionConfiguration, timeout: TimeInterval, progress: SSHRouteProgressHandler? = nil, logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-authentication") ) throws -> SSHAuthenticatedConnection { _ = initialization defer { configuration.clearSecrets() } let operationID = UUID().uuidString.lowercased() logger.info( "ssh.authentication.started", metadata: [ "host_id": RemoteLogging.hostID(configuration.hostID), "operation_id": "\(operationID)", "protocol": "ssh", "stage": "opening", ]) var transport: SSHRoutedSocket? var session: OpaquePointer? do { let opened = try SSHRoutedSocket.open( host: configuration.host, port: configuration.port, proxy: configuration.proxy, jumps: configuration.jumps, timeout: timeout, progress: progress ) transport = opened progress?(.connecting(configuration.hostID)) guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { throw TermindSSHError.library("Unable to create an SSH session.") } session = created libssh2_session_set_blocking(created, 1) try performHandshake( created, socket: opened.socket, peer: SSHSocketConnector.peerDescription( socket: opened.socket, fallbackHost: configuration.host, port: configuration.port ), timeout: timeout ) let observed = try observation( host: configuration.host, port: configuration.port, session: created ) try validateHostKey(observed, expected: configuration.expectedHostKeys) progress?(.authenticating(configuration.hostID)) let authentication = try authenticate( username: configuration.username, credentials: configuration.credentials, session: created ) progress?(.authenticated(configuration.hostID)) logger.info( "ssh.authentication.completed", metadata: [ "authentication": "\(authentication)", "host_id": RemoteLogging.hostID(configuration.hostID), "operation_id": "\(operationID)", "outcome": "success", "protocol": "ssh", "stage": "authenticated", ]) return SSHAuthenticatedConnection( byteTransport: opened, libssh2Session: created) } catch { logger.error( "ssh.authentication.failed", metadata: [ "error_code": RemoteLogging.errorCode(error), "host_id": RemoteLogging.hostID(configuration.hostID), "operation_id": "\(operationID)", "outcome": "failure", "protocol": "ssh", "stage": "authentication", ]) if let session { _ = libssh2_session_free(session) } transport?.close() throw error } } public static func openAuthenticatedConnection( using request: SSHConnectionRequest, timeout: TimeInterval, progress: SSHRouteProgressHandler? = nil, logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-authentication") ) async throws -> SSHAuthenticatedConnection { let operationID = UUID().uuidString.lowercased() let request = request.enforcingInteractionPolicy() let configuration = request.configuration defer { configuration.clearSecrets() } logger.info( "ssh.authentication.started", metadata: [ "host_id": RemoteLogging.hostID(configuration.hostID), "operation_id": "\(operationID)", "protocol": "ssh", "stage": "opening", ]) let transport = try await SSHRoutedSocket.open( host: configuration.host, port: configuration.port, proxy: configuration.proxy, jumps: configuration.jumps, timeout: timeout, progress: progress, authorizeHostKey: request.authorizeHostKey, keyboardInteractive: request.keyboardInteractive, hostKeyConstraints: request.hostKeyConstraints ) let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.authenticated-opening") let exchange = SSHKeyboardInteractiveExchange( configuration: configuration, handler: request.keyboardInteractive) let state = SSHAuthenticatedOpeningState(transport: transport) return try await withTaskCancellationHandler { do { progress?(.connecting(configuration.hostID)) let observed = try await queue.perform { try state.begin( configuration: configuration, timeout: timeout ) } let requiredApproval = !configuration.expectedHostKeys.contains(observed.identity) try await authorizeHostKey( hostID: configuration.hostID, observed: observed, expected: configuration.expectedHostKeys, constraint: request.hostKeyConstraints[configuration.hostID], authorizer: request.authorizeHostKey ) try Task.checkCancellation() let authentication = try await queue.perform { try state.authenticate( configuration: configuration, afterHostKeyApproval: requiredApproval, progress: progress, keyboardInteractive: exchange ) } let result = try await queue.perform { try state.takeResult() } logger.info( "ssh.authentication.completed", metadata: [ "authentication": "\(authentication)", "host_id": RemoteLogging.hostID(configuration.hostID), "operation_id": "\(operationID)", "outcome": "success", "protocol": "ssh", "stage": "authenticated", ]) return result } catch { logger.error( "ssh.authentication.failed", metadata: [ "error_code": RemoteLogging.errorCode(error), "host_id": RemoteLogging.hostID(configuration.hostID), "operation_id": "\(operationID)", "outcome": "failure", "protocol": "ssh", "stage": "authentication", ]) await queue.perform { state.cleanup() } throw error } } onCancel: { exchange.cancel() state.cancel() queue.async { state.cleanup() } } } public static func installAuthorizedKey( _ publicKey: String, named keyName: String, using request: SSHConnectionRequest, logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) async throws { try await installAuthorizedKeys( [SSHAuthorizedKeyInstall(publicKey: publicKey, name: keyName)], using: request, logger: logger, maxSessionsPolicy: maxSessionsPolicy ) } public static func installAuthorizedKeys( _ keys: [SSHAuthorizedKeyInstall], using request: SSHConnectionRequest, logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-key-install"), maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy() ) async throws { let connection = SSHConnection(logger: logger, maxSessionsPolicy: maxSessionsPolicy) try await installAuthorizedKeys(keys, using: request, connection: connection) } static func installAuthorizedKeys( _ keys: [SSHAuthorizedKeyInstall], using request: SSHConnectionRequest, connection: any SSHConnectionProtocol ) async throws { guard !keys.isEmpty else { return } let inputs = try keys.map { try authorizedKeyInstallInput(publicKey: $0.publicKey, keyName: $0.name) } do { try await connection.connect(using: request) let command = #"umask 077; dir="$HOME/.ssh"; file="$dir/authorized_keys"; mkdir -p "$dir" && touch "$file" && chmod 700 "$dir" && chmod 600 "$file" && IFS= read -r comment && IFS= read -r key && { LC_ALL=C grep -Fqx -- "$key" "$file" || printf '%s\n%s\n' "$comment" "$key" >> "$file"; }"# for input in inputs { let result = try await connection.execute( command, workingDirectory: nil, stdin: .plain(input), timeout: 20, outputLimit: 64 * 1024, onOutput: nil) guard result.exitCode == 0 else { let detail = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) throw TermindSSHError.library( detail.isEmpty ? "The remote authorized_keys update exited with status \(result.exitCode)." : "The remote authorized_keys update failed: \(detail)") } } await connection.disconnect() } catch { await connection.disconnect() throw error } } public static func authorizedKeyInstallInput(publicKey: String, keyName: String) throws -> Data { let normalizedKey = publicKey.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedKey.isEmpty, !normalizedKey.contains("\n"), !normalizedKey.contains("\r") else { throw TermindSSHError.library("The public key is not a single OpenSSH key line.") } let printableName = keyName.unicodeScalars .map { CharacterSet.controlCharacters.contains($0) ? " " : String($0) } .joined() let normalizedName = printableName .split(whereSeparator: \Character.isWhitespace) .joined(separator: " ") let safeName = normalizedName.isEmpty ? "Key" : normalizedName return Data(("# \(safeName) Key by Termind\n\(normalizedKey)\n").utf8) } public func connect( using request: SSHConnectionRequest, environmentVariables: [HostEnvironmentVariable], environmentRejection: @escaping SSHPTYChannel.EnvironmentRejectionHandler, shellIntegrationEnabled: Bool, columns: UInt16, rows: UInt16, progress: SSHRouteProgressHandler? ) async throws { let generation = try await queue.perform { [self] in // Preserve a viewport received before this worker's first connect, // but never carry another connection's pending request forward. var nextResize = ptyResize let generation = nextResize.beginConnection(columns: columns, rows: rows) cleanup() ptyResize = nextResize return generation } let encodedEnvironment = try environmentVariables.map { variable in ( variable.variable, try RemoteCharsetStreamConverter.encode(variable.variable, charset: request.configuration.charset), try RemoteCharsetStreamConverter.encode(variable.value, charset: request.configuration.charset) ) } do { let channelBudget = maxSessionsPolicy.makeBudget() let ptyChannelLease = try await channelBudget.acquire() let openedConnection = try await Self.openAuthenticatedConnection( using: request, timeout: 20, progress: progress, logger: logger ) try await queue.perform { [self] in guard ptyResize.generation == generation, let size = ptyResize.requestedSize else { ptyChannelLease.release() channelBudget.close() openedConnection.close() throw CancellationError() } connection = openedConnection hostID = request.configuration.hostID charset = request.configuration.charset inputConverter = try RemoteCharsetStreamConverter( charset: request.configuration.charset, direction: .encoding) outputConverter = try RemoteCharsetStreamConverter( charset: request.configuration.charset, direction: .decoding) workingDirectoryFilter = RemoteOSC7CharsetFilter(charset: request.configuration.charset) self.channelBudget = channelBudget self.ptyChannelLease = ptyChannelLease let created = openedConnection.libssh2Session guard let openedChannel = libssh2_channel_open_ex( created, "session", 7, 2 * 1024 * 1024, 32 * 1024, nil, 0 ) else { throw Self.connectionError(created, prefix: "Unable to open SSH PTY Channel") } ptyChannel = openedChannel let pty = libssh2_channel_request_pty_ex( openedChannel, "xterm-256color", 14, nil, 0, Int32(size.columns), Int32(size.rows), Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels) ) guard pty == 0 else { throw Self.connectionError(created, prefix: "PTY request failed") } ptyResize.didCreatePTY(size: size) var rejectedVariables: [String] = [] for environmentVariable in encodedEnvironment { let result = environmentVariable.1.withUnsafeBytes { variable in environmentVariable.2.withUnsafeBytes { value in libssh2_channel_setenv_ex( openedChannel, variable.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(environmentVariable.1.count), value.baseAddress?.assumingMemoryBound(to: CChar.self), UInt32(environmentVariable.2.count)) } } if result == LIBSSH2_ERROR_CHANNEL_REQUEST_DENIED { rejectedVariables.append(environmentVariable.0) } else if result != 0 { throw Self.connectionError(created, prefix: "SSH environment request failed") } } if !rejectedVariables.isEmpty { environmentRejection(rejectedVariables) } guard libssh2_channel_process_startup(openedChannel, "shell", 5, nil, 0) == 0 else { throw Self.connectionError(created, prefix: "Remote shell request failed") } buffer = openedConnection.installBuffer() libssh2_session_set_blocking(created, 0) libssh2_keepalive_config(created, 1, 20) didReportExit = false shellIntegrationLifecycleLogger.begin( hostID: request.configuration.hostID, enabled: shellIntegrationEnabled ) if shellIntegrationEnabled { let bootstrap = RemoteShellIntegrationBootstrap(charset: request.configuration.charset) pendingWrites.append(bootstrap.startCommand) commandEventFilter = RemoteCommandEventFilter( token: bootstrap.token, charset: request.configuration.charset) shellIntegration = bootstrap } startPump() progress?(.connected(request.configuration.hostID)) } try Task.checkCancellation() } catch { await queue.perform { [self] in guard ptyResize.generation == generation else { return } cleanup() } throw error } } public func write(_ data: Data) { guard !data.isEmpty else { return } queue.async { [self] in if var integration = shellIntegration { integration.noteUserInput() shellIntegration = integration } do { let converted = try inputConverter?.convert(data).data ?? data if !converted.isEmpty { pendingWrites.append(converted) } } catch { if data.range(of: Self.bracketedPasteStart) != nil { pendingWrites.append(Self.bracketedPasteStart) } if data.range(of: Self.bracketedPasteEnd) != nil { pendingWrites.append(Self.bracketedPasteEnd) } charsetErrorHandler(error.localizedDescription) } pump() } } func setCharsetErrorHandler(_ handler: @escaping SSHPTYChannel.CharsetErrorHandler) { queue.async { self.charsetErrorHandler = handler } } public func resize(columns: UInt16, rows: UInt16, widthPixels: UInt32, heightPixels: UInt32) { guard columns > 0, rows > 0 else { return } queue.async { [self] in ptyResize.request( SSHPTYSize(columns: columns, rows: rows, widthPixels: widthPixels, heightPixels: heightPixels) ) pump() } } public func disconnect() async { await queue.perform { self.closeTerminalSession() } } public static func authenticate( username: String, credentials: [SSHCredentials], session: OpaquePointer, keyboardInteractive: SSHKeyboardInteractiveExchange? = nil, socket: Int32 = -1 ) throws -> String { var methods = try authenticationMethods(username: username, session: session) // Asking for the method list sends SSH's `none` authentication request. // Servers that need no credential accept it there and report the // session as authenticated without another userauth call. if libssh2_userauth_authenticated(session) == 1 { return "none" } var attempted = false var lastMethod = "none" var secureEnclaveError: (any Error)? var remaining = credentials var attemptedInteractive = false var skippedInteractiveCredential = false while true { guard let index = remaining.firstIndex(where: { credential in switch credential { case .password: methods.contains("password") case .privateKey, .secureEnclave, .interactionRequired: methods.contains("publickey") } }) else { guard !attemptedInteractive, methods.contains("keyboard-interactive"), let keyboardInteractive else { break } attempted = true attemptedInteractive = true lastMethod = "keyboard_interactive" let result = Self.keyboardInteractive( username: username, exchange: keyboardInteractive, session: session, socket: socket ) try keyboardInteractive.checkCancellation() if let error = keyboardInteractive.error { throw error } if result == 0 { return lastMethod } methods = try authenticationMethods(username: username, session: session) if libssh2_userauth_authenticated(session) == 1 { return lastMethod } continue } try keyboardInteractive?.checkCancellation() let credential = remaining.remove(at: index) switch credential { case .password(let password): if methods.contains("password") { attempted = true lastMethod = "password" let result = try username.withCString { user in try password.withUnsafeCStringAndConsume { password, passwordLength in libssh2_userauth_password_ex( session, user, UInt32(username.utf8.count), password, UInt32(passwordLength), nil ) } } if result == 0 { return lastMethod } } case .privateKey(let privateKey, let publicKey, let passphrase, _): guard methods.contains("publickey") else { continue } attempted = true lastMethod = "public_key" let result = try username.withCString { user in try privateKey.withUnsafeCStringAndConsume { privateBuffer, privateKeyLength in let call: (UnsafePointer?) throws -> Int32 = { publicBuffer in if let passphrase { return try passphrase.withUnsafeCStringAndConsume { passphraseBuffer, _ in libssh2_userauth_publickey_frommemory( session, user, username.utf8.count, publicBuffer, publicKey?.utf8.count ?? 0, privateBuffer, privateKeyLength, passphraseBuffer ) } } return libssh2_userauth_publickey_frommemory( session, user, username.utf8.count, publicBuffer, publicKey?.utf8.count ?? 0, privateBuffer, privateKeyLength, nil ) } return try publicKey?.withCString(call) ?? call(nil) } } if result == 0 { return lastMethod } case .secureEnclave(let credential, let publicKey, _): guard methods.contains("publickey") else { continue } guard let publicBlob = SecureEnclaveKey.publicBlob(from: publicKey) else { secureEnclaveError = TermindSSHError.library( "The Secure Enclave key has an invalid SSH public key." ) continue } attempted = true lastMethod = "secure_enclave" let signer = SecureEnclaveSigner(credential: credential) var abstract: UnsafeMutableRawPointer? = Unmanaged.passUnretained(signer).toOpaque() let result = username.withCString { user in publicBlob.withUnsafeBytes { bytes in libssh2_userauth_publickey( session, user, bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), bytes.count, secureEnclaveSignCallback, &abstract ) } } if result == 0 { return lastMethod } secureEnclaveError = signer.error case .interactionRequired: skippedInteractiveCredential = true } methods = try authenticationMethods(username: username, session: session) if libssh2_userauth_authenticated(session) == 1 { return lastMethod } } if skippedInteractiveCredential { throw SSHInteractionRequired() } if let secureEnclaveError { throw TermindSSHError.authenticationFailed(secureEnclaveError.localizedDescription) } let available = methods.sorted().joined(separator: ", ") let prefix = attempted ? "SSH authentication failed" : "No configured credential matches the server methods (\(available))" throw TermindSSHError.authenticationFailed( Self.connectionErrorMessage(session, prefix: prefix) ) } public static func authenticate( username: String, credentials: [SSHCredentials], session: OpaquePointer, afterHostKeyApproval: Bool, keyboardInteractive: SSHKeyboardInteractiveExchange? = nil, socket: Int32 = -1 ) throws -> String { do { return try authenticate( username: username, credentials: credentials, session: session, keyboardInteractive: keyboardInteractive, socket: socket ) } catch { if error is CancellationError || keyboardInteractive?.error != nil { throw error } let code = libssh2_session_last_errno(session) if afterHostKeyApproval, code == LIBSSH2_ERROR_SOCKET_DISCONNECT || code == LIBSSH2_ERROR_SOCKET_RECV || code == LIBSSH2_ERROR_TIMEOUT || code == LIBSSH2_ERROR_SOCKET_TIMEOUT { throw TermindSSHError.library( String( localized: "The server closed the SSH connection while waiting for host-key approval. Retry to connect using the accepted key." ) + " [libssh2 \(code)]" ) } throw error } } private static func authenticationMethods( username: String, session: OpaquePointer ) throws -> Set { let pointer = username.withCString { user in libssh2_userauth_list(session, user, UInt32(username.utf8.count)) } guard let pointer else { if libssh2_userauth_authenticated(session) == 1 { return [] } throw Self.connectionError( session, prefix: "Unable to read SSH authentication methods" ) } let bytes = UnsafeBufferPointer(start: pointer, count: strlen(pointer)) .map { UInt8(bitPattern: $0) } return Set(String(decoding: bytes, as: UTF8.self).split(separator: ",").map(String.init)) } private static func keyboardInteractive( username: String, exchange: SSHKeyboardInteractiveExchange, session: OpaquePointer, socket: Int32 ) -> Int32 { let callback = SSHKeyboardInteractiveCallback(exchange: exchange, session: session, socket: socket) let box = Unmanaged.passRetained(callback) let abstract = libssh2_session_abstract(session) let previous = abstract?.pointee let timeout = libssh2_session_get_timeout(session) abstract?.pointee = box.toOpaque() defer { abstract?.pointee = previous libssh2_session_set_timeout(session, timeout) box.release() } return username.withCString { user in libssh2_userauth_keyboard_interactive_ex( session, user, UInt32(username.utf8.count), sshKeyboardInteractiveResponse ) } } private func startPump() { pumpTimer?.cancel() socketEvents?.cancel() guard let connection else { return } socketEvents = SSHSocketEvents(socket: connection.byteTransport.socket, queue: queue) { [weak self] in self?.pump() } socketEvents?.update(read: true, write: false) let timer = DispatchSource.makeTimerSource(queue: queue) // Timer advances shell-integration deadlines and keepalive only; data // throughput is readiness-driven by `socketEvents`. timer.schedule(deadline: .now(), repeating: .milliseconds(100), leeway: .milliseconds(20)) timer.setEventHandler { [weak self] in self?.pump() } pumpTimer = timer timer.resume() } private func pump() { guard let connection, let buffer else { return } let session = connection.libssh2Session let socket = connection.byteTransport.socket let deadline = DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds buffer.beginPass(deadline: deadline) do { try buffer.flush(socket: socket) } catch { reportExit(.failure(Self.connectionError(session, prefix: "SSH transport write failed"))) cleanup() return } if buffer.isBackpressured { socketEvents?.update(read: false, write: true) return } if let ptyChannel { let resizeResult = ptyResize.flush { size in libssh2_channel_request_pty_size_ex( ptyChannel, Int32(size.columns), Int32(size.rows), Int32(clamping: size.widthPixels), Int32(clamping: size.heightPixels) ) } if resizeResult != 0, resizeResult != LIBSSH2_ERROR_EAGAIN { var metadata: Logger.Metadata = [ "error_code": "library", "protocol": "ssh", "stage": "pty_resize", ] if let hostID { metadata["host_id"] = RemoteLogging.hostID(hostID) } logger.error("ssh.pty_resize.failed", metadata: metadata) ptyResize.discardFailedRequest() } // Continue other I/O on EAGAIN: window-change may itself be waiting // for a previously blocked channel write or window adjustment. var buffer = [CChar](repeating: 0, count: 32 * 1024) while DispatchTime.now().uptimeNanoseconds < deadline { let count = libssh2_channel_read_ex(ptyChannel, 0, &buffer, buffer.count) if count > 0 { deliverOutput(Data(bytes: buffer, count: count)) } else if count == LIBSSH2_ERROR_EAGAIN || count == 0 { break } else { reportExit(.failure(Self.connectionError(session, prefix: "SSH read failed"))) cleanup() return } } if var integration = shellIntegration { let update = integration.poll() shellIntegration = update.isComplete ? nil : integration applyShellIntegrationUpdate(update) } while !pendingWrites.isEmpty && DispatchTime.now().uptimeNanoseconds < deadline { let written = pendingWrites.withFirstBytes { bytes -> Int in guard let address = bytes.baseAddress?.assumingMemoryBound(to: CChar.self) else { return 0 } return libssh2_channel_write_ex(ptyChannel, 0, address, bytes.count) } if written > 0 { pendingWrites.consume(written) } else if written == LIBSSH2_ERROR_EAGAIN { break } else { reportExit(.failure(Self.connectionError(session, prefix: "SSH write failed"))) cleanup() return } } } var next = Int32(0) let keepalive = libssh2_keepalive_send(session, &next) if keepalive < 0, keepalive != LIBSSH2_ERROR_EAGAIN { reportExit(.failure(Self.connectionError(session, prefix: "SSH keepalive failed"))) cleanup() return } if let ptyChannel, libssh2_channel_eof(ptyChannel) == 1 { let status = libssh2_channel_get_exit_status(ptyChannel) reportExit(.success(status)) closeTerminalSession() return } do { try buffer.flush(socket: socket) } catch { reportExit(.failure(Self.connectionError(session, prefix: "SSH transport write failed"))) cleanup() return } let directions = libssh2_session_block_directions(session) socketEvents?.update( read: !buffer.isBackpressured, write: buffer.wantsWrite || directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0) } private func reportExit(_ result: Result) { guard !didReportExit else { return } didReportExit = true exit(result) } private func deliverOutput(_ data: Data) { guard var integration = shellIntegration else { deliverIntegratedOutput(data) return } let update = integration.receive(data) shellIntegration = update.isComplete ? nil : integration applyShellIntegrationUpdate(update) } private func applyShellIntegrationUpdate( _ update: RemoteShellIntegrationBootstrap.Update ) { if update.rejectedCharsetInput { charsetErrorHandler( RemoteCharsetConversionError( charset: outputConverter?.charset ?? .utf8, direction: .decoding ).localizedDescription ) } if let diagnostic = update.diagnostic, let hostID { shellIntegrationLifecycleLogger.record(diagnostic, hostID: hostID) } if let command = update.command { pendingWrites.append(command) } if update.integrationInstalled { shellIntegrationHandler( .available( shell: update.shell ?? "unknown", systemInfo: update.systemInfo, homeDirectory: update.homeDirectory ) ) } else { if let systemInfo = update.systemInfo { shellIntegrationHandler(.systemInfo(systemInfo)) } if update.homeResolutionUnavailable || update.isComplete { shellIntegrationHandler(.homeResolutionUnavailable) } } if let visibleOutput = update.visibleOutput, !visibleOutput.isEmpty { deliverIntegratedOutput(visibleOutput) } } private func deliverIntegratedOutput(_ data: Data) { guard var commandEventFilter else { emitConvertedOutput(data) return } let update = commandEventFilter.receive(data) self.commandEventFilter = commandEventFilter if !update.visibleOutput.isEmpty { emitConvertedOutput(update.visibleOutput) } if update.rejectedCharsetInput { charsetErrorHandler( RemoteCharsetConversionError( charset: outputConverter?.charset ?? .utf8, direction: .decoding ).localizedDescription ) } for event in update.events { switch event { case .commandStarted(let command): shellIntegrationHandler(.commandStarted(command)) case .commandFinished(let status, let duration): shellIntegrationHandler(.commandFinished(status, duration: duration)) case .promptReady: shellIntegrationHandler(.promptReady) case .draft(let draft): shellIntegrationHandler(.draft(draft)) } } } private func emitConvertedOutput(_ data: Data) { do { var filteredData = data if var filter = workingDirectoryFilter { let filtered = filter.receive(data) workingDirectoryFilter = filter filteredData = filtered.data if filtered.rejectedInput { charsetErrorHandler( RemoteCharsetConversionError( charset: outputConverter?.charset ?? .utf8, direction: .decoding ).localizedDescription ) } } let converted = try outputConverter?.convert(filteredData, recoveringInvalidInput: true) ?? .init(data: filteredData, rejectedInput: false) if !converted.data.isEmpty { output(converted.data) } if converted.rejectedInput { charsetErrorHandler( RemoteCharsetConversionError( charset: outputConverter?.charset ?? .utf8, direction: .decoding ).localizedDescription ) } } catch { charsetErrorHandler(error.localizedDescription) } } private func finishCharsetOutput() { guard let outputConverter else { return } do { if var filter = workingDirectoryFilter { let remainder = filter.finish() workingDirectoryFilter = filter if !remainder.isEmpty { let prefix = try outputConverter.convert(remainder, recoveringInvalidInput: true) if !prefix.data.isEmpty { output(prefix.data) } if prefix.rejectedInput { charsetErrorHandler( RemoteCharsetConversionError( charset: outputConverter.charset, direction: .decoding ).localizedDescription ) } } } let converted = try outputConverter.finish(recoveringInvalidInput: true) if !converted.data.isEmpty { output(converted.data) } if converted.rejectedInput { charsetErrorHandler( RemoteCharsetConversionError( charset: outputConverter.charset, direction: .decoding ).localizedDescription ) } } catch { charsetErrorHandler(error.localizedDescription) } } private func closeTerminalSession() { finishCharsetOutput() pendingWrites.removeAll() ptyResize.reset() if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) } shellIntegration = nil commandEventFilter = nil inputConverter = nil outputConverter = nil workingDirectoryFilter = nil hostID = nil if let ptyChannel { _ = libssh2_channel_send_eof(ptyChannel) _ = libssh2_channel_close(ptyChannel) _ = libssh2_channel_free(ptyChannel) self.ptyChannel = nil ptyChannelLease?.release() ptyChannelLease = nil } if sftpChannelOwner.isEmptyOnQueue { cleanup() } } private func cleanup() { pumpTimer?.cancel() pumpTimer = nil let connection = connection self.connection = nil pendingWrites.removeAll() ptyResize.reset() if let hostID { shellIntegrationLifecycleLogger.sessionEnded(hostID: hostID) } shellIntegration = nil commandEventFilter = nil inputConverter = nil outputConverter = nil workingDirectoryFilter = nil hostID = nil if let ptyChannel { _ = libssh2_channel_send_eof(ptyChannel) _ = libssh2_channel_close(ptyChannel) _ = libssh2_channel_free(ptyChannel) self.ptyChannel = nil } sftpChannelOwner.closeAllOnQueue() ptyChannelLease?.release() ptyChannelLease = nil channelBudget?.close() channelBudget = nil buffer = nil if let socketEvents { socketEvents.cancel { connection?.close() } self.socketEvents = nil } else { connection?.close() } } public static func observation(host: String, port: Int, session: OpaquePointer) throws -> SSHHostKeyObservation { guard let hash = libssh2_hostkey_hash(session, Int32(LIBSSH2_HOSTKEY_HASH_SHA256)) else { throw connectionError(session, prefix: "The server did not provide a host-key fingerprint") } let digest = Data(bytes: hash, count: 32).base64EncodedString().replacingOccurrences(of: "=", with: "") let legacyMD5Fingerprint = libssh2_hostkey_hash(session, Int32(LIBSSH2_HOSTKEY_HASH_MD5)).map { Data(bytes: $0, count: 16) } var length = 0 var type = Int32(LIBSSH2_HOSTKEY_TYPE_UNKNOWN) _ = libssh2_session_hostkey(session, &length, &type) let algorithm: SSHKeyAlgorithm switch type { case LIBSSH2_HOSTKEY_TYPE_RSA: algorithm = .rsa case LIBSSH2_HOSTKEY_TYPE_ECDSA_256: algorithm = .ecdsa256 case LIBSSH2_HOSTKEY_TYPE_ECDSA_384: algorithm = .ecdsa384 case LIBSSH2_HOSTKEY_TYPE_ECDSA_521: algorithm = .ecdsa521 case LIBSSH2_HOSTKEY_TYPE_ED25519: algorithm = .ed25519 default: throw connectionError(session, prefix: "The server provided an unsupported host-key type") } return SSHHostKeyObservation( hostname: host, port: port, algorithm: algorithm, fingerprint: "SHA256:\(digest)", legacyMD5Fingerprint: legacyMD5Fingerprint ) } public static func validateHostKey( _ observed: SSHHostKeyObservation, expected: Set ) throws { guard expected.contains(observed.identity) else { throw TermindSSHError.hostKeyChanged( expected: Set(expected.map(\.fingerprint)).sorted(), received: observed.fingerprint ) } } public static func authorizeHostKey( hostID: Host.ID, observed: SSHHostKeyObservation, expected: Set, constraint: SSHHostKeyConstraint? = nil, authorizer: SSHHostKeyAuthorizer? ) async throws { let constraintMatched: Bool if let constraint { guard constraint.matches(observed) else { throw TermindSSHError.mismatchedHostKey } constraintMatched = true } else { constraintMatched = false } if expected.contains(observed.identity) { return } // A provisional fingerprint is an additional constraint, never an // override for an existing, conflicting Known Hosts record. if constraintMatched, !expected.isEmpty { throw TermindSSHError.mismatchedHostKey } if let authorizer { let decision = try await authorizer( SSHHostKeyChallenge( hostID: hostID, observation: observed, expectedHostKeys: expected, provisionalConstraintMatched: constraintMatched ) ) guard decision == .accept else { throw CancellationError() } return } if expected.isEmpty { throw TermindSSHError.untrustedHost } throw TermindSSHError.hostKeyChanged( expected: expected.map(\.fingerprint).sorted(), received: observed.fingerprint ) } public static func performHandshake( _ session: OpaquePointer, socket: Int32, peer: String, timeout: TimeInterval = 20 ) throws { let started = ContinuousClock.now errno = 0 libssh2_session_set_timeout( session, max(1, Int(timeout * 1_000)) ) let result = libssh2_session_handshake(session, socket) let systemError = errno == 0 ? nil : errno guard result == 0 else { var message: UnsafeMutablePointer? var length = Int32(0) _ = libssh2_session_last_error(session, &message, &length, 0) let detail = message.map { String(cString: $0) } ?? "" let elapsed = started.duration(to: .now).components let milliseconds = max( 0, Int(elapsed.seconds) * 1_000 + Int(elapsed.attoseconds / 1_000_000_000_000_000) ) let phase: SSHHandshakeFailure.Phase = detail.localizedCaseInsensitiveContains("banner") ? .identification : detail.localizedCaseInsensitiveContains("key") ? .keyExchange : .unknown let kind: SSHHandshakeFailure.Kind switch result { case LIBSSH2_ERROR_SOCKET_DISCONNECT: kind = .peerClosed case LIBSSH2_ERROR_BANNER_RECV: kind = .invalidIdentification case LIBSSH2_ERROR_TIMEOUT, LIBSSH2_ERROR_SOCKET_TIMEOUT: kind = .timedOut case LIBSSH2_ERROR_SOCKET_RECV where systemError == EAGAIN || systemError == EWOULDBLOCK || systemError == ETIMEDOUT: kind = .timedOut case LIBSSH2_ERROR_SOCKET_RECV: kind = .receiveFailed default: kind = .other } throw TermindSSHError.handshake( SSHHandshakeFailure( code: result, systemError: systemError, kind: kind, phase: phase, elapsedMilliseconds: milliseconds, peer: peer ) ) } } public static func connectionError(_ session: OpaquePointer, prefix: String) -> TermindSSHError { .library(connectionErrorMessage(session, prefix: prefix)) } private static func connectionErrorMessage(_ session: OpaquePointer, prefix: String) -> String { var message: UnsafeMutablePointer? var length = Int32(0) let code = libssh2_session_last_error(session, &message, &length, 0) let detail = message.map { String(cString: $0) } ?? "libssh2 error \(code)" return "\(prefix): \(detail)" } } nonisolated extension SSHPTYChannelWorker { fileprivate func openSFTPChannel() async throws -> any SFTPChannelProtocol { try await sftpChannelOwner.openChannel() } private func updateSharedConnectionEvents(session: OpaquePointer, buffer: SSHConnectionBuffer) { let directions = libssh2_session_block_directions(session) socketEvents?.update( read: !buffer.isBackpressured, write: buffer.wantsWrite || directions & LIBSSH2_SESSION_BLOCK_OUTBOUND != 0) } } private nonisolated final class SSHAuthenticatedOpeningState: @unchecked Sendable { private let cancellationLock = NSLock() private var cancellableSocket: Int32 private var transport: SSHRoutedSocket? private var session: OpaquePointer? init(transport: SSHRoutedSocket) { self.transport = transport cancellableSocket = transport.socket } func begin( configuration: SSHConnectionConfiguration, timeout: TimeInterval ) throws -> SSHHostKeyObservation { guard let transport else { throw TermindSSHError.disconnected } guard let created = libssh2_session_init_ex(nil, nil, nil, nil) else { throw TermindSSHError.library("Unable to create an SSH session.") } session = created libssh2_session_set_blocking(created, 1) try SSHConnectionOpener.performHandshake( created, socket: transport.socket, peer: SSHSocketConnector.peerDescription( socket: transport.socket, fallbackHost: configuration.host, port: configuration.port ), timeout: timeout ) return try SSHConnectionOpener.observation( host: configuration.host, port: configuration.port, libssh2Session: created ) } func authenticate( configuration: SSHConnectionConfiguration, afterHostKeyApproval: Bool, progress: SSHRouteProgressHandler?, keyboardInteractive: SSHKeyboardInteractiveExchange ) throws -> String { guard let session else { throw TermindSSHError.disconnected } progress?(.authenticating(configuration.hostID)) let authentication = try SSHConnectionOpener.authenticate( username: configuration.username, credentials: configuration.credentials, libssh2Session: session, afterHostKeyApproval: afterHostKeyApproval, keyboardInteractive: keyboardInteractive, socket: transport?.socket ?? -1 ) progress?(.authenticated(configuration.hostID)) return authentication } func takeResult() throws -> SSHAuthenticatedConnection { guard let transport, let session else { throw TermindSSHError.disconnected } let result = SSHAuthenticatedConnection( byteTransport: transport, libssh2Session: session) cancellationLock.withLock { cancellableSocket = -1 } self.transport = nil self.session = nil return result } func cleanup() { if let session { _ = libssh2_session_disconnect_ex( session, SSH_DISCONNECT_BY_APPLICATION, "Termind connection cancelled", "" ) _ = libssh2_session_free(session) self.session = nil } cancellationLock.withLock { cancellableSocket = -1 } transport?.close() transport = nil } func cancel() { cancellationLock.withLock { if cancellableSocket >= 0 { _ = Darwin.shutdown(cancellableSocket, SHUT_RDWR) } } } } private nonisolated final class SecureEnclaveSigner: @unchecked Sendable { public let credential: SecureEnclaveKeyCredential public var error: (any Error)? public init(credential: SecureEnclaveKeyCredential) { self.credential = credential } } private nonisolated let secureEnclaveSignCallback: @convention(c) ( OpaquePointer?, UnsafeMutablePointer?>?, UnsafeMutablePointer?, UnsafePointer?, Int, UnsafeMutablePointer? ) -> Int32 = { _, signature, signatureLength, data, dataLength, abstract in guard let opaqueSigner = abstract?.pointee, let data, dataLength > 0 else { return -1 } let signer = Unmanaged.fromOpaque(opaqueSigner).takeUnretainedValue() do { let signedData = try SecureEnclaveKey.sign( Data(bytes: data, count: dataLength), credential: signer.credential ) guard let output = malloc(signedData.count)?.assumingMemoryBound(to: UInt8.self) else { return -1 } signedData.copyBytes(to: output, count: signedData.count) signature?.pointee = output signatureLength?.pointee = signedData.count return 0 } catch { signer.error = error return -1 } }