84 lines
3.5 KiB
Swift
84 lines
3.5 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import CSSH2
|
|
import Foundation
|
|
import TermindApplication
|
|
import TermindDomain
|
|
|
|
nonisolated enum SSHTailscaleAuthentication {
|
|
enum Step: Equatable, Sendable {
|
|
case authenticated
|
|
case methods(Set<String>)
|
|
case pending(String?)
|
|
}
|
|
|
|
static func isTailscaleServer(_ banner: String?) -> Bool {
|
|
guard let banner, banner.hasPrefix("SSH-2.0-") else { return false }
|
|
let software = banner.dropFirst("SSH-2.0-".count).split(whereSeparator: \.isWhitespace).first ?? ""
|
|
return software == "Tailscale" || software.hasPrefix("Tailscale_")
|
|
}
|
|
|
|
static func query(
|
|
username: String, session: OpaquePointer, exchange: SSHKeyboardInteractiveExchange?
|
|
) throws -> Step {
|
|
let blocking = libssh2_session_get_blocking(session)
|
|
let readTimeout = libssh2_session_get_read_timeout(session)
|
|
libssh2_session_set_blocking(session, 0)
|
|
// The application deadline owns approval expiry, not the default 60-second packet timeout.
|
|
libssh2_session_set_read_timeout(session, max(readTimeout, 601))
|
|
defer {
|
|
libssh2_session_set_blocking(session, blocking)
|
|
libssh2_session_set_read_timeout(session, readTimeout)
|
|
}
|
|
return try run(exchange: exchange) {
|
|
let methods = username.withCString { libssh2_userauth_list(session, $0, UInt32(username.utf8.count)) }
|
|
if libssh2_userauth_authenticated(session) != 0 { return .authenticated }
|
|
if let methods {
|
|
return .methods(Set(String(cString: methods).split(separator: ",").map(String.init)))
|
|
}
|
|
// Reading a missing banner changes last_errno, so capture the authentication result first.
|
|
let code = libssh2_session_last_errno(session)
|
|
guard code == LIBSSH2_ERROR_EAGAIN else {
|
|
throw failure(code: code)
|
|
}
|
|
var banner: UnsafeMutablePointer<CChar>?
|
|
_ = libssh2_userauth_banner(session, &banner)
|
|
return .pending(banner.map { String(cString: $0) })
|
|
}
|
|
}
|
|
|
|
static func run(
|
|
exchange: SSHKeyboardInteractiveExchange?,
|
|
timeout: Duration = .seconds(600),
|
|
now: () -> ContinuousClock.Instant = { .now },
|
|
pause: () -> Void = { Thread.sleep(forTimeInterval: 0.05) },
|
|
step: () throws -> Step
|
|
) throws -> Step {
|
|
let deadline = now().advanced(by: timeout)
|
|
var presentedBanners: Set<String> = []
|
|
defer { exchange?.endTailscaleApproval() }
|
|
while true {
|
|
try exchange?.checkTailscaleApproval()
|
|
guard now() < deadline else { throw TailscaleConfigurationError.approvalTimedOut }
|
|
let result = try step()
|
|
try exchange?.checkTailscaleApproval()
|
|
switch result {
|
|
case .authenticated, .methods:
|
|
return result
|
|
case .pending(let banner):
|
|
if let banner, !banner.isEmpty, presentedBanners.insert(banner).inserted {
|
|
guard let exchange else { throw SSHInteractionRequired() }
|
|
try exchange.presentTailscaleApproval(banner)
|
|
}
|
|
pause()
|
|
}
|
|
}
|
|
}
|
|
|
|
static func failure(code: Int32) -> TermindSSHError {
|
|
// Authentication banners may contain one-time approval URLs; keep them out of errors and logs.
|
|
.authenticationFailed(String(localized: "Tailscale SSH authentication failed.") + " [libssh2 \(code)]")
|
|
}
|
|
}
|