Add native security key registration, reference import, and signing on macOS and iOS. Store references in Vault metadata and route authentication through connection, Assistant, MCP, and agent forwarding flows. Preserve signing cancellation and actionable authentication errors for security keys and Secure Enclave. Pin the released libssh2 forks as nested submodules and refresh package resolution.
2048 lines
96 KiB
Swift
2048 lines
96 KiB
Swift
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?
|
|
}
|
|
|
|
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 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..<buffer.endIndex
|
|
),
|
|
let lineEnd = buffer[terminatorRange.upperBound...].firstIndex(of: 0x0A)
|
|
else { return Update() }
|
|
|
|
if !isLate {
|
|
preservedOutput = removingSystemInfoMarker(
|
|
from: outputBeforeCommand(
|
|
in: Data(buffer[..<prefixRange.lowerBound])
|
|
)
|
|
)
|
|
}
|
|
let shellBytes = Data(buffer[shellStart..<terminatorRange.lowerBound])
|
|
let shellPath =
|
|
(try? RemoteCharsetStreamConverter.decode(shellBytes, charset: charset))
|
|
?? String(decoding: shellBytes, as: UTF8.self)
|
|
let shell = shellPath.split(separator: "/").last.map(String.init)?.lowercased()
|
|
detectedShell = shell
|
|
detectedSystemInfo = parseSystemInfo(in: buffer)
|
|
let suffix = Data(buffer[buffer.index(after: lineEnd)...])
|
|
buffer.removeAll(keepingCapacity: false)
|
|
|
|
guard let command = installationCommand(for: shell) else {
|
|
var visible = preservedOutput
|
|
visible.append(suffix)
|
|
finish()
|
|
return Update(
|
|
visibleOutput: visible, isComplete: true, shell: detectedShell,
|
|
systemInfo: detectedSystemInfo,
|
|
diagnostic: .skipped(.unsupportedShell)
|
|
)
|
|
}
|
|
guard !(isLate && userDidWrite) else {
|
|
var visible = preservedOutput
|
|
visible.append(suffix)
|
|
finish()
|
|
return Update(
|
|
visibleOutput: visible, isComplete: true, shell: detectedShell,
|
|
systemInfo: detectedSystemInfo,
|
|
diagnostic: .skipped(.userInput)
|
|
)
|
|
}
|
|
phase = .installing
|
|
installationStartedLate = isLate
|
|
deadline = now + Self.responseTimeout
|
|
lastActivity = nil
|
|
return Update(command: command)
|
|
}
|
|
|
|
private mutating func receiveInstallation() -> 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
|
|
)
|
|
)
|
|
}
|
|
|
|
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..<lineEnd])
|
|
if payload.last == 0x0D { payload.removeLast() }
|
|
let decoded = RemoteCommandEventFilter.decodePercentEncoded(payload, charset: charset)
|
|
guard
|
|
decoded.hasPrefix("/"),
|
|
!decoded.unicodeScalars.contains(where: {
|
|
CharacterSet.controlCharacters.contains($0)
|
|
|| CharacterSet.newlines.contains($0)
|
|
})
|
|
else { return nil }
|
|
return decoded
|
|
}
|
|
|
|
private mutating func parseSystemInfo(in data: Data) -> 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..<lineEnd])
|
|
if payload.last == 0x0D { payload.removeLast() }
|
|
guard payload.count >= 2, payload.suffix(2).elementsEqual(Data("__".utf8))
|
|
else { return nil }
|
|
payload.removeLast(2)
|
|
let fields =
|
|
(try? RemoteCharsetStreamConverter.decode(payload, charset: charset))
|
|
?? String(decoding: payload, as: UTF8.self)
|
|
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[..<prefixRange.lowerBound])
|
|
result.append(Data(data[data.index(after: lineEnd)...]))
|
|
return result
|
|
}
|
|
|
|
private mutating func scanForLateProbe(now: UInt64) -> 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[..<suppressionStart])
|
|
buffer = Data(buffer[suppressionStart...])
|
|
phase = .suppressingLateProbe
|
|
lastActivity = now
|
|
deadline = now + Self.responseTimeout
|
|
|
|
let probe = receiveProbe(now: now, isLate: true)
|
|
return mergingVisible(visible, into: probe)
|
|
}
|
|
|
|
var retainedCount = longestSuffixMatchingPrefix(of: targets, in: buffer)
|
|
if retainedCount > 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[..<visibleEnd])
|
|
buffer = Data(buffer[visibleEnd...])
|
|
lastActivity = buffer.isEmpty ? nil : now
|
|
return Update(visibleOutput: visible.isEmpty ? nil : visible)
|
|
}
|
|
|
|
private func earliestMatch(of targets: [Data], in data: Data)
|
|
-> (range: Range<Data.Index>, 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[..<boundary]
|
|
guard let lineEnd = prefix.lastIndex(of: 0x0A) else { return Data() }
|
|
return Data(prefix[...lineEnd])
|
|
}
|
|
|
|
private func startOfLine(containing index: Data.Index, in data: Data) -> Data.Index {
|
|
data[..<index].lastIndex(of: 0x0A).map { data.index(after: $0) }
|
|
?? data.startIndex
|
|
}
|
|
|
|
private func installationCommand(for shell: String?) -> 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] = []
|
|
}
|
|
|
|
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[..<marker.lowerBound])
|
|
}
|
|
guard let end = buffer[marker.upperBound...].firstIndex(of: 0x07) else {
|
|
if buffer.distance(from: marker.upperBound, to: buffer.endIndex)
|
|
> 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..<end])
|
|
guard payload.count <= Self.maximumPayloadBytes else {
|
|
buffer = Data(buffer[buffer.index(after: end)...])
|
|
continue
|
|
}
|
|
switch match.kind {
|
|
case .command:
|
|
let command = Self.decodePercentEncoded(payload, charset: charset)
|
|
if !command.isEmpty {
|
|
update.events.append(.commandStarted(command))
|
|
}
|
|
case .commandFinished:
|
|
let fields = String(decoding: payload, as: UTF8.self)
|
|
.split(separator: ";", omittingEmptySubsequences: false)
|
|
if let statusText = fields.first, fields.count <= 2,
|
|
let status = Int32(statusText), (0...255).contains(status)
|
|
{
|
|
let duration =
|
|
fields.count == 2
|
|
? Self.duration(milliseconds: fields[1])
|
|
: nil
|
|
update.events.append(.commandFinished(status, duration: duration))
|
|
}
|
|
case .prompt:
|
|
guard payload.isEmpty else { break }
|
|
update.events.append(.promptReady)
|
|
case .draft:
|
|
update.events.append(.draft(Self.decodePercentEncoded(payload, charset: charset)))
|
|
}
|
|
buffer = Data(buffer[buffer.index(after: end)...])
|
|
}
|
|
|
|
let retained = longestSuffixMatchingPrefix()
|
|
let visibleEnd = buffer.index(buffer.endIndex, offsetBy: -retained)
|
|
update.visibleOutput.append(buffer[..<visibleEnd])
|
|
buffer = Data(buffer[visibleEnd...])
|
|
return update
|
|
}
|
|
|
|
private func longestSuffixMatchingPrefix() -> 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<Data.Index>, kind: MarkerKind)? {
|
|
let candidates: [(Range<Data.Index>, 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
|
|
}
|
|
}
|
|
let decodedData = Data(decoded)
|
|
return (try? RemoteCharsetStreamConverter.decode(decodedData, charset: charset))
|
|
?? String(decoding: decodedData, as: UTF8.self)
|
|
}
|
|
|
|
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<Int32, TermindSSHError>) -> Void
|
|
public typealias ShellIntegrationHandler = @Sendable (SSHShellIntegrationEvent) -> Void
|
|
public typealias EnvironmentRejectionHandler = @Sendable ([String]) -> Void
|
|
|
|
private nonisolated let worker: SSHPTYChannelWorker
|
|
private let keyboardInteractive: SSHKeyboardInteractiveHandler?
|
|
private let agentForwardingFactory: SSHAgentForwardingFactory?
|
|
private var environmentVariables: [HostEnvironmentVariable] = []
|
|
private var environmentRejection: EnvironmentRejectionHandler = { _ in }
|
|
|
|
public init(
|
|
output: @escaping OutputHandler, exit: @escaping ExitHandler,
|
|
shellIntegration: @escaping ShellIntegrationHandler = { _ in },
|
|
keyboardInteractive: SSHKeyboardInteractiveHandler? = nil,
|
|
agentForwardingFactory: SSHAgentForwardingFactory? = nil,
|
|
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-pty-channel"),
|
|
maxSessionsPolicy: SSHMaxSessionsPolicy = SSHMaxSessionsPolicy()
|
|
) {
|
|
self.keyboardInteractive = keyboardInteractive
|
|
self.agentForwardingFactory = agentForwardingFactory
|
|
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<SSHHostKeyIdentity>,
|
|
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() }
|
|
var request = request.withKeyboardInteractiveFallback(keyboardInteractive)
|
|
request.agentForwarding = try await agentForwardingFactory?(request)
|
|
try await worker.connect(
|
|
using: request,
|
|
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 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 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 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()
|
|
var logger = logger
|
|
logger[metadataKey: "operation_id"] = "\(operationID)"
|
|
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, logger: logger
|
|
)
|
|
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, logger: logger
|
|
)
|
|
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()
|
|
var logger = logger
|
|
logger[metadataKey: "operation_id"] = "\(operationID)"
|
|
logger[metadataKey: "host_id"] = RemoteLogging.hostID(request.configuration.hostID)
|
|
let authenticationLogger = logger
|
|
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,
|
|
securityKeySigning: request.securityKeySigning,
|
|
interactionPolicy: request.interactionPolicy,
|
|
hostKeyConstraints: request.hostKeyConstraints, logger: logger
|
|
)
|
|
let queue = DispatchQueue(label: "com.akinokaede.termind.ssh.authenticated-opening")
|
|
let exchange = SSHKeyboardInteractiveExchange(
|
|
configuration: configuration, handler: request.keyboardInteractive,
|
|
securityKeySigning: request.securityKeySigning, allowsUserInteraction: request.interactionPolicy == .allowed
|
|
)
|
|
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, logger: authenticationLogger
|
|
)
|
|
}
|
|
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
|
|
if let provider = request.agentForwarding {
|
|
let transport = openedConnection.installBuffer(deferIO: true)
|
|
transport.agentChannels = SSHAgentChannels(provider: provider, queue: queue) { [weak self] in
|
|
self?.pump()
|
|
}
|
|
libssh2_session_callback_set2(
|
|
created, LIBSSH2_CALLBACK_AUTHAGENT,
|
|
unsafeBitCast(SSHAgentChannels.callback, to: (@convention(c) () -> Void).self))
|
|
guard libssh2_channel_request_auth_agent(openedChannel) == 0 else {
|
|
throw Self.connectionError(created, prefix: "Agent Forwarding request failed")
|
|
}
|
|
}
|
|
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
|
|
}
|
|
let converted = (try? inputConverter?.convert(data).data) ?? data
|
|
if !converted.isEmpty { pendingWrites.append(converted) }
|
|
pump()
|
|
}
|
|
}
|
|
|
|
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,
|
|
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-authentication")
|
|
) 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 lastFailure: String?
|
|
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, .securityKey, .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 }
|
|
lastFailure = connectionErrorMessage(session, prefix: "SSH authentication failed", fallbackCode: result)
|
|
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 }
|
|
lastFailure = connectionErrorMessage(
|
|
session, prefix: "SSH authentication failed", fallbackCode: result)
|
|
}
|
|
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<CChar>?) 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 }
|
|
lastFailure = connectionErrorMessage(session, prefix: "SSH authentication failed", fallbackCode: result)
|
|
case .securityKey(let credential, let name):
|
|
guard let keyboardInteractive else { throw SSHInteractionRequired() }
|
|
attempted = true
|
|
lastMethod = "security_key"
|
|
let publicBlob = try WebAuthnSSH.publicBlob(credential)
|
|
let signer = SecurityKeySigner(
|
|
credential: credential, keyName: name, exchange: keyboardInteractive, logger: logger)
|
|
logger.info("ssh.security_key.started", metadata: ["stage": "public_key_offer"])
|
|
var abstract: UnsafeMutableRawPointer? = Unmanaged.passUnretained(signer).toOpaque()
|
|
let timeout = libssh2_session_get_timeout(session)
|
|
defer { libssh2_session_set_timeout(session, timeout) }
|
|
let result = username.withCString { user in
|
|
publicBlob.withUnsafeBytes { bytes in
|
|
libssh2_userauth_publickey_signed(
|
|
session, user,
|
|
bytes.baseAddress?.assumingMemoryBound(to: UInt8.self), bytes.count,
|
|
securityKeySignCallback, &abstract)
|
|
}
|
|
}
|
|
try keyboardInteractive.checkCancellation()
|
|
if result != 0 {
|
|
var metadata: Logging.Logger.Metadata = [
|
|
"stage": "\(signer.stage)", "libssh2_code": "\(result)",
|
|
"error_code": signer.error.map(RemoteLogging.errorCode) ?? "libssh2_authentication_failed",
|
|
]
|
|
if let error = signer.error as NSError? {
|
|
metadata["native_error_domain"] = "\(error.domain)"
|
|
metadata["native_error_code"] = "\(error.code)"
|
|
}
|
|
logger.error("ssh.security_key.failed", metadata: metadata)
|
|
} else {
|
|
logger.info("ssh.security_key.completed", metadata: ["stage": "authenticated"])
|
|
}
|
|
if let error = signer.error { throw error }
|
|
if result == 0 { return lastMethod }
|
|
if result == LIBSSH2_ERROR_AUTHENTICATION_FAILED, signer.stage == "public_key_offer" {
|
|
lastFailure = connectionErrorMessage(
|
|
session,
|
|
prefix: String(
|
|
localized:
|
|
"The server rejected the username or public key. Check the login user and the key in authorized_keys."
|
|
), fallbackCode: result)
|
|
} else {
|
|
lastFailure = connectionErrorMessage(
|
|
session, prefix: "SSH authentication failed", fallbackCode: result)
|
|
}
|
|
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
|
|
)
|
|
}
|
|
}
|
|
// A cancelled/failed system signing prompt must never fall through to another credential.
|
|
if let error = signer.error { throw error }
|
|
if result == 0 { return lastMethod }
|
|
lastFailure = connectionErrorMessage(session, prefix: "SSH authentication failed", fallbackCode: result)
|
|
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(
|
|
lastFailure ?? Self.connectionErrorMessage(session, prefix: prefix)
|
|
)
|
|
}
|
|
|
|
public static func authenticate(
|
|
username: String, credentials: [SSHCredentials],
|
|
session: OpaquePointer, afterHostKeyApproval: Bool,
|
|
keyboardInteractive: SSHKeyboardInteractiveExchange? = nil, socket: Int32 = -1,
|
|
logger: Logging.Logger = ApplicationLogger.disabled("termind.ssh-authentication")
|
|
) throws -> String {
|
|
do {
|
|
return try authenticate(
|
|
username: username, credentials: credentials,
|
|
session: session, keyboardInteractive: keyboardInteractive, socket: socket, logger: logger
|
|
)
|
|
} 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<String> {
|
|
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
|
|
}
|
|
var agentProgress =
|
|
buffer.agentChannels?.advance(
|
|
deadline: min(
|
|
deadline, DispatchTime.now().uptimeNanoseconds + SSHForwardingTuning.current.sliceNanoseconds / 2),
|
|
permitsIO: { !buffer.isBackpressured }) ?? false
|
|
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
|
|
}
|
|
}
|
|
}
|
|
|
|
agentProgress =
|
|
(buffer.agentChannels?.advance(deadline: deadline, permitsIO: { !buffer.isBackpressured }) ?? false)
|
|
|| agentProgress
|
|
if agentProgress { queue.async { [weak self] in self?.pump() } }
|
|
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<Int32, TermindSSHError>) {
|
|
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 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) }
|
|
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
|
|
}
|
|
let converted =
|
|
try outputConverter?.convert(filteredData)
|
|
?? .init(data: filteredData, rejectedInput: false)
|
|
if !converted.data.isEmpty { output(converted.data) }
|
|
} catch {
|
|
output(data)
|
|
}
|
|
}
|
|
|
|
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)
|
|
if !prefix.data.isEmpty { output(prefix.data) }
|
|
}
|
|
}
|
|
let converted = try outputConverter.finish()
|
|
if !converted.data.isEmpty { output(converted.data) }
|
|
} catch {}
|
|
}
|
|
|
|
private func closeTerminalSession() {
|
|
buffer?.agentChannels?.shutdown()
|
|
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() {
|
|
buffer?.agentChannels?.shutdown()
|
|
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<SSHHostKeyIdentity>
|
|
) 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<SSHHostKeyIdentity>, 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<CChar>?
|
|
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, fallbackCode: Int32? = nil
|
|
) -> String {
|
|
var message: UnsafeMutablePointer<CChar>?
|
|
var length = Int32(0)
|
|
let code = libssh2_session_last_error(session, &message, &length, 0)
|
|
let detail = message.map { String(cString: $0) }?.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
let reason = detail.flatMap { $0.isEmpty ? nil : $0 } ?? "libssh2 error \(fallbackCode ?? code)"
|
|
return "\(prefix): \(reason) [libssh2 \(fallbackCode ?? code)]"
|
|
}
|
|
|
|
}
|
|
|
|
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,
|
|
logger: Logging.Logger
|
|
) 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, logger: logger
|
|
)
|
|
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<UInt8>?>?,
|
|
UnsafeMutablePointer<Int>?, UnsafePointer<UInt8>?, Int,
|
|
UnsafeMutablePointer<UnsafeMutableRawPointer?>?
|
|
) -> Int32 = { _, signature, signatureLength, data, dataLength, abstract in
|
|
guard let opaqueSigner = abstract?.pointee, let data, dataLength > 0 else { return -1 }
|
|
let signer = Unmanaged<SecureEnclaveSigner>.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
|
|
}
|
|
}
|