5 Commits

Author SHA1 Message Date
Keith Smith
3c158269bf Fix SSH askpass helper text-file-busy race 2026-03-01 09:42:32 -07:00
Keith Smith
e2a8b874d7 Document Milestone 3 deliverables and SSH prerequisites 2026-03-01 09:37:43 -07:00
Keith Smith
71a2f2e868 Expand profile editor for SSH auth and host policy fields 2026-03-01 09:37:37 -07:00
Keith Smith
6a4bcb75eb Add threaded session backend architecture with real SSH backend 2026-03-01 09:37:34 -07:00
Keith Smith
582c57bc5f Auto-fill standard port when protocol changes 2026-03-01 09:24:31 -07:00
20 changed files with 1235 additions and 80 deletions

View File

@@ -22,8 +22,17 @@ add_executable(orbithub
src/profile_repository.h src/profile_repository.h
src/profiles_window.cpp src/profiles_window.cpp
src/profiles_window.h 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.cpp
src/session_window.h 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) target_link_libraries(orbithub PRIVATE Qt6::Widgets Qt6::Sql)

View File

@@ -8,7 +8,8 @@ Run all commands from the repository root unless noted.
sudo apt update sudo apt update
sudo apt install -y \ sudo apt install -y \
build-essential cmake ninja-build git pkg-config \ build-essential cmake ninja-build git pkg-config \
qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools qt6-base-dev qt6-base-dev-tools qt6-tools-dev qt6-tools-dev-tools \
openssh-client
cmake -S . -B build -G Ninja cmake -S . -B build -G Ninja
cmake --build build cmake --build build
@@ -20,7 +21,7 @@ cmake --build build
```bash ```bash
xcode-select --install xcode-select --install
brew update brew update
brew install cmake ninja pkg-config qt@6 brew install cmake ninja pkg-config qt@6 openssh
cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH="$(brew --prefix qt@6)" cmake -S . -B build -G Ninja -DCMAKE_PREFIX_PATH="$(brew --prefix qt@6)"
cmake --build build cmake --build build
@@ -33,6 +34,7 @@ cmake --build build
winget install -e --id Git.Git winget install -e --id Git.Git
winget install -e --id Kitware.CMake winget install -e --id Kitware.CMake
winget install -e --id Ninja-build.Ninja winget install -e --id Ninja-build.Ninja
Add-WindowsCapability -Online -Name OpenSSH.Client~~~~0.0.1.0
winget install -e --id Microsoft.VisualStudio.2022.BuildTools ` winget install -e --id Microsoft.VisualStudio.2022.BuildTools `
--override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools" --override "--quiet --wait --norestart --add Microsoft.VisualStudio.Workload.VCTools"
``` ```
@@ -53,4 +55,5 @@ cmake --build build
## Notes ## Notes
- OrbitHub currently requires Qt6 Widgets and CMake 3.21+. - OrbitHub currently requires Qt6 Widgets and CMake 3.21+.
- Milestone 3 SSH sessions require an `ssh` client available on `PATH`.
- If Qt is installed in a custom location, pass `-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/<toolchain>` to CMake. - If Qt is installed in a custom location, pass `-DCMAKE_PREFIX_PATH=/path/to/Qt/6.x.x/<toolchain>` to CMake.

View File

@@ -64,3 +64,16 @@ OrbitHub uses a two-window model:
- Connect loads full profile details into session tab - Connect loads full profile details into session tab
- Session lifecycle states in UI (`Connecting`, `Connected`, `Failed`) with non-blocking updates - Session lifecycle states in UI (`Connecting`, `Connected`, `Failed`) with non-blocking updates
- Tag: v0-m2-done - Tag: v0-m2-done
---
## Milestone 3
- Real SSH backend using native `ssh` process (connect, disconnect, reconnect)
- Protocol backend abstraction with worker-thread execution
- RDP/VNC explicitly marked as not implemented in session UX
- Connect-time credential prompts (password/private key path) with no secret storage in DB
- Session tab controls: `Connect`, `Disconnect`, `Reconnect`, `Copy Error`
- Per-session timestamped event log and user-friendly error mapping
- Profile schema extended with `private_key_path` and `known_hosts_policy`
- Tag: v0-m3-done

