Files
Termind/Frameworks/TermindRemoteInfrastructure/SSH/SSHTailscaleAuthentication.swift
T
Kaede Akino ea099fc5a6 fix(ssh): support Tailscale approval on ordinary connections
Detect Tailscale SSH by server identification and authenticate with the configured username without requiring credentials or Pro. Keep browser approval concurrent with transport polling and dismiss prompts when authentication finishes. Preserve managed Tailnet access limits and add loopback regression coverage.
2026-10-17 06:31:55 +08:00

81 lines
3.4 KiB
Swift

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