Start Milestone 4 interactive SSH terminal and host-key flow

This commit is contained in:
Keith Smith
2026-03-01 09:50:03 -07:00
parent 3c158269bf
commit 2ea712db36
10 changed files with 245 additions and 132 deletions

View File

@@ -52,7 +52,7 @@ 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( m_knownHostsPolicyInput->addItems(
{QStringLiteral("Strict"), QStringLiteral("Accept New"), QStringLiteral("Ignore")}); {QStringLiteral("Ask"), QStringLiteral("Strict"), QStringLiteral("Accept New"), QStringLiteral("Ignore")});
m_privateKeyPathInput->setPlaceholderText(QStringLiteral("/home/user/.ssh/id_ed25519")); m_privateKeyPathInput->setPlaceholderText(QStringLiteral("/home/user/.ssh/id_ed25519"));

View File

@@ -32,7 +32,7 @@ void bindProfileFields(QSqlQuery& query, const Profile& profile)
query.addBindValue(profile.authMode.trimmed()); query.addBindValue(profile.authMode.trimmed());
query.addBindValue(profile.privateKeyPath.trimmed()); query.addBindValue(profile.privateKeyPath.trimmed());
query.addBindValue(profile.knownHostsPolicy.trimmed().isEmpty() query.addBindValue(profile.knownHostsPolicy.trimmed().isEmpty()
? QStringLiteral("Strict") ? QStringLiteral("Ask")
: profile.knownHostsPolicy.trimmed()); : profile.knownHostsPolicy.trimmed());
} }
@@ -49,7 +49,7 @@ Profile profileFromQuery(const QSqlQuery& query)
profile.privateKeyPath = query.value(7).toString(); profile.privateKeyPath = query.value(7).toString();
profile.knownHostsPolicy = query.value(8).toString(); profile.knownHostsPolicy = query.value(8).toString();
if (profile.knownHostsPolicy.isEmpty()) { if (profile.knownHostsPolicy.isEmpty()) {
profile.knownHostsPolicy = QStringLiteral("Strict"); profile.knownHostsPolicy = QStringLiteral("Ask");
} }
return profile; return profile;
} }
@@ -253,7 +253,7 @@ bool ProfileRepository::initializeDatabase()
"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 ''," "private_key_path TEXT NOT NULL DEFAULT '',"
"known_hosts_policy TEXT NOT NULL DEFAULT 'Strict'" "known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'"
")")); ")"));
if (!created) { if (!created) {
@@ -299,7 +299,7 @@ bool ProfileRepository::ensureProfileSchema() const
{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("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'")}}; {QStringLiteral("known_hosts_policy"), QStringLiteral("ALTER TABLE profiles ADD COLUMN known_hosts_policy TEXT NOT NULL DEFAULT 'Ask'")}};
for (const ColumnDef& column : required) { for (const ColumnDef& column : required) {
if (columns.contains(column.name)) { if (columns.contains(column.name)) {

View File

@@ -17,7 +17,7 @@ struct Profile
QString protocol = QStringLiteral("SSH"); QString protocol = QStringLiteral("SSH");
QString authMode = QStringLiteral("Password"); QString authMode = QStringLiteral("Password");
QString privateKeyPath; QString privateKeyPath;
QString knownHostsPolicy = QStringLiteral("Strict"); QString knownHostsPolicy = QStringLiteral("Ask");
}; };
class ProfileRepository class ProfileRepository

View File

@@ -41,11 +41,15 @@ public slots:
virtual void connectSession(const SessionConnectOptions& options) = 0; virtual void connectSession(const SessionConnectOptions& options) = 0;
virtual void disconnectSession() = 0; virtual void disconnectSession() = 0;
virtual void reconnectSession(const SessionConnectOptions& options) = 0; virtual void reconnectSession(const SessionConnectOptions& options) = 0;
virtual void sendInput(const QString& input) = 0;
virtual void confirmHostKey(bool trustHost) = 0;
signals: signals:
void stateChanged(SessionState state, const QString& message); void stateChanged(SessionState state, const QString& message);
void eventLogged(const QString& message); void eventLogged(const QString& message);
void connectionError(const QString& displayMessage, const QString& rawMessage); void connectionError(const QString& displayMessage, const QString& rawMessage);
void outputReceived(const QString& text);
void hostKeyConfirmationRequested(const QString& prompt);
private: private:
Profile m_profile; Profile m_profile;

View File

@@ -15,6 +15,7 @@
#include <QMessageBox> #include <QMessageBox>
#include <QPlainTextEdit> #include <QPlainTextEdit>
#include <QPushButton> #include <QPushButton>
#include <QTextCursor>
#include <QThread> #include <QThread>
#include <QVBoxLayout> #include <QVBoxLayout>
@@ -28,11 +29,15 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
m_state(SessionState::Disconnected), m_state(SessionState::Disconnected),
m_statusLabel(nullptr), m_statusLabel(nullptr),
m_errorLabel(nullptr), m_errorLabel(nullptr),
m_terminalOutput(nullptr),
m_terminalInput(nullptr),
m_eventLog(nullptr), m_eventLog(nullptr),
m_connectButton(nullptr), m_connectButton(nullptr),
m_disconnectButton(nullptr), m_disconnectButton(nullptr),
m_reconnectButton(nullptr), m_reconnectButton(nullptr),
m_copyErrorButton(nullptr) m_copyErrorButton(nullptr),
m_sendInputButton(nullptr),
m_clearTerminalButton(nullptr)
{ {
qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions"); qRegisterMetaType<SessionConnectOptions>("SessionConnectOptions");
qRegisterMetaType<SessionState>("SessionState"); qRegisterMetaType<SessionState>("SessionState");
@@ -59,6 +64,16 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
m_backend, m_backend,
&SessionBackend::reconnectSession, &SessionBackend::reconnectSession,
Qt::QueuedConnection); Qt::QueuedConnection);
connect(this,
&SessionTab::requestInput,
m_backend,
&SessionBackend::sendInput,
Qt::QueuedConnection);
connect(this,
&SessionTab::requestHostKeyConfirmation,
m_backend,
&SessionBackend::confirmHostKey,
Qt::QueuedConnection);
connect(m_backend, connect(m_backend,
&SessionBackend::stateChanged, &SessionBackend::stateChanged,
@@ -75,6 +90,16 @@ SessionTab::SessionTab(const Profile& profile, QWidget* parent)
this, this,
&SessionTab::onBackendConnectionError, &SessionTab::onBackendConnectionError,
Qt::QueuedConnection); Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::outputReceived,
this,
&SessionTab::onBackendOutputReceived,
Qt::QueuedConnection);
connect(m_backend,
&SessionBackend::hostKeyConfirmationRequested,
this,
&SessionTab::onBackendHostKeyConfirmationRequested,
Qt::QueuedConnection);
m_backendThread->start(); m_backendThread->start();
@@ -139,6 +164,22 @@ void SessionTab::onCopyErrorClicked()
appendEvent(QStringLiteral("Copied last error to clipboard.")); appendEvent(QStringLiteral("Copied last error to clipboard."));
} }
void SessionTab::onSendInputClicked()
{
const QString input = m_terminalInput->text();
if (input.isEmpty()) {
return;
}
emit requestInput(input + QStringLiteral("\n"));
m_terminalInput->clear();
}
void SessionTab::onClearTerminalClicked()
{
m_terminalOutput->clear();
}
void SessionTab::onBackendStateChanged(SessionState state, const QString& message) void SessionTab::onBackendStateChanged(SessionState state, const QString& message)
{ {
setState(state, message); setState(state, message);
@@ -156,6 +197,35 @@ void SessionTab::onBackendConnectionError(const QString& displayMessage, const Q
m_copyErrorButton->setEnabled(true); m_copyErrorButton->setEnabled(true);
} }
void SessionTab::onBackendOutputReceived(const QString& text)
{
if (text.isEmpty()) {
return;
}
QTextCursor cursor = m_terminalOutput->textCursor();
cursor.movePosition(QTextCursor::End);
cursor.insertText(text);
m_terminalOutput->setTextCursor(cursor);
m_terminalOutput->ensureCursorVisible();
}
void SessionTab::onBackendHostKeyConfirmationRequested(const QString& prompt)
{
const QString question = prompt.isEmpty()
? QStringLiteral("Unknown SSH host key. Do you trust this host?")
: prompt;
const QMessageBox::StandardButton reply = QMessageBox::question(
this,
QStringLiteral("SSH Host Key Confirmation"),
QStringLiteral("%1\n\nTrust and continue?").arg(question),
QMessageBox::Yes | QMessageBox::No,
QMessageBox::No);
emit requestHostKeyConfirmation(reply == QMessageBox::Yes);
}
void SessionTab::setupUi() void SessionTab::setupUi()
{ {
auto* rootLayout = new QVBoxLayout(this); auto* rootLayout = new QVBoxLayout(this);
@@ -186,20 +256,33 @@ void SessionTab::setupUi()
actionRow->addWidget(m_copyErrorButton); actionRow->addWidget(m_copyErrorButton);
actionRow->addStretch(); actionRow->addStretch();
auto* surfaceLabel = new QLabel(QStringLiteral("OrbitHub Native Surface"), this); auto* terminalHeader = new QHBoxLayout();
QFont surfaceFont = surfaceLabel->font(); auto* terminalLabel = new QLabel(QStringLiteral("SSH Terminal"), this);
surfaceFont.setPointSize(surfaceFont.pointSize() + 6); m_clearTerminalButton = new QPushButton(QStringLiteral("Clear"), this);
surfaceFont.setBold(true); terminalHeader->addWidget(terminalLabel);
surfaceLabel->setFont(surfaceFont); terminalHeader->addStretch();
surfaceLabel->setAlignment(Qt::AlignCenter); terminalHeader->addWidget(m_clearTerminalButton);
surfaceLabel->setMinimumHeight(180);
surfaceLabel->setStyleSheet(
QStringLiteral("border: 1px solid #8a8a8a; background-color: #f5f5f5;"));
m_terminalOutput = new QPlainTextEdit(this);
m_terminalOutput->setReadOnly(true);
m_terminalOutput->setMaximumBlockCount(4000);
QFont terminalFont(QStringLiteral("Monospace"));
terminalFont.setStyleHint(QFont::TypeWriter);
m_terminalOutput->setFont(terminalFont);
m_terminalOutput->setMinimumHeight(220);
auto* terminalInputRow = new QHBoxLayout();
m_terminalInput = new QLineEdit(this);
m_terminalInput->setPlaceholderText(QStringLiteral("Type command and press Enter..."));
m_sendInputButton = new QPushButton(QStringLiteral("Send"), this);
terminalInputRow->addWidget(m_terminalInput, 1);
terminalInputRow->addWidget(m_sendInputButton);
auto* eventHeader = new QLabel(QStringLiteral("Session Events"), this);
m_eventLog = new QPlainTextEdit(this); m_eventLog = new QPlainTextEdit(this);
m_eventLog->setReadOnly(true); m_eventLog->setReadOnly(true);
m_eventLog->setPlaceholderText(QStringLiteral("Session event log...")); m_eventLog->setPlaceholderText(QStringLiteral("Session event log..."));
m_eventLog->setMinimumHeight(180); m_eventLog->setMinimumHeight(140);
rootLayout->addWidget(profileLabel); rootLayout->addWidget(profileLabel);
rootLayout->addWidget(endpointLabel); rootLayout->addWidget(endpointLabel);
@@ -207,13 +290,22 @@ void SessionTab::setupUi()
rootLayout->addWidget(m_statusLabel); rootLayout->addWidget(m_statusLabel);
rootLayout->addWidget(m_errorLabel); rootLayout->addWidget(m_errorLabel);
rootLayout->addLayout(actionRow); rootLayout->addLayout(actionRow);
rootLayout->addWidget(surfaceLabel); rootLayout->addLayout(terminalHeader);
rootLayout->addWidget(m_terminalOutput, 1);
rootLayout->addLayout(terminalInputRow);
rootLayout->addWidget(eventHeader);
rootLayout->addWidget(m_eventLog, 1); rootLayout->addWidget(m_eventLog, 1);
connect(m_connectButton, &QPushButton::clicked, this, &SessionTab::onConnectClicked); connect(m_connectButton, &QPushButton::clicked, this, &SessionTab::onConnectClicked);
connect(m_disconnectButton, &QPushButton::clicked, this, &SessionTab::onDisconnectClicked); connect(m_disconnectButton, &QPushButton::clicked, this, &SessionTab::onDisconnectClicked);
connect(m_reconnectButton, &QPushButton::clicked, this, &SessionTab::onReconnectClicked); connect(m_reconnectButton, &QPushButton::clicked, this, &SessionTab::onReconnectClicked);
connect(m_copyErrorButton, &QPushButton::clicked, this, &SessionTab::onCopyErrorClicked); connect(m_copyErrorButton, &QPushButton::clicked, this, &SessionTab::onCopyErrorClicked);
connect(m_sendInputButton, &QPushButton::clicked, this, &SessionTab::onSendInputClicked);
connect(m_terminalInput, &QLineEdit::returnPressed, this, &SessionTab::onSendInputClicked);
connect(m_clearTerminalButton,
&QPushButton::clicked,
this,
&SessionTab::onClearTerminalClicked);
} }
std::optional<SessionConnectOptions> SessionTab::buildConnectOptions() std::optional<SessionConnectOptions> SessionTab::buildConnectOptions()
@@ -352,12 +444,17 @@ QString SessionTab::stateSuffix() const
void SessionTab::refreshActionButtons() void SessionTab::refreshActionButtons()
{ {
m_connectButton->setEnabled(m_state == SessionState::Disconnected const bool isConnected = m_state == SessionState::Connected;
|| m_state == SessionState::Failed); const bool canConnect = m_state == SessionState::Disconnected || m_state == SessionState::Failed;
m_connectButton->setEnabled(canConnect);
m_disconnectButton->setEnabled(m_state == SessionState::Connected m_disconnectButton->setEnabled(m_state == SessionState::Connected
|| m_state == SessionState::Connecting); || m_state == SessionState::Connecting);
m_reconnectButton->setEnabled(m_state == SessionState::Connected m_reconnectButton->setEnabled(m_state == SessionState::Connected
|| m_state == SessionState::Failed || m_state == SessionState::Failed
|| m_state == SessionState::Disconnected); || m_state == SessionState::Disconnected);
m_copyErrorButton->setEnabled(!m_lastError.isEmpty()); m_copyErrorButton->setEnabled(!m_lastError.isEmpty());
m_terminalInput->setEnabled(isConnected);
m_sendInputButton->setEnabled(isConnected);
} }

View File

@@ -9,6 +9,7 @@
#include <optional> #include <optional>
class QLabel; class QLabel;
class QLineEdit;
class QPlainTextEdit; class QPlainTextEdit;
class QPushButton; class QPushButton;
class QThread; class QThread;
@@ -29,16 +30,22 @@ signals:
void requestConnect(const SessionConnectOptions& options); void requestConnect(const SessionConnectOptions& options);
void requestDisconnect(); void requestDisconnect();
void requestReconnect(const SessionConnectOptions& options); void requestReconnect(const SessionConnectOptions& options);
void requestInput(const QString& input);
void requestHostKeyConfirmation(bool trustHost);
private slots: private slots:
void onConnectClicked(); void onConnectClicked();
void onDisconnectClicked(); void onDisconnectClicked();
void onReconnectClicked(); void onReconnectClicked();
void onCopyErrorClicked(); void onCopyErrorClicked();
void onSendInputClicked();
void onClearTerminalClicked();
void onBackendStateChanged(SessionState state, const QString& message); void onBackendStateChanged(SessionState state, const QString& message);
void onBackendEventLogged(const QString& message); void onBackendEventLogged(const QString& message);
void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage); void onBackendConnectionError(const QString& displayMessage, const QString& rawMessage);
void onBackendOutputReceived(const QString& text);
void onBackendHostKeyConfirmationRequested(const QString& prompt);
private: private:
Profile m_profile; Profile m_profile;
@@ -49,11 +56,15 @@ private:
QLabel* m_statusLabel; QLabel* m_statusLabel;
QLabel* m_errorLabel; QLabel* m_errorLabel;
QPlainTextEdit* m_terminalOutput;
QLineEdit* m_terminalInput;
QPlainTextEdit* m_eventLog; QPlainTextEdit* m_eventLog;
QPushButton* m_connectButton; QPushButton* m_connectButton;
QPushButton* m_disconnectButton; QPushButton* m_disconnectButton;
QPushButton* m_reconnectButton; QPushButton* m_reconnectButton;
QPushButton* m_copyErrorButton; QPushButton* m_copyErrorButton;
QPushButton* m_sendInputButton;
QPushButton* m_clearTerminalButton;
void setupUi(); void setupUi();
std::optional<SessionConnectOptions> buildConnectOptions(); std::optional<SessionConnectOptions> buildConnectOptions();