View File

@@ -43,3 +43,20 @@ Delivered:
Git: Git:
- Tag: `v0-m2-done` - Tag: `v0-m2-done`
## Milestone 3 - Real SSH Backend and Session Controls
Status: Completed
Delivered:
- Backend architecture introduced (`SessionBackend` + protocol-specific implementations)
- Worker-thread backend execution for connection lifecycle operations
- Real SSH process backend (`ssh`) with connect/disconnect/reconnect
- Unsupported protocol backend with explicit not-implemented messaging (RDP/VNC)
- Session tab controls: `Connect`, `Disconnect`, `Reconnect`, `Copy Error`
- Connect-time credential flow (password prompt / private-key path selection)
- Session event log pane with timestamps and user-friendly error mapping
- SQLite profile schema migration for `private_key_path` and `known_hosts_policy`
Git:
- Tag: `v0-m3-done`

View File

@@ -2,13 +2,30 @@
#include <QComboBox> #include <QComboBox>
#include <QDialogButtonBox> #include <QDialogButtonBox>
#include <QFileDialog>
#include <QFormLayout> #include <QFormLayout>
#include <QHBoxLayout>
#include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QMessageBox> #include <QMessageBox>
#include <QPushButton> #include <QPushButton>
#include <QSignalBlocker>
#include <QSpinBox> #include <QSpinBox>
#include <QVBoxLayout> #include <QVBoxLayout>
namespace {
int standardPortForProtocol(const QString& protocol)
{
if (protocol == QStringLiteral("RDP")) {
return 3389;
}
if (protocol == QStringLiteral("VNC")) {
return 5900;
}
return 22; // SSH default
}
}
ProfileDialog::ProfileDialog(QWidget* parent) ProfileDialog::ProfileDialog(QWidget* parent)
: QDialog(parent), : QDialog(parent),
m_nameInput(new QLineEdit(this)), m_nameInput(new QLineEdit(this)),
@@ -16,9 +33,12 @@ ProfileDialog::ProfileDialog(QWidget* parent)
m_portInput(new QSpinBox(this)), m_portInput(new QSpinBox(this)),
m_usernameInput(new QLineEdit(this)), m_usernameInput(new QLineEdit(this)),
m_protocolInput(new QComboBox(this)), m_protocolInput(new QComboBox(this)),
m_authModeInput(new QComboBox(this)) m_authModeInput(new QComboBox(this)),
m_privateKeyPathInput(new QLineEdit(this)),
m_browsePrivateKeyButton(new QPushButton(QStringLiteral("Browse"), this)),
m_knownHostsPolicyInput(new QComboBox(this))
{ {
resize(420, 260); resize(520, 340);
auto* layout = new QVBoxLayout(this); auto* layout = new QVBoxLayout(this);
auto* form = new QFormLayout(); auto* form = new QFormLayout();
@@ -31,6 +51,42 @@ ProfileDialog::ProfileDialog(QWidget* parent)
m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")}); m_protocolInput->addItems({QStringLiteral("SSH"), QStringLiteral("RDP"), QStringLiteral("VNC")});
m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")}); m_authModeInput->addItems({QStringLiteral("Password"), QStringLiteral("Private Key")});
m_knownHostsPolicyInput->addItems(
{QStringLiteral("Strict"), QStringLiteral("Accept New"), QStringLiteral("Ignore")});
m_privateKeyPathInput->setPlaceholderText(QStringLiteral("/home/user/.ssh/id_ed25519"));
auto* privateKeyRow = new QWidget(this);
auto* privateKeyLayout = new QHBoxLayout(privateKeyRow);
privateKeyLayout->setContentsMargins(0, 0, 0, 0);
privateKeyLayout->addWidget(m_privateKeyPathInput, 1);
privateKeyLayout->addWidget(m_browsePrivateKeyButton);
connect(m_browsePrivateKeyButton,
&QPushButton::clicked,
this,
[this]() {
const QString selected = QFileDialog::getOpenFileName(this,
QStringLiteral("Select Private Key"),
QString(),
QStringLiteral("All Files (*)"));
if (!selected.isEmpty()) {
m_privateKeyPathInput->setText(selected);
}
});
connect(m_protocolInput,
&QComboBox::currentTextChanged,
this,
[this](const QString& protocol) {
m_portInput->setValue(standardPortForProtocol(protocol));
refreshAuthFields();
});
connect(m_authModeInput,
&QComboBox::currentTextChanged,
this,
[this](const QString&) { refreshAuthFields(); });
form->addRow(QStringLiteral("Name"), m_nameInput); form->addRow(QStringLiteral("Name"), m_nameInput);
form->addRow(QStringLiteral("Host"), m_hostInput); form->addRow(QStringLiteral("Host"), m_hostInput);
@@ -38,13 +94,23 @@ ProfileDialog::ProfileDialog(QWidget* parent)
form->addRow(QStringLiteral("Username"), m_usernameInput); form->addRow(QStringLiteral("Username"), m_usernameInput);
form->addRow(QStringLiteral("Protocol"), m_protocolInput); form->addRow(QStringLiteral("Protocol"), m_protocolInput);
form->addRow(QStringLiteral("Auth Mode"), m_authModeInput); form->addRow(QStringLiteral("Auth Mode"), m_authModeInput);
form->addRow(QStringLiteral("Private Key"), privateKeyRow);
form->addRow(QStringLiteral("Known Hosts"), m_knownHostsPolicyInput);
auto* note = new QLabel(
QStringLiteral("Passwords are requested at connect time and are not stored."),
this);
note->setWordWrap(true);
auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this); auto* buttons = new QDialogButtonBox(QDialogButtonBox::Ok | QDialogButtonBox::Cancel, this);
connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept); connect(buttons, &QDialogButtonBox::accepted, this, &QDialog::accept);
connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject); connect(buttons, &QDialogButtonBox::rejected, this, &QDialog::reject);
layout->addLayout(form); layout->addLayout(form);
layout->addWidget(note);
layout->addWidget(buttons); layout->addWidget(buttons);
refreshAuthFields();
} }
void ProfileDialog::setDialogTitle(const QString& title) void ProfileDialog::setDialogTitle(const QString& title)
@@ -58,12 +124,22 @@ void ProfileDialog::setProfile(const Profile& profile)
m_hostInput->setText(profile.host); m_hostInput->setText(profile.host);
m_portInput->setValue(profile.port > 0 ? profile.port : 22); m_portInput->setValue(profile.port > 0 ? profile.port : 22);
m_usernameInput->setText(profile.username); m_usernameInput->setText(profile.username);
m_privateKeyPathInput->setText(profile.privateKeyPath);
const int protocolIndex = m_protocolInput->findText(profile.protocol); const int protocolIndex = m_protocolInput->findText(profile.protocol);
{
// Keep stored custom port when loading an existing profile.
const QSignalBlocker blocker(m_protocolInput);
m_protocolInput->setCurrentIndex(protocolIndex >= 0 ? protocolIndex : 0); m_protocolInput->setCurrentIndex(protocolIndex >= 0 ? protocolIndex : 0);
}
const int authModeIndex = m_authModeInput->findText(profile.authMode); const int authModeIndex = m_authModeInput->findText(profile.authMode);
m_authModeInput->setCurrentIndex(authModeIndex >= 0 ? authModeIndex : 0); m_authModeInput->setCurrentIndex(authModeIndex >= 0 ? authModeIndex : 0);
const int knownHostsIndex = m_knownHostsPolicyInput->findText(profile.knownHostsPolicy);
m_knownHostsPolicyInput->setCurrentIndex(knownHostsIndex >= 0 ? knownHostsIndex : 0);
refreshAuthFields();
} }
Profile ProfileDialog::profile() const Profile ProfileDialog::profile() const
@@ -76,6 +152,8 @@ Profile ProfileDialog::profile() const
profile.username = m_usernameInput->text().trimmed(); profile.username = m_usernameInput->text().trimmed();
profile.protocol = m_protocolInput->currentText(); profile.protocol = m_protocolInput->currentText();
profile.authMode = m_authModeInput->currentText(); profile.authMode = m_authModeInput->currentText();
profile.privateKeyPath = m_privateKeyPathInput->text().trimmed();
profile.knownHostsPolicy = m_knownHostsPolicyInput->currentText();
return profile; return profile;
} }
@@ -95,5 +173,24 @@ void ProfileDialog::accept()
return; return;
} }
if (m_protocolInput->currentText() == QStringLiteral("SSH")
&& m_usernameInput->text().trimmed().isEmpty()) {
QMessageBox::warning(this,
QStringLiteral("Validation Error"),
QStringLiteral("Username is required for SSH profiles."));
return;
}
QDialog::accept(); QDialog::accept();
} }
void ProfileDialog::refreshAuthFields()
{
const bool isSsh = m_protocolInput->currentText() == QStringLiteral("SSH");
const bool isPrivateKey = m_authModeInput->currentText() == QStringLiteral("Private Key");
m_authModeInput->setEnabled(isSsh);
m_privateKeyPathInput->setEnabled(isSsh && isPrivateKey);
m_browsePrivateKeyButton->setEnabled(isSsh && isPrivateKey);
m_knownHostsPolicyInput->setEnabled(isSsh);
}

