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

View File

@@ -22,8 +22,17 @@ add_executable(orbithub
src/profile_repository.h
src/profiles_window.cpp
src/profiles_window.h
src/session_backend.h
src/session_backend_factory.cpp
src/session_backend_factory.h
src/session_tab.cpp
src/session_tab.h
src/session_window.cpp
src/session_window.h
src/ssh_session_backend.cpp
src/ssh_session_backend.h
src/unsupported_session_backend.cpp
src/unsupported_session_backend.h
)
target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)

View File

@@ -30,6 +30,10 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile)
query.addBindValue(profile.username.trimmed());
query.addBindValue(profile.protocol.trimmed());
query.addBindValue(profile.authMode.trimmed());
query.addBindValue(profile.privateKeyPath.trimmed());
query.addBindValue(profile.knownHostsPolicy.trimmed().isEmpty()
? QStringLiteral("Strict")
: profile.knownHostsPolicy.trimmed());
}
Profile profileFromQuery(const QSqlQuery& query)
@@ -42,6 +46,11 @@ Profile profileFromQuery(const QSqlQuery& query)
profile.username = query.value(4).toString();
profile.protocol = query.value(5).toString();
profile.authMode = query.value(6).toString();
profile.privateKeyPath = query.value(7).toString();
profile.knownHostsPolicy = query.value(8).toString();
if (profile.knownHostsPolicy.isEmpty()) {
profile.knownHostsPolicy = QStringLiteral("Strict");
}
return profile;
}
@@ -93,12 +102,12 @@ std::vector<Profile> ProfileRepository::listProfiles(const QString& searchQuery)
QSqlQuery query(QSqlDatabase::database(m_connectionName));
if (searchQuery.trimmed().isEmpty()) {
query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode "
"SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy "
"FROM profiles "
"ORDER BY lower(name) ASC, id ASC"));
} else {
query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode "
"SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy "
"FROM profiles "
"WHERE lower(name) LIKE lower(?) OR lower(host) LIKE lower(?) "
"ORDER BY lower(name) ASC, id ASC"));
@@ -129,7 +138,7 @@ std::optional<Profile> ProfileRepository::getProfile(qint64 id) const
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral(
"SELECT id, name, host, port, username, protocol, auth_mode "
"SELECT id, name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy "
"FROM profiles WHERE id = ?"));
query.addBindValue(id);
@@ -160,8 +169,8 @@ std::optional<Profile> ProfileRepository::createProfile(const Profile& profile)
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral(
"INSERT INTO profiles(name, host, port, username, protocol, auth_mode) "
"VALUES (?, ?, ?, ?, ?, ?)"));
"INSERT INTO profiles(name, host, port, username, protocol, auth_mode, private_key_path, known_hosts_policy) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)"));
bindProfileFields(query, profile);
if (!query.exec()) {
@@ -190,7 +199,7 @@ bool ProfileRepository::updateProfile(const Profile& profile) const
QSqlQuery query(QSqlDatabase::database(m_connectionName));
query.prepare(QStringLiteral(
"UPDATE profiles "
"SET name = ?, host = ?, port = ?, username = ?, protocol = ?, auth_mode = ? "
"SET name = ?, host = ?, port = ?, username = ?, protocol = ?, auth_mode = ?, private_key_path = ?, known_hosts_policy = ? "
"WHERE id = ?"));
bindProfileFields(query, profile);
query.addBindValue(profile.id);
@@ -242,7 +251,9 @@ bool ProfileRepository::initializeDatabase()
"port INTEGER NOT NULL DEFAULT 22,"
"username TEXT NOT NULL DEFAULT '',"
"protocol TEXT NOT NULL DEFAULT 'SSH',"
"auth_mode TEXT NOT NULL DEFAULT 'Password'"
"auth_mode TEXT NOT NULL DEFAULT 'Password',"
"private_key_path TEXT NOT NULL DEFAULT '',"
"known_hosts_policy TEXT NOT NULL DEFAULT 'Strict'"
")"));
if (!created) {
@@ -286,7 +297,9 @@ bool ProfileRepository::ensureProfileSchema() const
{QStringLiteral("port"), QStringLiteral("ALTER TABLE profiles ADD COLUMN port INTEGER NOT NULL DEFAULT 22")},
{QStringLiteral("username"), QStringLiteral("ALTER TABLE profiles ADD COLUMN username TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("protocol"), QStringLiteral("ALTER TABLE profiles ADD COLUMN protocol TEXT NOT NULL DEFAULT 'SSH'")},
{QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")}};
{QStringLiteral("auth_mode"), QStringLiteral("ALTER TABLE profiles ADD COLUMN auth_mode TEXT NOT NULL DEFAULT 'Password'")},
{QStringLiteral("private_key_path"), QStringLiteral("ALTER TABLE profiles ADD COLUMN private_key_path TEXT NOT NULL DEFAULT ''")},
{QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Strict'")}};
for (const ColumnDef& column : required) {
if (columns.contains(column.name)) {

View File

@@ -16,6 +16,8 @@ struct Profile
QString username;
QString protocol = QStringLiteral("SSH");
QString authMode = QStringLiteral("Password");
QString privateKeyPath;
QString knownHostsPolicy = QStringLiteral("Strict");
};
class ProfileRepository

57
src/session_backend.h Normal file
View File

@@ -0,0 +1,57 @@
#ifndef ORBITHUB_SESSION_BACKEND_H
#define ORBITHUB_SESSION_BACKEND_H
#include "profile_repository.h"
#include <QObject>
#include <QString>
class SessionConnectOptions
{
public:
QString password;
QString privateKeyPath;
QString knownHostsPolicy;
};
enum class SessionState {
Disconnected,
Connecting,
Connected,
Failed,
};
class SessionBackend : public QObject
{
Q_OBJECT
public:
explicit SessionBackend(const Profile& profile, QObject* parent = nullptr)
: QObject(parent), m_profile(profile)
{
}
~SessionBackend() override = default;
const Profile& profile() const
{
return m_profile;
}
public slots:
virtual void connectSession(const SessionConnectOptions& options) = 0;
virtual void disconnectSession() = 0;
virtual void reconnectSession(const SessionConnectOptions& options) = 0;
signals:
void stateChanged(SessionState state, const QString& message);
void eventLogged(const QString& message);
void connectionError(const QString& displayMessage, const QString& rawMessage);
private:
Profile m_profile;
};
Q_DECLARE_METATYPE(SessionConnectOptions)
Q_DECLARE_METATYPE(SessionState)
#endif

View File

@@ -0,0 +1,14 @@
#include "session_backend_factory.h"
#include "session_backend.h"
#include "ssh_session_backend.h"
#include "unsupported_session_backend.h"
std::unique_ptr<SessionBackend> createSessionBackend(const Profile& profile)
{
if (profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) == 0) {
return std::make_unique<SshSessionBackend>(profile);
}
return std::make_unique<UnsupportedSessionBackend>(profile);
}

View File

@@ -0,0 +1,12 @@
#ifndef ORBITHUB_SESSION_BACKEND_FACTORY_H
#define ORBITHUB_SESSION_BACKEND_FACTORY_H
#include "profile_repository.h"
#include <memory>
class SessionBackend;
std::unique_ptr<SessionBackend> createSessionBackend(const Profile& profile);
#endif

363
src/session_tab.cpp Normal file
View File

@@ -0,0 +1,363 @@
#include "session_tab.h"
#include "session_backend_factory.h"
#include <QClipboard>
#include <QDateTime>
#include <QFileDialog>
#include <QFileInfo>
#include <QFont>
#include <QGuiApplication>
#include <QHBoxLayout>
#include <QInputDialog>
#include <QLabel>
#include <QLineEdit>
#include <QMessageBox>
#include <QPlainTextEdit>
#include <QPushButton>
#include <QThread>
#include <QVBoxLayout>
#include <memory>
SessionTab::SessionTab(const Profile& profile, QWidget* parent)
: QWidget(parent),
m_profile(profile),
m_backendThread(new QThread(this)),
m_backend(nullptr),
m_state(SessionState::Disconnected),
m_statusLabel(nullptr),
m_errorLabel(nullptr),
m_eventLog(nullptr),
m_connectButton(nullptr),
m_disconnectButton(nullptr),
m_reconnectButton(nullptr),
m_copyErrorButton(nullptr)
{
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
qRegisterMetaType<SessionState>("SessionState");
setupUi();
std::unique_ptr<SessionBackend> backend = createSessionBackend(m_profile);
m_backend = backend.release();
m_backend->moveToThread(m_backendThread);
connect(m_backendThread, &QThread::finished, m_backend, &QObject::deleteLater);
connect(this,
&SessionTab::requestConnect,
m_backend,
&SessionBackend::connectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestDisconnect,
m_backend,
&SessionBackend::disconnectSession,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestReconnect,
m_backend,
&SessionBackend::reconnectSession,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::stateChanged,
this,
&SessionTab::onBackendStateChanged,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::eventLogged,
this,
&SessionTab::onBackendEventLogged,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::connectionError,
this,
&SessionTab::onBackendConnectionError,
Qt::QueuedConnection);
m_backendThread->start();
setState(SessionState::Disconnected, QStringLiteral("Ready to connect."));
}
SessionTab::~SessionTab()
{
if (m_backend != nullptr && m_backendThread->isRunning()) {
QMetaObject::invokeMethod(m_backend, "disconnectSession", Qt::BlockingQueuedConnection);
}
m_backendThread->quit();
m_backendThread->wait(2000);
}
QString SessionTab::tabTitle() const
{
return QStringLiteral("%1 (%2)").arg(m_profile.name, stateSuffix());
}
void SessionTab::onConnectClicked()
{
if (!validateProfileForConnect()) {
return;
}
const std::optional<SessionConnectOptions> options = buildConnectOptions();
if (!options.has_value()) {
return;
}
emit requestConnect(options.value());
}
void SessionTab::onDisconnectClicked()
{
emit requestDisconnect();
}
void SessionTab::onReconnectClicked()
{
if (!validateProfileForConnect()) {
return;
}
const std::optional<SessionConnectOptions> options = buildConnectOptions();
if (!options.has_value()) {
return;
}
emit requestReconnect(options.value());
}
void SessionTab::onCopyErrorClicked()
{
if (m_lastError.isEmpty()) {
return;
}
QGuiApplication::clipboard()->setText(m_lastError);
appendEvent(QStringLiteral("Copied last error to clipboard."));
}
void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
{
setState(state, message);
}
void SessionTab::onBackendEventLogged(const QString& message)
{
appendEvent(message);
}
void SessionTab::onBackendConnectionError(const QString& displayMessage, const QString& rawMessage)
{
m_lastError = rawMessage.isEmpty() ? displayMessage : rawMessage;
m_errorLabel->setText(QStringLiteral("Last Error: %1").arg(displayMessage));
m_copyErrorButton->setEnabled(true);
}
void SessionTab::setupUi()
{
auto* rootLayout = new QVBoxLayout(this);
auto* profileLabel = new QLabel(QStringLiteral("Profile: %1").arg(m_profile.name), this);
auto* endpointLabel = new QLabel(
QStringLiteral("Endpoint: %1://%2@%3:%4")
.arg(m_profile.protocol,
m_profile.username.isEmpty() ? QStringLiteral("<none>") : m_profile.username,
m_profile.host,
QString::number(m_profile.port)),
this);
auto* authLabel = new QLabel(QStringLiteral("Auth: %1").arg(m_profile.authMode), this);
m_statusLabel = new QLabel(this);
m_errorLabel = new QLabel(QStringLiteral("Last Error: None"), this);
m_errorLabel->setWordWrap(true);
auto* actionRow = new QHBoxLayout();
m_connectButton = new QPushButton(QStringLiteral("Connect"), this);
m_disconnectButton = new QPushButton(QStringLiteral("Disconnect"), this);
m_reconnectButton = new QPushButton(QStringLiteral("Reconnect"), this);
m_copyErrorButton = new QPushButton(QStringLiteral("Copy Error"), this);
actionRow->addWidget(m_connectButton);
actionRow->addWidget(m_disconnectButton);
actionRow->addWidget(m_reconnectButton);
actionRow->addWidget(m_copyErrorButton);
actionRow->addStretch();
auto* surfaceLabel = new QLabel(QStringLiteral("OrbitHub Native Surface"), this);
QFont surfaceFont = surfaceLabel->font();
surfaceFont.setPointSize(surfaceFont.pointSize() + 6);
surfaceFont.setBold(true);
surfaceLabel->setFont(surfaceFont);
surfaceLabel->setAlignment(Qt::AlignCenter);
surfaceLabel->setMinimumHeight(180);
surfaceLabel->setStyleSheet(
QStringLiteral("border: 1px solid #8a8a8a; background-color: #f5f5f5;"));
m_eventLog = new QPlainTextEdit(this);
m_eventLog->setReadOnly(true);
m_eventLog->setPlaceholderText(QStringLiteral("Session event log..."));
m_eventLog->setMinimumHeight(180);
rootLayout->addWidget(profileLabel);
rootLayout->addWidget(endpointLabel);
rootLayout->addWidget(authLabel);
rootLayout->addWidget(m_statusLabel);
rootLayout->addWidget(m_errorLabel);
rootLayout->addLayout(actionRow);
rootLayout->addWidget(surfaceLabel);
rootLayout->addWidget(m_eventLog, 1);
connect(m_connectButton, &QPushButton::clicked, this, &SessionTab::onConnectClicked);
connect(m_disconnectButton, &QPushButton::clicked, this, &SessionTab::onDisconnectClicked);
connect(m_reconnectButton, &QPushButton::clicked, this, &SessionTab::onReconnectClicked);
connect(m_copyErrorButton, &QPushButton::clicked, this, &SessionTab::onCopyErrorClicked);
}
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
{
SessionConnectOptions options;
options.knownHostsPolicy = m_profile.knownHostsPolicy;
if (m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) != 0) {
return options;
}
if (m_profile.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
bool accepted = false;
const QString password = QInputDialog::getText(this,
QStringLiteral("SSH Password"),
QStringLiteral("Password for %1@%2:")
.arg(m_profile.username, m_profile.host),
QLineEdit::Password,
QString(),
&accepted);
if (!accepted) {
return std::nullopt;
}
if (password.isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Password is required for password authentication."));
return std::nullopt;
}
options.password = password;
return options;
}
QString keyPath = m_profile.privateKeyPath.trimmed();
if (keyPath.isEmpty()) {
keyPath = QFileDialog::getOpenFileName(this,
QStringLiteral("Select Private Key"),
QString(),
QStringLiteral("All Files (*)"));
if (keyPath.isEmpty()) {
return std::nullopt;
}
}
if (!QFileInfo::exists(keyPath)) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("Private key file not found: %1").arg(keyPath));
return std::nullopt;
}
options.privateKeyPath = keyPath;
return options;
}
bool SessionTab::validateProfileForConnect()
{
if (m_profile.protocol.compare(QStringLiteral("SSH"), Qt::CaseInsensitive) != 0) {
return true;
}
if (m_profile.host.trimmed().isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("SSH host is required."));
return false;
}
if (m_profile.username.trimmed().isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("SSH username is required."));
return false;
}
if (m_profile.port < 1 || m_profile.port > 65535) {
QMessageBox::warning(this,
QStringLiteral("Connect"),
QStringLiteral("SSH port must be between 1 and 65535."));
return false;
}
return true;
}
void SessionTab::appendEvent(const QString& message)
{
const QString timestamp = QDateTime::currentDateTime().toString(QStringLiteral("yyyy-MM-dd HH:mm:ss"));
m_eventLog->appendPlainText(QStringLiteral("[%1] %2").arg(timestamp, message));
}
void SessionTab::setState(SessionState state, const QString& message)
{
m_state = state;
QString style;
switch (state) {
case SessionState::Disconnected:
style = QStringLiteral("border: 1px solid #8a8a8a; background-color: #efefef; padding: 6px;");
break;
case SessionState::Connecting:
style = QStringLiteral("border: 1px solid #a5a5a5; background-color: #fff3cd; padding: 6px;");
break;
case SessionState::Connected:
style = QStringLiteral("border: 1px solid #3c763d; background-color: #dff0d8; padding: 6px;");
break;
case SessionState::Failed:
style = QStringLiteral("border: 1px solid #a94442; background-color: #f2dede; padding: 6px;");
break;
}
m_statusLabel->setStyleSheet(style);
m_statusLabel->setText(QStringLiteral("Connection State: %1").arg(message));
refreshActionButtons();
emit tabTitleChanged(tabTitle());
}
QString SessionTab::stateSuffix() const
{
switch (m_state) {
case SessionState::Disconnected:
return QStringLiteral("Disconnected");
case SessionState::Connecting:
return QStringLiteral("Connecting");
case SessionState::Connected:
return QStringLiteral("Connected");
case SessionState::Failed:
return QStringLiteral("Failed");
}
return QStringLiteral("Unknown");
}
void SessionTab::refreshActionButtons()
{
m_connectButton->setEnabled(m_state == SessionState::Disconnected
|| m_state == SessionState::Failed);
m_disconnectButton->setEnabled(m_state == SessionState::Connected
|| m_state == SessionState::Connecting);
m_reconnectButton->setEnabled(m_state == SessionState::Connected
|| m_state == SessionState::Failed
|| m_state == SessionState::Disconnected);
m_copyErrorButton->setEnabled(!m_lastError.isEmpty());
}