View File

@@ -1,31 +1,7 @@
#include "ssh_session_backend.h" #include "ssh_session_backend.h"
#include <QDir>
#include <QFile>
#include <QFileInfo> #include <QFileInfo>
#include <QProcessEnvironment> #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) SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
: SessionBackend(profile, parent), : SessionBackend(profile, parent),
@@ -33,7 +9,10 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
m_connectedProbeTimer(new QTimer(this)), m_connectedProbeTimer(new QTimer(this)),
m_state(SessionState::Disconnected), m_state(SessionState::Disconnected),
m_userInitiatedDisconnect(false), m_userInitiatedDisconnect(false),
m_reconnectPending(false) m_reconnectPending(false),
m_waitingForPasswordPrompt(false),
m_waitingForHostKeyConfirmation(false),
m_passwordSubmitted(false)
{ {
m_connectedProbeTimer->setSingleShot(true); m_connectedProbeTimer->setSingleShot(true);
@@ -46,6 +25,10 @@ SshSessionBackend::SshSessionBackend(const Profile& profile, QObject* parent)
qOverload<int, QProcess::ExitStatus>(&QProcess::finished), qOverload<int, QProcess::ExitStatus>(&QProcess::finished),
this, this,
&SshSessionBackend::onProcessFinished); &SshSessionBackend::onProcessFinished);
connect(m_process,
&QProcess::readyReadStandardOutput,
this,
&SshSessionBackend::onReadyReadStandardOutput);
connect(m_process, connect(m_process,
&QProcess::readyReadStandardError, &QProcess::readyReadStandardError,
this, this,
@@ -62,7 +45,6 @@ SshSessionBackend::~SshSessionBackend()
m_process->kill(); m_process->kill();
m_process->waitForFinished(500); m_process->waitForFinished(500);
} }
cleanupAskPassScript();
} }
void SshSessionBackend::connectSession(const SessionConnectOptions& options) void SshSessionBackend::connectSession(const SessionConnectOptions& options)
@@ -75,6 +57,10 @@ void SshSessionBackend::connectSession(const SessionConnectOptions& options)
m_userInitiatedDisconnect = false; m_userInitiatedDisconnect = false;
m_reconnectPending = false; m_reconnectPending = false;
m_lastRawError.clear(); m_lastRawError.clear();
m_activeOptions = options;
m_waitingForPasswordPrompt = false;
m_waitingForHostKeyConfirmation = false;
m_passwordSubmitted = false;
if (!startSshProcess(options)) { if (!startSshProcess(options)) {
return; return;
@@ -123,6 +109,35 @@ void SshSessionBackend::reconnectSession(const SessionConnectOptions& options)
m_process->terminate(); m_process->terminate();
} }
void SshSessionBackend::sendInput(const QString& input)
{
if (m_process->state() != QProcess::Running) {
emit eventLogged(QStringLiteral("Input ignored: session is not running."));
return;
}
if (input.isEmpty()) {
return;
}
m_process->write(input.toUtf8());
}
void SshSessionBackend::confirmHostKey(bool trustHost)
{
if (m_process->state() != QProcess::Running || !m_waitingForHostKeyConfirmation) {
return;
}
m_waitingForHostKeyConfirmation = false;
const QString response = trustHost ? QStringLiteral("yes\n") : QStringLiteral("no\n");
m_process->write(response.toUtf8());
emit eventLogged(trustHost
? QStringLiteral("Host key accepted by user.")
: QStringLiteral("Host key rejected by user."));
}
void SshSessionBackend::onProcessStarted() void SshSessionBackend::onProcessStarted()
{ {
emit eventLogged(QStringLiteral("ssh process started.")); emit eventLogged(QStringLiteral("ssh process started."));
@@ -146,7 +161,6 @@ void SshSessionBackend::onProcessErrorOccurred(QProcess::ProcessError)
void SshSessionBackend::onProcessFinished(int exitCode, QProcess::ExitStatus) void SshSessionBackend::onProcessFinished(int exitCode, QProcess::ExitStatus)
{ {
m_connectedProbeTimer->stop(); m_connectedProbeTimer->stop();
cleanupAskPassScript();
if (m_reconnectPending) { if (m_reconnectPending) {
m_reconnectPending = false; m_reconnectPending = false;
@@ -178,6 +192,21 @@ void SshSessionBackend::onProcessFinished(int exitCode, QProcess::ExitStatus)
setState(SessionState::Disconnected, QStringLiteral("SSH session ended.")); setState(SessionState::Disconnected, QStringLiteral("SSH session ended."));
} }
void SshSessionBackend::onReadyReadStandardOutput()
{
const QString chunk = QString::fromUtf8(m_process->readAllStandardOutput());
if (chunk.isEmpty()) {
return;
}
emit outputReceived(chunk);
if (m_state == SessionState::Connecting && !m_waitingForHostKeyConfirmation
&& !m_waitingForPasswordPrompt) {
setState(SessionState::Connected, QStringLiteral("SSH session established."));
}
}
void SshSessionBackend::onReadyReadStandardError() void SshSessionBackend::onReadyReadStandardError()
{ {
const QString chunk = QString::fromUtf8(m_process->readAllStandardError()); const QString chunk = QString::fromUtf8(m_process->readAllStandardError());
@@ -186,10 +215,40 @@ void SshSessionBackend::onReadyReadStandardError()
} }
m_lastRawError += chunk; m_lastRawError += chunk;
emit outputReceived(chunk);
const QStringList lines = chunk.split(QLatin1Char('\n'), Qt::SkipEmptyParts); const QStringList lines = chunk.split(QLatin1Char('\n'), Qt::SkipEmptyParts);
for (const QString& line : lines) { for (const QString& line : lines) {
emit eventLogged(line.trimmed()); const QString trimmed = line.trimmed();
if (!trimmed.isEmpty()) {
emit eventLogged(trimmed);
}
if (trimmed.contains(QStringLiteral("Are you sure you want to continue connecting"),
Qt::CaseInsensitive)
&& !m_waitingForHostKeyConfirmation) {
m_waitingForHostKeyConfirmation = true;
emit eventLogged(QStringLiteral("Awaiting host key confirmation from user."));
emit hostKeyConfirmationRequested(trimmed);
continue;
}
if (trimmed.contains(QStringLiteral("password:"), Qt::CaseInsensitive)
&& profile().authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0
&& !m_passwordSubmitted) {
if (m_activeOptions.password.isEmpty()) {
const QString message = QStringLiteral("Password prompt received but no password is available.");
setState(SessionState::Failed, message);
emit connectionError(message, trimmed);
return;
}
m_waitingForPasswordPrompt = false;
m_passwordSubmitted = true;
m_process->write((m_activeOptions.password + QStringLiteral("\n")).toUtf8());
emit eventLogged(QStringLiteral("Password prompt received; credentials submitted."));
continue;
}
} }
} }
@@ -199,7 +258,8 @@ void SshSessionBackend::onConnectedProbeTimeout()
return; return;
} }
if (m_process->state() == QProcess::Running) { if (m_process->state() == QProcess::Running && !m_waitingForHostKeyConfirmation
&& !m_waitingForPasswordPrompt) {
setState(SessionState::Connected, QStringLiteral("SSH session established.")); setState(SessionState::Connected, QStringLiteral("SSH session established."));
} }
} }
@@ -229,12 +289,9 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
return false; return false;
} }
cleanupAskPassScript();
QStringList args; QStringList args;
args << QStringLiteral("-N") << QStringLiteral("-T") << QStringLiteral("-p") args << QStringLiteral("-tt") << QStringLiteral("-p") << QString::number(p.port)
<< QString::number(p.port) << QStringLiteral("-o") << QStringLiteral("-o") << QStringLiteral("ConnectTimeout=12") << QStringLiteral("-o")
<< QStringLiteral("ConnectTimeout=12") << QStringLiteral("-o")
<< QStringLiteral("ServerAliveInterval=20") << QStringLiteral("-o") << QStringLiteral("ServerAliveInterval=20") << QStringLiteral("-o")
<< QStringLiteral("ServerAliveCountMax=2"); << QStringLiteral("ServerAliveCountMax=2");
@@ -248,12 +305,12 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
<< QStringLiteral("UserKnownHostsFile=%1").arg(knownHostsFileForNullDevice()); << QStringLiteral("UserKnownHostsFile=%1").arg(knownHostsFileForNullDevice());
} else if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) { } else if (policy.compare(QStringLiteral("Accept New"), Qt::CaseInsensitive) == 0) {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=accept-new"); args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=accept-new");
} else if (policy.compare(QStringLiteral("Ask"), Qt::CaseInsensitive) == 0) {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=ask");
} else { } else {
args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=yes"); args << QStringLiteral("-o") << QStringLiteral("StrictHostKeyChecking=yes");
} }
QProcessEnvironment environment = QProcessEnvironment::systemEnvironment();
if (p.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) { if (p.authMode.compare(QStringLiteral("Password"), Qt::CaseInsensitive) == 0) {
if (options.password.isEmpty()) { if (options.password.isEmpty()) {
const QString message = QStringLiteral("Password is required for password authentication."); const QString message = QStringLiteral("Password is required for password authentication.");
@@ -265,13 +322,7 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
args << QStringLiteral("-o") << QStringLiteral("PreferredAuthentications=password") args << QStringLiteral("-o") << QStringLiteral("PreferredAuthentications=password")
<< QStringLiteral("-o") << QStringLiteral("PubkeyAuthentication=no") << QStringLiteral("-o") << QStringLiteral("PubkeyAuthentication=no")
<< QStringLiteral("-o") << QStringLiteral("NumberOfPasswordPrompts=1"); << QStringLiteral("-o") << QStringLiteral("NumberOfPasswordPrompts=1");
m_waitingForPasswordPrompt = true;
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) { } else if (p.authMode.compare(QStringLiteral("Private Key"), Qt::CaseInsensitive) == 0) {
QString keyPath = options.privateKeyPath.trimmed(); QString keyPath = options.privateKeyPath.trimmed();
if (keyPath.isEmpty()) { if (keyPath.isEmpty()) {
@@ -303,7 +354,7 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
: QStringLiteral("%1@%2").arg(p.username.trimmed(), p.host.trimmed()); : QStringLiteral("%1@%2").arg(p.username.trimmed(), p.host.trimmed());
args << target; args << target;
m_process->setProcessEnvironment(environment); m_process->setProcessEnvironment(QProcessEnvironment::systemEnvironment());
m_process->setProgram(QStringLiteral("ssh")); m_process->setProgram(QStringLiteral("ssh"));
m_process->setArguments(args); m_process->setArguments(args);
m_process->setProcessChannelMode(QProcess::SeparateChannels); m_process->setProcessChannelMode(QProcess::SeparateChannels);
@@ -320,66 +371,6 @@ bool SshSessionBackend::startSshProcess(const SessionConnectOptions& options)
return true; 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 QString SshSessionBackend::mapSshError(const QString& rawError) const
{ {
const QString raw = rawError.trimmed(); const QString raw = rawError.trimmed();
@@ -409,9 +400,6 @@ QString SshSessionBackend::mapSshError(const QString& rawError) const
if (raw.contains(QStringLiteral("No such file or directory"), Qt::CaseInsensitive)) { if (raw.contains(QStringLiteral("No such file or directory"), Qt::CaseInsensitive)) {
return QStringLiteral("Required file was not found."); 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()) { if (raw.isEmpty()) {
return QStringLiteral("SSH connection failed for an unknown reason."); return QStringLiteral("SSH connection failed for an unknown reason.");
} }

View File

@@ -19,11 +19,14 @@ public slots:
void connectSession(const SessionConnectOptions& options) override; void connectSession(const SessionConnectOptions& options) override;
void disconnectSession() override; void disconnectSession() override;
void reconnectSession(const SessionConnectOptions& options) override; void reconnectSession(const SessionConnectOptions& options) override;
void sendInput(const QString& input) override;
void confirmHostKey(bool trustHost) override;
private slots: private slots:
void onProcessStarted(); void onProcessStarted();
void onProcessErrorOccurred(QProcess::ProcessError error); void onProcessErrorOccurred(QProcess::ProcessError error);
void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus); void onProcessFinished(int exitCode, QProcess::ExitStatus exitStatus);
void onReadyReadStandardOutput();
void onReadyReadStandardError(); void onReadyReadStandardError();
void onConnectedProbeTimeout(); void onConnectedProbeTimeout();
@@ -34,15 +37,14 @@ private:
bool m_userInitiatedDisconnect; bool m_userInitiatedDisconnect;
bool m_reconnectPending; bool m_reconnectPending;
SessionConnectOptions m_reconnectOptions; SessionConnectOptions m_reconnectOptions;
SessionConnectOptions m_activeOptions;
QString m_lastRawError; QString m_lastRawError;
QString m_askPassScriptPath; bool m_waitingForPasswordPrompt;
bool m_waitingForHostKeyConfirmation;
bool m_passwordSubmitted;
void setState(SessionState state, const QString& message); void setState(SessionState state, const QString& message);
bool startSshProcess(const SessionConnectOptions& options); bool startSshProcess(const SessionConnectOptions& options);
bool configureAskPass(const SessionConnectOptions& options,
QProcessEnvironment& environment,
QString& error);
void cleanupAskPassScript();
QString mapSshError(const QString& rawError) const; QString mapSshError(const QString& rawError) const;
QString knownHostsFileForNullDevice() const; QString knownHostsFileForNullDevice() const;
}; };

View File

@@ -24,3 +24,12 @@ void UnsupportedSessionBackend::reconnectSession(const SessionConnectOptions& op
{ {
connectSession(options); connectSession(options);
} }
void UnsupportedSessionBackend::sendInput(const QString&)
{
emit eventLogged(QStringLiteral("Input ignored: protocol backend is not interactive."));
}
void UnsupportedSessionBackend::confirmHostKey(bool)
{
}

View File

@@ -14,6 +14,8 @@ public slots:
void connectSession(const SessionConnectOptions& options) override; void connectSession(const SessionConnectOptions& options) override;
void disconnectSession() override; void disconnectSession() override;
void reconnectSession(const SessionConnectOptions& options) override; void reconnectSession(const SessionConnectOptions& options) override;
void sendInput(const QString& input) override;
void confirmHostKey(bool trustHost) override;
}; };
#endif #endif