View File

@@ -7,6 +7,7 @@
class QComboBox; class QComboBox;
class QLineEdit; class QLineEdit;
class QPushButton;
class QSpinBox; class QSpinBox;
class ProfileDialog : public QDialog class ProfileDialog : public QDialog
@@ -30,6 +31,11 @@ private:
QLineEdit* m_usernameInput; QLineEdit* m_usernameInput;
QComboBox* m_protocolInput; QComboBox* m_protocolInput;
QComboBox* m_authModeInput; QComboBox* m_authModeInput;
QLineEdit* m_privateKeyPathInput;
QPushButton* m_browsePrivateKeyButton;
QComboBox* m_knownHostsPolicyInput;
void refreshAuthFields();
}; };
#endif #endif

View File

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

View File

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

View File

@@ -119,12 +119,13 @@ void ProfilesWindow::loadProfiles(const QString& query)
for (const Profile& profile : profiles) { for (const Profile& profile : profiles) {
auto* item = new QListWidgetItem(formatProfileListItem(profile), m_profilesList); auto* item = new QListWidgetItem(formatProfileListItem(profile), m_profilesList);
item->setData(Qt::UserRole, QVariant::fromValue(profile.id)); item->setData(Qt::UserRole, QVariant::fromValue(profile.id));
item->setToolTip(QStringLiteral("%1://%2@%3:%4\nAuth: %5") item->setToolTip(QStringLiteral("%1://%2@%3:%4\nAuth: %5\nKnown Hosts: %6")
.arg(profile.protocol, .arg(profile.protocol,
profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username, profile.username.isEmpty() ? QStringLiteral("<none>") : profile.username,
profile.host, profile.host,
QString::number(profile.port), QString::number(profile.port),
profile.authMode)); profile.authMode,
profile.knownHostsPolicy));
m_profileCache.insert_or_assign(profile.id, profile); m_profileCache.insert_or_assign(profile.id, profile);
} }
} }

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 "session_window.h"
#include <QFont> #include "session_tab.h"
#include <QLabel>
#include <QTabWidget> #include <QTabWidget>
#include <QTimer>
#include <QVBoxLayout>
#include <QWidget>
SessionWindow::SessionWindow(const Profile& profile, QWidget* parent) SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
: QMainWindow(parent), m_tabs(new QTabWidget(this)) : QMainWindow(parent), m_tabs(new QTabWidget(this))
{ {
setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name)); setWindowTitle(QStringLiteral("OrbitHub Session - %1").arg(profile.name));
resize(980, 680); resize(1080, 760);
m_tabs->setTabsClosable(true); m_tabs->setTabsClosable(true);
connect(m_tabs, connect(m_tabs,
@@ -32,64 +29,22 @@ SessionWindow::SessionWindow(const Profile& profile, QWidget* parent)
void SessionWindow::addSessionTab(const Profile& profile) void SessionWindow::addSessionTab(const Profile& profile)
{ {
auto* container = new QWidget(this); auto* tab = new SessionTab(profile, this);
auto* layout = new QVBoxLayout(container); const int index = m_tabs->addTab(tab, tab->tabTitle());
m_tabs->setCurrentIndex(index);
auto* profileLabel = new QLabel(QStringLiteral("Profile: %1").arg(profile.name), container); connect(tab,
auto* endpointLabel = new QLabel( &SessionTab::tabTitleChanged,
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,
[this, tabIndex, statusLabel, profile]() { [this, tab](const QString& title) { updateTabTitle(tab, title); });
const bool shouldFail = profile.host.contains(QStringLiteral("fail"), }
Qt::CaseInsensitive);
if (shouldFail) { void SessionWindow::updateTabTitle(SessionTab* tab, const QString& title)
statusLabel->setText(QStringLiteral("Connection State: Failed")); {
statusLabel->setStyleSheet(QStringLiteral( for (int i = 0; i < m_tabs->count(); ++i) {
"border: 1px solid #a94442; background-color: #f2dede; padding: 6px;")); if (m_tabs->widget(i) == tab) {
m_tabs->setTabText(tabIndex, m_tabs->setTabText(i, title);
QStringLiteral("%1 (Failed)").arg(profile.name));
return; 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));
});
} }

View File

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

429
src/ssh_session_backend.cpp Normal file
View File

@@ -0,0 +1,429 @@
#include "ssh_session_backend.h"
#include <QDir>
#include <QFile>
#include <QFileInfo>
#include <QProcessEnvironment>
#include <QTextStream>
#include <QUuid>
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_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)
{
cleanupAskPassScript();
#ifdef Q_OS_WIN
m_askPassScriptPath = QDir::temp().filePath(
QStringLiteral("orbithub_askpass_%1.cmd")
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces)));
#else
m_askPassScriptPath = QDir::temp().filePath(
QStringLiteral("orbithub_askpass_%1.sh")
.arg(QUuid::createUuid().toString(QUuid::WithoutBraces)));
#endif
QFile script(m_askPassScriptPath);
if (!script.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
error = QStringLiteral("Failed to create temporary askpass helper script.");
cleanupAskPassScript();
return false;
}
QTextStream out(&script);
#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();
script.close();
#ifndef Q_OS_WIN
if (!QFile::setPermissions(m_askPassScriptPath,
QFile::ReadOwner | QFile::WriteOwner | QFile::ExeOwner)) {
error = QStringLiteral("Failed to set permissions on askpass helper script.");
cleanupAskPassScript();
return false;
}
#endif
environment.insert(QStringLiteral("SSH_ASKPASS"), m_askPassScriptPath);
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_askPassScriptPath.isEmpty()) {
QFile::remove(m_askPassScriptPath);
m_askPassScriptPath.clear();
}
}
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.contains(QStringLiteral("Text file busy"), Qt::CaseInsensitive)) {
return QStringLiteral("Credential helper could not start (text file busy). Retry the connection.");
}
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
}

50
src/ssh_session_backend.h Normal file
View File

@@ -0,0 +1,50 @@
#ifndef ORBITHUB_SSH_SESSION_BACKEND_H
#define ORBITHUB_SSH_SESSION_BACKEND_H
#include "session_backend.h"
#include <QProcess>
#include <QString>
#include <QTimer>
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;
QString m_askPassScriptPath;
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