67
src/session_tab.h Normal file
View File

@@ -0,0 +1,67 @@
#ifndef ORBITHUB_SESSION_TAB_H
#define ORBITHUB_SESSION_TAB_H
#include "profile_repository.h"
#include "session_backend.h"
#include <QWidget>
#include <optional>
class QLabel;
class QPlainTextEdit;
class QPushButton;
class QThread;
class SessionBackend;
class SessionTab : public QWidget
{
Q_OBJECT
public:
explicit SessionTab(const Profile& profile, QWidget* parent = nullptr);
~SessionTab() override;
QString tabTitle() const;
signals:
void tabTitleChanged(const QString& title);
void requestConnect(const SessionConnectOptions& options);
void requestDisconnect();
void requestReconnect(const SessionConnectOptions& options);
private slots:
void onConnectClicked();
void onDisconnectClicked();
void onReconnectClicked();
void onCopyErrorClicked();
void onBackendStateChanged(SessionState state, const QString& message);
void onBackendEventLogged(const QString& message);
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
private:
Profile m_profile;
QThread* m_backendThread;
SessionBackend* m_backend;
SessionState m_state;
QString m_lastError;
QLabel* m_statusLabel;
QLabel* m_errorLabel;
QPlainTextEdit* m_eventLog;
QPushButton* m_connectButton;
QPushButton* m_disconnectButton;
QPushButton* m_reconnectButton;
QPushButton* m_copyErrorButton;
void setupUi();
std::optional<SessionConnectOptions> buildConnectOptions();
bool validateProfileForConnect();
void appendEvent(const QString& message);
void setState(SessionState state, const QString& message);
QString stateSuffix() const;
void refreshActionButtons();
};
#endif

