Add threaded session backend architecture with real SSH backend

This commit is contained in:
Keith Smith
2026-03-01 09:37:34 -07:00
parent 582c57bc5f
commit 6a4bcb75eb
14 changed files with 1083 additions and 73 deletions

419
src/ssh_session_backend.cpp Normal file
View File

@@ -0,0 +1,419 @@
#include "ssh_session_backend.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QProcessEnvironment>
#include <QTemporaryFile>
#include <QTextStream>
namespace {
QString escapeForShellSingleQuotes(const QString& value)
{
QString escaped = value;
escaped.replace(QStringLiteral("'"), QStringLiteral("'\"'\"'"));
return escaped;
}
QString escapedForWindowsEcho(const QString& value)
{
QString escaped = value;
escaped.replace(QStringLiteral("^"), QStringLiteral("^^"));
escaped.replace(QStringLiteral("&"), QStringLiteral("^&"));
escaped.replace(QStringLiteral("|"), QStringLiteral("^|"));
escaped.replace(QStringLiteral("<"), QStringLiteral("^<"));
escaped.replace(QStringLiteral(">"), QStringLiteral("^>"));
return escaped;
}
}
SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
: SessionBackend(profile, parent),
m_process(new QProcess(this)),
m_connectedProbeTimer(new QTimer(this)),
m_state(SessionState::Disconnected),
m_userInitiatedDisconnect(false),
m_reconnectPending(false),
m_askPassScript(nullptr)
{
m_connectedProbeTimer->setSingleShot(true);
connect(m_process, &QProcess::started, this, &SshSessionBackend::onProcessStarted);
connect(m_process,
&QProcess::errorOccurred,
this,
&SshSessionBackend::onProcessErrorOccurred);
connect(m_process,
qOverload<int, QProcess::ExitStatus>(&QProcess::finished),
this,
&SshSessionBackend::onProcessFinished);
connect(m_process,
&QProcess::readyReadStandardError,
this,
&SshSessionBackend::onReadyReadStandardError);
connect(m_connectedProbeTimer,
&QTimer::timeout,
this,
&SshSessionBackend::onConnectedProbeTimeout);
}
SshSessionBackend::~SshSessionBackend()
{
if (m_process->state() != QProcess::NotRunning) {
m_process->kill();
m_process->waitForFinished(500);
}
cleanupAskPassScript();
}
void SshSessionBackend::connectSession(const SessionConnectOptions& options)
{
if (m_state == SessionState::Connected || m_state == SessionState::Connecting) {
emit eventLogged(QStringLiteral("Connect skipped: session is already active."));
return;
}
m_userInitiatedDisconnect = false;
m_reconnectPending = false;
m_lastRawError.clear();
if (!startSshProcess(options)) {
return;
}
setState(SessionState::Connecting, QStringLiteral("Connecting to SSH endpoint..."));
emit eventLogged(QStringLiteral("Launching ssh client."));
}
void SshSessionBackend::disconnectSession()
{
if (m_process->state() == QProcess::NotRunning) {
if (m_state != SessionState::Disconnected) {
setState(SessionState::Disconnected, QStringLiteral("Session is disconnected."));
}
return;
}
m_userInitiatedDisconnect = true;
emit eventLogged(QStringLiteral("Disconnect requested."));
m_connectedProbeTimer->stop();
m_process->terminate();
QTimer::singleShot(1500,
this,
[this]() {
if (m_process->state() != QProcess::NotRunning) {
emit eventLogged(QStringLiteral("Force-stopping ssh process."));
m_process->kill();
}
});
}
void SshSessionBackend::reconnectSession(const SessionConnectOptions& options)
{
emit eventLogged(QStringLiteral("Reconnect requested."));
if (m_process->state() == QProcess::NotRunning) {
connectSession(options);
return;
}
m_reconnectPending = true;
m_reconnectOptions = options;
m_userInitiatedDisconnect = true;
m_process->terminate();
}
void SshSessionBackend::onProcessStarted()
{
emit eventLogged(QStringLiteral("ssh process started."));
m_connectedProbeTimer->start(1200);
}
void SshSessionBackend::onProcessErrorOccurred(QProcess::ProcessError)
{
const QString rawError = m_process->errorString();
if (!rawError.isEmpty()) {
m_lastRawError += rawError + QLatin1Char('\n');
}
if (m_state == SessionState::Connecting) {
const QString display = mapSshError(m_lastRawError);
setState(SessionState::Failed, display);
emit connectionError(display, m_lastRawError.trimmed());
}
}
void SshSessionBackend::onProcessFinished(int exitCode, QProcess::ExitStatus)
{
m_connectedProbeTimer->stop();
cleanupAskPassScript();
if (m_reconnectPending) {
m_reconnectPending = false;
SessionConnectOptions options = m_reconnectOptions;
setState(SessionState::Disconnected, QStringLiteral("Reconnecting..."));
QTimer::singleShot(0, this, [this, options]() { connectSession(options); });
return;
}
if (m_userInitiatedDisconnect) {
m_userInitiatedDisconnect = false;
setState(SessionState::Disconnected, QStringLiteral("Session disconnected."));
emit eventLogged(QStringLiteral("ssh process exited after disconnect request."));
return;
}
if (m_state == SessionState::Connecting || exitCode != 0) {
QString rawError = m_lastRawError.trimmed();
if (rawError.isEmpty()) {
rawError = QStringLiteral("ssh exited with code %1").arg(exitCode);
}
const QString display = mapSshError(rawError);
setState(SessionState::Failed, display);
emit connectionError(display, rawError);
return;
}
setState(SessionState::Disconnected, QStringLiteral("SSH session ended."));
}
void SshSessionBackend::onReadyReadStandardError()
{
const QString chunk = QString::fromUtf8(m_process->readAllStandardError());
if (chunk.isEmpty()) {
return;
}
m_lastRawError += chunk;
const QStringList lines = chunk.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
for (const QString& line : lines) {
emit eventLogged(line.trimmed());
}
}
void SshSessionBackend::onConnectedProbeTimeout()
{
if (m_state != SessionState::Connecting) {
return;
}
if (m_process->state() == QProcess::Running) {
setState(SessionState::Connected, QStringLiteral("SSH session established."));
}
}
void SshSessionBackend::setState(SessionState state, const QString& message)
{
m_state = state;
emit stateChanged(state, message);
emit eventLogged(message);
}
bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
{
const Profile& p = profile();
if (p.host.trimmed().isEmpty()) {
const QString message = QStringLiteral("Host is required for SSH connections.");
setState(SessionState::Failed, message);
emit connectionError(message, message);
return false;
}
if (p.port < 1 || p.port > 65535) {
const QString message = QStringLiteral("Port must be between 1 and 65535.");
setState(SessionState::Failed, message);
emit connectionError(message, message);
return false;
}
cleanupAskPassScript();
QStringList args;
args << QStringLiteral("-N") << QStringLiteral("-T") << QStringLiteral("-p")
<< QString::number(p.port) << QStringLiteral("-o")
<< QStringLiteral("ConnectTimeout=12") << QStringLiteral("-o")
<< QStringLiteral("ServerAliveInterval=20") << QStringLiteral("-o")
<< QStringLiteral("ServerAliveCountMax=2");
const QString policy = options.knownHostsPolicy.trimmed().isEmpty()
? p.knownHostsPolicy.trimmed()
: options.knownHostsPolicy.trimmed();
if (policy.compare(QStringLiteral("Ignore"), Qt::CaseInsensitive) == 0) {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=no")
<< QStringLiteral("-o")
<< QStringLiteral("UserKnownHostsFile=%1").arg(knownHostsFileForNullDevice());
} else if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=accept-new");
} else {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=yes");
}
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
if (p.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
if (options.password.isEmpty()) {
const QString message = QStringLiteral("Password is required for password authentication.");
setState(SessionState::Failed, message);
emit connectionError(message, message);
return false;
}
args << QStringLiteral("-o") << QStringLiteral("PreferredAuthentications=password")
<< QStringLiteral("-o") << QStringLiteral("PubkeyAuthentication=no")
<< QStringLiteral("-o") << QStringLiteral("NumberOfPasswordPrompts=1");
QString askPassError;
if (!configureAskPass(options, environment, askPassError)) {
setState(SessionState::Failed, askPassError);
emit connectionError(askPassError, askPassError);
return false;
}
} else if (p.authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
QString keyPath = options.privateKeyPath.trimmed();
if (keyPath.isEmpty()) {
keyPath = p.privateKeyPath.trimmed();
}
if (keyPath.isEmpty()) {
const QString message = QStringLiteral("Private key path is required.");
setState(SessionState::Failed, message);
emit connectionError(message, message);
return false;
}
if (!QFileInfo::exists(keyPath)) {
const QString message = QStringLiteral("Private key file does not exist: %1")
.arg(keyPath);
setState(SessionState::Failed, message);
emit connectionError(message, message);
return false;
}
args << QStringLiteral("-i") << keyPath << QStringLiteral("-o")
<< QStringLiteral("PreferredAuthentications=publickey") << QStringLiteral("-o")
<< QStringLiteral("PasswordAuthentication=no");
}
const QString target = p.username.trimmed().isEmpty()
? p.host.trimmed()
: QStringLiteral("%1@%2").arg(p.username.trimmed(), p.host.trimmed());
args << target;
m_process->setProcessEnvironment(environment);
m_process->setProgram(QStringLiteral("ssh"));
m_process->setArguments(args);
m_process->setProcessChannelMode(QProcess::SeparateChannels);
m_process->start();
if (!m_process->waitForStarted(3000)) {
const QString rawError = m_process->errorString();
const QString display = mapSshError(rawError);
setState(SessionState::Failed, display);
emit connectionError(display, rawError);
return false;
}
return true;
}
bool SshSessionBackend::configureAskPass(const SessionConnectOptions& options,
QProcessEnvironment& environment,
QString& error)
{
#ifdef Q_OS_WIN
m_askPassScript = new QTemporaryFile(QDir::tempPath() + QStringLiteral("/orbithub_askpass_XXXXXX.cmd"),
this);
#else
m_askPassScript = new QTemporaryFile(QDir::tempPath() + QStringLiteral("/orbithub_askpass_XXXXXX.sh"),
this);
#endif
if (!m_askPassScript->open()) {
error = QStringLiteral("Failed to create temporary askpass helper script.");
cleanupAskPassScript();
return false;
}
QTextStream out(m_askPassScript);
#ifdef Q_OS_WIN
out << "@echo off\r\n";
out << "echo " << escapedForWindowsEcho(options.password) << "\r\n";
#else
out << "#!/bin/sh\n";
out << "printf '%s\\n' '" << escapeForShellSingleQuotes(options.password) << "'\n";
#endif
out.flush();
m_askPassScript->flush();
m_askPassScript->close();
#ifndef Q_OS_WIN
QFile::setPermissions(m_askPassScript->fileName(),
QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner);
#endif
environment.insert(QStringLiteral("SSH_ASKPASS"), m_askPassScript->fileName());
environment.insert(QStringLiteral("SSH_ASKPASS_REQUIRE"), QStringLiteral("force"));
if (!environment.contains(QStringLiteral("DISPLAY"))) {
environment.insert(QStringLiteral("DISPLAY"), QStringLiteral(":0"));
}
return true;
}
void SshSessionBackend::cleanupAskPassScript()
{
if (m_askPassScript != nullptr) {
delete m_askPassScript;
m_askPassScript = nullptr;
}
}
QString SshSessionBackend::mapSshError(const QString& rawError) const
{
const QString raw = rawError.trimmed();
if (raw.contains(QStringLiteral("Permission denied"), Qt::CaseInsensitive)) {
return QStringLiteral("Authentication failed. Check username and credentials.");
}
if (raw.contains(QStringLiteral("Host key verification failed"), Qt::CaseInsensitive)) {
return QStringLiteral("Host key verification failed.");
}
if (raw.contains(QStringLiteral("Could not resolve hostname"), Qt::CaseInsensitive)) {
return QStringLiteral("Host could not be resolved.");
}
if (raw.contains(QStringLiteral("Connection timed out"), Qt::CaseInsensitive)
|| raw.contains(QStringLiteral("Operation timed out"), Qt::CaseInsensitive)) {
return QStringLiteral("Connection timed out.");
}
if (raw.contains(QStringLiteral("Connection refused"), Qt::CaseInsensitive)) {
return QStringLiteral("Connection refused by remote host.");
}
if (raw.contains(QStringLiteral("No route to host"), Qt::CaseInsensitive)) {
return QStringLiteral("No route to host.");
}
if (raw.contains(QStringLiteral("Identity file"), Qt::CaseInsensitive)
&& raw.contains(QStringLiteral("not accessible"), Qt::CaseInsensitive)) {
return QStringLiteral("Private key file is not accessible.");
}
if (raw.contains(QStringLiteral("No such file or directory"), Qt::CaseInsensitive)) {
return QStringLiteral("Required file was not found.");
}
if (raw.isEmpty()) {
return QStringLiteral("SSH connection failed for an unknown reason.");
}
return QStringLiteral("SSH connection failed.");
}
QString SshSessionBackend::knownHostsFileForNullDevice() const
{
#ifdef Q_OS_WIN
return QStringLiteral("NUL");
#else
return QStringLiteral("/dev/null");
#endif
}