diff --git a/CMakeLists.txt b/CMakeLists.txt index d643a5d..671b0b2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -98,6 +98,8 @@ set(ORBITHUB_SOURCES src/app_icon.cpp src/app_icon.h src/main.cpp + src/mremoteng_importer.cpp + src/mremoteng_importer.h src/profile_dialog.cpp src/profile_dialog.h src/profile_repository.cpp diff --git a/src/mremoteng_importer.cpp b/src/mremoteng_importer.cpp new file mode 100644 index 0000000..a88682b --- /dev/null +++ b/src/mremoteng_importer.cpp @@ -0,0 +1,152 @@ +#include "mremoteng_importer.h" + +#include +#include + +namespace { +QString joinFolderPath(const QStringList& parts) +{ + QStringList trimmed; + for (const QString& part : parts) { + const QString t = part.trimmed(); + if (!t.isEmpty()) { + trimmed.push_back(t); + } + } + return trimmed.join(QStringLiteral("/")); +} + +// Maps mRemoteNG's Protocol attribute (an enum: RDP, VNC, SSH1, SSH2, +// Telnet, Rlogin, RAW, HTTP, HTTPS, PowerShell, IntApp, Winbox) onto what +// OrbitHub actually supports. *supported is set to false for anything +// OrbitHub can't yet connect to (VNC included -- see issue #3). +QString mappedProtocol(const QString& mRemoteNGProtocol, bool* supported) +{ + const QString p = mRemoteNGProtocol.trimmed(); + if (p.compare(QStringLiteral("RDP"), Qt::CaseInsensitive) == 0) { + *supported = true; + return QStringLiteral("RDP"); + } + if (p.compare(QStringLiteral("SSH1"), Qt::CaseInsensitive) == 0 + || p.compare(QStringLiteral("SSH2"), Qt::CaseInsensitive) == 0) { + *supported = true; + return QStringLiteral("SSH"); + } + *supported = false; + return p; +} + +int defaultPortFor(const QString& orbitHubProtocol) +{ + return orbitHubProtocol == QStringLiteral("RDP") ? 3389 : 22; +} +} + +MRemoteNGImportResult parseMRemoteNGConnections(const QByteArray& xmlData) +{ + MRemoteNGImportResult result; + + QXmlStreamReader reader(xmlData); + + bool foundRoot = false; + while (!reader.atEnd() && !foundRoot) { + if (reader.readNext() == QXmlStreamReader::StartElement) { + if (reader.name().compare(QStringLiteral("Connections"), Qt::CaseInsensitive) != 0) { + result.errorMessage = QStringLiteral( + "This doesn't look like an mRemoteNG connections file (expected a " + " root element)."); + return result; + } + foundRoot = true; + + const auto attrs = reader.attributes(); + if (attrs.value(QStringLiteral("FullFileEncryption")) + .compare(QStringLiteral("true"), Qt::CaseInsensitive) + == 0) { + result.errorMessage = QStringLiteral( + "This file has \"full file encryption\" enabled in mRemoteNG, which " + "encrypts the entire connection list with your master password. " + "OrbitHub has no way to read that. In mRemoteNG, re-export with full " + "file encryption turned off (only the saved passwords need stay " + "encrypted -- OrbitHub never imports those anyway)."); + return result; + } + } + } + + if (!foundRoot) { + result.errorMessage = QStringLiteral("Not a valid XML file, or the file is empty."); + return result; + } + + QStringList folderStack; + std::vector openNodeIsContainer; + + while (!reader.atEnd()) { + const QXmlStreamReader::TokenType token = reader.readNext(); + + if (token == QXmlStreamReader::StartElement + && reader.name().compare(QStringLiteral("Node"), Qt::CaseInsensitive) == 0) { + const auto attrs = reader.attributes(); + const QString type = attrs.value(QStringLiteral("Type")).toString(); + const bool isContainer = + type.compare(QStringLiteral("Container"), Qt::CaseInsensitive) == 0; + const QString name = attrs.value(QStringLiteral("Name")).toString().trimmed(); + + if (isContainer) { + folderStack.push_back(name); + const QString path = joinFolderPath(folderStack); + if (!path.isEmpty()) { + result.folders.push_back(path); + } + } else { + bool supported = false; + const QString protocolLabel = attrs.value(QStringLiteral("Protocol")).toString(); + const QString mappedProto = mappedProtocol(protocolLabel, &supported); + + if (!supported) { + result.skippedUnsupportedProtocol.push_back(QStringLiteral("%1 (%2)").arg( + name.isEmpty() ? QStringLiteral("(unnamed)") : name, + protocolLabel.isEmpty() ? QStringLiteral("unknown") : protocolLabel)); + } else { + MRemoteNGImportedProfile imported; + imported.profile.name = name; + imported.profile.host = attrs.value(QStringLiteral("Hostname")).toString().trimmed(); + const QString portText = attrs.value(QStringLiteral("Port")).toString(); + imported.profile.port = + portText.isEmpty() ? defaultPortFor(mappedProto) : portText.toInt(); + if (imported.profile.port <= 0) { + imported.profile.port = defaultPortFor(mappedProto); + } + imported.profile.username = attrs.value(QStringLiteral("Username")).toString(); + imported.profile.protocol = mappedProto; + imported.profile.authMode = QStringLiteral("Password"); + if (mappedProto == QStringLiteral("RDP")) { + imported.profile.domain = attrs.value(QStringLiteral("Domain")).toString(); + } + imported.folderPath = joinFolderPath(folderStack); + result.profiles.push_back(imported); + } + } + + openNodeIsContainer.push_back(isContainer); + } else if (token == QXmlStreamReader::EndElement + && reader.name().compare(QStringLiteral("Node"), Qt::CaseInsensitive) == 0) { + if (!openNodeIsContainer.empty()) { + if (openNodeIsContainer.back()) { + folderStack.removeLast(); + } + openNodeIsContainer.pop_back(); + } + } + } + + if (reader.hasError()) { + result.errorMessage = QStringLiteral("Failed to parse XML: %1").arg(reader.errorString()); + result.folders.clear(); + result.profiles.clear(); + result.skippedUnsupportedProtocol.clear(); + } + + return result; +} diff --git a/src/mremoteng_importer.h b/src/mremoteng_importer.h new file mode 100644 index 0000000..af4bffd --- /dev/null +++ b/src/mremoteng_importer.h @@ -0,0 +1,48 @@ +#ifndef ORBITHUB_MREMOTENG_IMPORTER_H +#define ORBITHUB_MREMOTENG_IMPORTER_H + +#include "profile_repository.h" + +#include +#include +#include + +#include + +// A profile parsed out of an mRemoteNG confCons.xml export, plus which +// folder (if any) it belongs to, expressed the same way ProfilesWindow's +// own folder paths are ("Parent/Child"). +struct MRemoteNGImportedProfile +{ + Profile profile; + QString folderPath; +}; + +struct MRemoteNGImportResult +{ + // Non-empty only when parsing failed outright (malformed XML, or a + // FullFileEncryption="true" export -- that encrypts the whole node + // tree with the user's master password, which OrbitHub has no way to + // ask for or use, so those files can't be read at all here). + QString errorMessage; + + // Every folder path that appeared, including ones with no directly + // imported profile in them (e.g. a container that held only + // unsupported-protocol connections). + std::vector folders; + + std::vector profiles; + + // " ()" for each connection whose protocol + // OrbitHub doesn't support (VNC, Telnet, HTTP, ...) -- skipped, never + // silently dropped. + QStringList skippedUnsupportedProtocol; +}; + +// Passwords are never read from the file, even for the common case where +// they're readable without a master password (mRemoteNG only encrypts the +// Password attribute itself, not the surrounding plaintext fields) -- +// OrbitHub never persists credentials on a Profile in the first place. +MRemoteNGImportResult parseMRemoteNGConnections(const QByteArray& xmlData); + +#endif diff --git a/src/profiles_window.cpp b/src/profiles_window.cpp index f1ac30e..0f51ba8 100644 --- a/src/profiles_window.cpp +++ b/src/profiles_window.cpp @@ -1,5 +1,6 @@ #include "profiles_window.h" +#include "mremoteng_importer.h" #include "profile_dialog.h" #include "profile_repository.h" #include "profiles_tree_widget.h" @@ -1183,3 +1184,72 @@ void ProfilesWindow::importProfiles() QMessageBox::information(this, QStringLiteral("Import Profiles"), summary); } } + +void ProfilesWindow::importFromMRemoteNG() +{ + const QString fileName = QFileDialog::getOpenFileName( + this, QStringLiteral("Import from mRemoteNG"), QString(), + QStringLiteral("mRemoteNG Connections (*.xml)")); + if (fileName.isEmpty()) { + return; + } + + QFile file(fileName); + if (!file.open(QIODevice::ReadOnly)) { + QMessageBox::warning(this, + QStringLiteral("Import from mRemoteNG"), + QStringLiteral("Failed to read %1: %2") + .arg(fileName, file.errorString())); + return; + } + const QByteArray xmlData = file.readAll(); + file.close(); + + const MRemoteNGImportResult parsed = parseMRemoteNGConnections(xmlData); + if (!parsed.errorMessage.isEmpty()) { + QMessageBox::warning(this, QStringLiteral("Import from mRemoteNG"), parsed.errorMessage); + return; + } + + for (const QString& folderPath : parsed.folders) { + m_repository->createFolder(folderPath); + } + + int imported = 0; + QStringList failures; + for (const MRemoteNGImportedProfile& item : parsed.profiles) { + Profile profile = item.profile; + profile.folderPath = item.folderPath; + if (profile.name.isEmpty() || profile.host.isEmpty()) { + failures.push_back(QStringLiteral("(unnamed connection): missing name or host")); + continue; + } + if (m_repository->createProfile(profile).has_value()) { + ++imported; + } else { + failures.push_back(QStringLiteral("%1: %2").arg(profile.name, m_repository->lastError())); + } + } + + loadProfiles(); + + QString summary = QStringLiteral("Imported %1 of %2 connection(s) from %3.\n\n" + "Passwords are never imported -- you'll be prompted the " + "first time you connect each profile, same as a new one.") + .arg(imported) + .arg(static_cast(parsed.profiles.size())) + .arg(fileName); + if (!parsed.skippedUnsupportedProtocol.isEmpty()) { + summary += QStringLiteral( + "\n\nSkipped %1 connection(s) using a protocol OrbitHub doesn't support " + "yet:\n%2") + .arg(static_cast(parsed.skippedUnsupportedProtocol.size())) + .arg(parsed.skippedUnsupportedProtocol.join(QChar::fromLatin1('\n'))); + } + if (!failures.isEmpty()) { + summary += QStringLiteral("\n\nFailed:\n%1").arg(failures.join(QChar::fromLatin1('\n'))); + QMessageBox::warning(this, QStringLiteral("Import from mRemoteNG"), summary); + } else { + QMessageBox::information(this, QStringLiteral("Import from mRemoteNG"), summary); + } +} diff --git a/src/profiles_window.h b/src/profiles_window.h index fa3ed5d..4591a92 100644 --- a/src/profiles_window.h +++ b/src/profiles_window.h @@ -34,6 +34,7 @@ public: void createFolderInCurrentContext(); void exportProfiles(); void importProfiles(); + void importFromMRemoteNG(); signals: void connectRequested(const Profile& profile); diff --git a/src/session_window.cpp b/src/session_window.cpp index f6ea03c..b3376e4 100644 --- a/src/session_window.cpp +++ b/src/session_window.cpp @@ -172,6 +172,7 @@ SessionWindow::SessionWindow(QWidget* parent) fileMenu->addSeparator(); QAction* importProfilesAction = fileMenu->addAction(QStringLiteral("Import Profiles...")); QAction* exportProfilesAction = fileMenu->addAction(QStringLiteral("Export Profiles...")); + QAction* importMRemoteNGAction = fileMenu->addAction(QStringLiteral("Import from mRemoteNG...")); fileMenu->addSeparator(); QAction* quitAction = fileMenu->addAction(QStringLiteral("Quit")); @@ -191,6 +192,10 @@ SessionWindow::SessionWindow(QWidget* parent) &QAction::triggered, this, [this]() { m_profilesWidget->exportProfiles(); }); + connect(importMRemoteNGAction, + &QAction::triggered, + this, + [this]() { m_profilesWidget->importFromMRemoteNG(); }); connect(quitAction, &QAction::triggered, this, []() { qApp->quit(); }); QMenu* helpMenu = menuBar()->addMenu(QStringLiteral("Help")); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 96d2b3d..b06ace5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -6,6 +6,14 @@ target_include_directories(test_profile_repository PRIVATE ${CMAKE_SOURCE_DIR}/s target_link_libraries(test_profile_repository PRIVATE Qt6::Core Qt6::Sql Qt6::Test) add_test(NAME test_profile_repository COMMAND test_profile_repository) +add_executable(test_mremoteng_importer + test_mremoteng_importer.cpp + ${CMAKE_SOURCE_DIR}/src/mremoteng_importer.cpp +) +target_include_directories(test_mremoteng_importer PRIVATE ${CMAKE_SOURCE_DIR}/src) +target_link_libraries(test_mremoteng_importer PRIVATE Qt6::Core Qt6::Test) +add_test(NAME test_mremoteng_importer COMMAND test_mremoteng_importer) + add_executable(test_ssh_session_backend test_ssh_session_backend.cpp ${CMAKE_SOURCE_DIR}/src/ssh_session_backend.cpp diff --git a/tests/test_mremoteng_importer.cpp b/tests/test_mremoteng_importer.cpp new file mode 100644 index 0000000..5691e8c --- /dev/null +++ b/tests/test_mremoteng_importer.cpp @@ -0,0 +1,124 @@ +#include "mremoteng_importer.h" + +#include "test_mremoteng_importer_fixtures.h" + +#include + +#include + +class TestMRemoteNGImporter : public QObject +{ + Q_OBJECT + +private slots: + void parsesFoldersAndProfilesFromRealisticSample(); + void mapsRdpConnectionFieldsCorrectly(); + void mapsSshConnectionFieldsCorrectly(); + void skipsUnsupportedProtocolWithoutDroppingSilently(); + void neverImportsPasswordField(); + void refusesFullFileEncryptedExports(); + void refusesUnrecognizedRootElement(); + void refusesMalformedXml(); + void refusesEmptyInput(); +}; + +void TestMRemoteNGImporter::parsesFoldersAndProfilesFromRealisticSample() +{ + const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGSampleXml())); + QVERIFY(result.errorMessage.isEmpty()); + + QCOMPARE(result.folders.size(), size_t(2)); + QCOMPARE(result.folders[0], QStringLiteral("Work")); + QCOMPARE(result.folders[1], QStringLiteral("Work/Servers")); + + // 3 connection nodes in the sample: DC (RDP), build-box (SSH2), + // oldkiosk (VNC, unsupported) -- only the first two should come + // through as imported profiles. + QCOMPARE(result.profiles.size(), size_t(2)); + QCOMPARE(result.skippedUnsupportedProtocol.size(), 1); +} + +void TestMRemoteNGImporter::mapsRdpConnectionFieldsCorrectly() +{ + const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGSampleXml())); + const auto it = std::find_if(result.profiles.begin(), result.profiles.end(), + [](const MRemoteNGImportedProfile& p) { + return p.profile.name == QStringLiteral("DC"); + }); + QVERIFY(it != result.profiles.end()); + QCOMPARE(it->profile.host, QStringLiteral("10.0.0.5")); + QCOMPARE(it->profile.port, 3389); + QCOMPARE(it->profile.username, QStringLiteral("Administrator")); + QCOMPARE(it->profile.protocol, QStringLiteral("RDP")); + QCOMPARE(it->profile.domain, QStringLiteral("CORP")); + QCOMPARE(it->folderPath, QStringLiteral("Work")); +} + +void TestMRemoteNGImporter::mapsSshConnectionFieldsCorrectly() +{ + const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGSampleXml())); + const auto it = std::find_if(result.profiles.begin(), result.profiles.end(), + [](const MRemoteNGImportedProfile& p) { + return p.profile.name == QStringLiteral("build-box"); + }); + QVERIFY(it != result.profiles.end()); + QCOMPARE(it->profile.host, QStringLiteral("build.internal")); + QCOMPARE(it->profile.port, 22); + QCOMPARE(it->profile.username, QStringLiteral("deploy")); + // SSH1/SSH2 both collapse to OrbitHub's single "SSH" protocol. + QCOMPARE(it->profile.protocol, QStringLiteral("SSH")); + QCOMPARE(it->profile.authMode, QStringLiteral("Password")); + // Domain is RDP-only; must not leak through for SSH. + QCOMPARE(it->profile.domain, QString()); + QCOMPARE(it->folderPath, QStringLiteral("Work/Servers")); +} + +void TestMRemoteNGImporter::skipsUnsupportedProtocolWithoutDroppingSilently() +{ + const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGSampleXml())); + QCOMPARE(result.skippedUnsupportedProtocol.size(), 1); + QVERIFY(result.skippedUnsupportedProtocol[0].contains(QStringLiteral("oldkiosk"))); + QVERIFY(result.skippedUnsupportedProtocol[0].contains(QStringLiteral("VNC"))); +} + +void TestMRemoteNGImporter::neverImportsPasswordField() +{ + // Profile has no password-storing field at all -- this test exists to + // document that guarantee, not to probe internals that don't exist. + const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGSampleXml())); + QVERIFY(!result.profiles.empty()); + for (const MRemoteNGImportedProfile& item : result.profiles) { + QCOMPARE(item.profile.authMode, QStringLiteral("Password")); + QVERIFY(item.profile.privateKeyPath.isEmpty()); + } +} + +void TestMRemoteNGImporter::refusesFullFileEncryptedExports() +{ + const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGFullFileEncryptedXml())); + QVERIFY(!result.errorMessage.isEmpty()); + QVERIFY(result.errorMessage.contains(QStringLiteral("full file encryption"), Qt::CaseInsensitive)); + QVERIFY(result.profiles.empty()); +} + +void TestMRemoteNGImporter::refusesUnrecognizedRootElement() +{ + const MRemoteNGImportResult result = parseMRemoteNGConnections(QByteArray(mRemoteNGWrongRootXml())); + QVERIFY(!result.errorMessage.isEmpty()); +} + +void TestMRemoteNGImporter::refusesMalformedXml() +{ + const MRemoteNGImportResult result = + parseMRemoteNGConnections(QByteArray(" + + + + + + + + + +)"; +} + +inline const char* mRemoteNGFullFileEncryptedXml() +{ + return R"( +SomeOpaqueBase64Blob== +)"; +} + +inline const char* mRemoteNGWrongRootXml() +{ + return R"( + +)"; +} + +#endif