View File

@@ -1,17 +1,14 @@
#include "session_window.h"
#include <QFont>
#include <QLabel>
#include "session_tab.h"
#include <QTabWidget>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
: QMainWindow(parent), m_tabs(new QTabWidget(this))
{
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
resize(980, 680);
resize(1080, 760);
m_tabs->setTabsClosable(true);
connect(m_tabs,
@@ -32,64 +29,22 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
void SessionWindow::addSessionTab(const Profile& profile)
{
auto* container = new QWidget(this);
auto* layout = new QVBoxLayout(container);
auto* tab = new SessionTab(profile, this);
const int index = m_tabs->addTab(tab, tab->tabTitle());
m_tabs->setCurrentIndex(index);
auto* profileLabel = new QLabel(QStringLiteral("Profile: %1").arg(profile.name), container);
auto* endpointLabel = new QLabel(
QStringLiteral("Endpoint: %1://%2@%3:%4")
.arg(profile.protocol,
profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username,
profile.host,
QString::number(profile.port)),
container);
auto* authModeLabel = new QLabel(QStringLiteral("Auth Mode: %1").arg(profile.authMode), container);
auto* statusLabel = new QLabel(QStringLiteral("Connection State: Connecting"), container);
auto* surfaceLabel = new QLabel(QStringLiteral("OrbitHub Native Surface"), container);
QFont profileFont = profileLabel->font();
profileFont.setBold(true);
profileLabel->setFont(profileFont);
QFont surfaceFont = surfaceLabel->font();
surfaceFont.setPointSize(surfaceFont.pointSize() + 6);
surfaceFont.setBold(true);
surfaceLabel->setFont(surfaceFont);
statusLabel->setStyleSheet(
QStringLiteral("border: 1px solid #a5a5a5; background-color: #fff3cd; padding: 6px;"));
surfaceLabel->setAlignment(Qt::AlignCenter);
surfaceLabel->setMinimumHeight(220);
surfaceLabel->setStyleSheet(
QStringLiteral("border: 1px solid #8a8a8a; background-color: #f5f5f5;"));
layout->addWidget(profileLabel);
layout->addWidget(endpointLabel);
layout->addWidget(authModeLabel);
layout->addWidget(statusLabel);
layout->addWidget(surfaceLabel, 1);
const int tabIndex = m_tabs->addTab(container, QStringLiteral("%1 (Connecting)").arg(profile.name));
QTimer::singleShot(900,
this,
[this, tabIndex, statusLabel, profile]() {
const bool shouldFail = profile.host.contains(QStringLiteral("fail"),
Qt::CaseInsensitive);
if (shouldFail) {
statusLabel->setText(QStringLiteral("Connection State: Failed"));
statusLabel->setStyleSheet(QStringLiteral(
"border: 1px solid #a94442; background-color: #f2dede; padding: 6px;"));
m_tabs->setTabText(tabIndex,
QStringLiteral("%1 (Failed)").arg(profile.name));
return;
}
statusLabel->setText(QStringLiteral("Connection State: Connected"));
statusLabel->setStyleSheet(QStringLiteral(
"border: 1px solid #3c763d; background-color: #dff0d8; padding: 6px;"));
m_tabs->setTabText(tabIndex,
QStringLiteral("%1 (Connected)").arg(profile.name));
});
connect(tab,
&SessionTab::tabTitleChanged,
this,
[this, tab](const QString& title) { updateTabTitle(tab, title); });
}
void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
{
for (int i = 0; i < m_tabs->count(); ++i) {
if (m_tabs->widget(i) == tab) {
m_tabs->setTabText(i, title);
return;
}
}
}

View File

@@ -6,6 +6,7 @@
#include <QMainWindow>
class QTabWidget;
class SessionTab;
class SessionWindow : public QMainWindow
{
@@ -18,6 +19,7 @@ private:
QTabWidget* m_tabs;
void addSessionTab(const Profile& profile);
void updateTabTitle(SessionTab* tab, const QString& title);
};
#endif

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
}

52
src/ssh_session_backend.h Normal file
View File

@@ -0,0 +1,52 @@
#ifndef ORBITHUB_SSH_SESSION_BACKEND_H
#define ORBITHUB_SSH_SESSION_BACKEND_H
#include "session_backend.h"
#include <QProcess>
#include <QString>
#include <QTimer>
class QTemporaryFile;
class SshSessionBackend : public SessionBackend
{
Q_OBJECT
public:
explicit SshSessionBackend(const Profile& profile, QObject* parent = nullptr);
~SshSessionBackend() override;
public slots:
void connectSession(const SessionConnectOptions& options) override;
void disconnectSession() override;
void reconnectSession(const SessionConnectOptions& options) override;
private slots:
void onProcessStarted();
void onProcessErrorOccurred(QProcess::ProcessError error);
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
void onReadyReadStandardError();
void onConnectedProbeTimeout();
private:
QProcess* m_process;
QTimer* m_connectedProbeTimer;
SessionState m_state;
bool m_userInitiatedDisconnect;
bool m_reconnectPending;
SessionConnectOptions m_reconnectOptions;
QString m_lastRawError;
QTemporaryFile* m_askPassScript;
void setState(SessionState state, const QString& message);
bool startSshProcess(const SessionConnectOptions& options);
bool configureAskPass(const SessionConnectOptions& options,
QProcessEnvironment& environment,
QString& error);
void cleanupAskPassScript();
QString mapSshError(const QString& rawError) const;
QString knownHostsFileForNullDevice() const;
};
#endif

View File

@@ -0,0 +1,26 @@
#include "unsupported_session_backend.h"
UnsupportedSessionBackend::UnsupportedSessionBackend(const Profile& profile, QObject* parent)
: SessionBackend(profile, parent)
{
}
void UnsupportedSessionBackend::connectSession(const SessionConnectOptions&)
{
const QString message = QStringLiteral("Protocol '%1' is not implemented yet.")
.arg(profile().protocol);
emit eventLogged(message);
emit stateChanged(SessionState::Failed, message);
emit connectionError(message, message);
}
void UnsupportedSessionBackend::disconnectSession()
{
emit stateChanged(SessionState::Disconnected,
QStringLiteral("No active connection for this protocol."));
}
void UnsupportedSessionBackend::reconnectSession(const SessionConnectOptions& options)
{
connectSession(options);
}

View File

@@ -0,0 +1,19 @@
#ifndef ORBITHUB_UNSUPPORTED_SESSION_BACKEND_H
#define ORBITHUB_UNSUPPORTED_SESSION_BACKEND_H
#include "session_backend.h"
class UnsupportedSessionBackend : public SessionBackend
{
Q_OBJECT
public:
explicit UnsupportedSessionBackend(const Profile& profile, QObject* parent = nullptr);
public slots:
void connectSession(const SessionConnectOptions& options) override;
void disconnectSession() override;
void reconnectSession(const SessionConnectOptions& options) override;
};
#endif