From a640f95f4667771cb9b97f89ef330fca1c75f902 Mon Sep 17 00:00:00 2001 From: vibesoftwarecoder Date: Fri, 23 Oct 2026 16:37:50 -0500 Subject: [PATCH] Add the USB forwarding session manager and harden elevation Adds the per-session manager that decides when USB forwarding runs and keeps it running, and the two settings that drive it. Nothing calls it yet, so nothing changes for a user: both settings are off by default and the manager is not wired into the stream. The manager starts nothing unless forwarding is enabled. It asks the host whether forwarding is allowed, then opens one tunnel for each chosen device that is currently shared. The tunnel host is always the host the stream is connected to, never user text, and both the pinned server certificate and the client certificate are required. It runs off the stream thread, never throws into it, retries with backoff, and can be polled without a Qt event loop. Stopping it is safe from any thread, including from inside its own callbacks. Sharing or unsharing a device no longer blocks the window while the Windows permission prompt is open: the result is reported when the elevated tool really finishes, a slow one keeps the backend busy until it exits, and the wait cannot hold up quitting the app. A tunnel now turns on TCP keepalive after the handshake, so a host that disappears is noticed. Co-Authored-By: Claude Sonnet 5.5 --- app/app.pro | 6 +- app/backend/usbforwardingbackend.cpp | 181 ++- app/backend/usbforwardingbackend.h | 45 +- app/backend/usbforwardingelevation.cpp | 87 ++ app/backend/usbforwardingelevation.h | 45 + app/backend/usbforwardingmanager.cpp | 813 ++++++++++++ app/backend/usbforwardingmanager.h | 234 ++++ app/backend/usbforwardingtunnel.cpp | 87 +- app/backend/usbforwardingtunnel.h | 21 + app/settings/streamingpreferences.cpp | 6 + app/settings/streamingpreferences.h | 8 + tests/run_usb_forwarding_tests.ps1 | 3 +- tests/usb_forwarding_backend_list/main.cpp | 34 + .../usb_forwarding_backend_list.pro | 8 +- tests/usb_forwarding_elevation/main.cpp | 389 ++++++ .../usb_forwarding_elevation.pro | 17 + tests/usb_forwarding_keepalive/main.cpp | 218 ++++ .../usb_forwarding_keepalive.pro | 12 + tests/usb_forwarding_manager/main.cpp | 1162 +++++++++++++++++ .../usb_forwarding_manager.pro | 15 + 20 files changed, 3363 insertions(+), 28 deletions(-) create mode 100644 app/backend/usbforwardingelevation.cpp create mode 100644 app/backend/usbforwardingelevation.h create mode 100644 app/backend/usbforwardingmanager.cpp create mode 100644 app/backend/usbforwardingmanager.h create mode 100644 tests/usb_forwarding_elevation/main.cpp create mode 100644 tests/usb_forwarding_elevation/usb_forwarding_elevation.pro create mode 100644 tests/usb_forwarding_keepalive/main.cpp create mode 100644 tests/usb_forwarding_keepalive/usb_forwarding_keepalive.pro create mode 100644 tests/usb_forwarding_manager/main.cpp create mode 100644 tests/usb_forwarding_manager/usb_forwarding_manager.pro diff --git a/app/app.pro b/app/app.pro index a17ba9e12e..453957f4c3 100644 --- a/app/app.pro +++ b/app/app.pro @@ -209,6 +209,8 @@ SOURCES += \ backend/usbforwardingtunnel.cpp \ backend/usbforwardingenvironment.cpp \ backend/usbforwardingbackend.cpp \ + backend/usbforwardingelevation.cpp \ + backend/usbforwardingmanager.cpp \ wm.cpp HEADERS += \ @@ -254,7 +256,9 @@ HEADERS += \ backend/usbforwardingcapability.h \ backend/pairedcertificate.h \ backend/usbforwardingenvironment.h \ - backend/usbforwardingbackend.h + backend/usbforwardingbackend.h \ + backend/usbforwardingelevation.h \ + backend/usbforwardingmanager.h # Platform-specific renderers and decoders ffmpeg { diff --git a/app/backend/usbforwardingbackend.cpp b/app/backend/usbforwardingbackend.cpp index 54f20f219d..fd00100345 100644 --- a/app/backend/usbforwardingbackend.cpp +++ b/app/backend/usbforwardingbackend.cpp @@ -12,13 +12,38 @@ #include #include #include +#include +#include -#ifdef Q_OS_WIN -#include -#include +namespace { + +// Elevation can sit behind a UAC prompt for a long time; the wait is on a +// worker thread, so a generous bound costs nothing. +constexpr int kElevationTimeoutMs = 2 * 60 * 1000; + +constexpr int kElevationHardLimitMs = 10 * 60 * 1000; + +#ifdef USBFWD_TESTING +std::mutex s_HooksMutex; +UsbForwardingBackend::TestHooks s_Hooks; + +UsbForwardingBackend::TestHooks currentHooks() +{ + std::lock_guard lock(s_HooksMutex); + return s_Hooks; +} #endif -namespace { +QString locateUsbipdOrHook() +{ +#ifdef USBFWD_TESTING + const auto hooks = currentHooks(); + if (hooks.locateUsbipd) { + return hooks.locateUsbipd(); + } +#endif + return UsbForwardingEnvironment::locateUsbipd(); +} // Parses "USB\VID_054C&PID_0CE6\..." into "054c:0ce6". QString vidPidFromInstanceId(const QString &instanceId) @@ -32,10 +57,56 @@ QString vidPidFromInstanceId(const QString &instanceId) } return vidMatch.captured(1).toLower() + QLatin1Char(':') + pidMatch.captured(1).toLower(); } - } // namespace -UsbForwardingBackend::UsbForwardingBackend(QObject *parent) : QObject(parent) {} +#ifdef USBFWD_TESTING +void UsbForwardingBackend::setTestHooks(TestHooks hooks) +{ + std::lock_guard lock(s_HooksMutex); + s_Hooks = std::move(hooks); +} +#endif + +struct UsbForwardingBackend::DeliveryLink +{ + std::mutex mutex; + UsbForwardingBackend *backend = nullptr; +}; + +// Identity of a device across ports and reboots. A device with a real serial +// number gets "vid:pid#serial". A device WITHOUT one gets plain "vid:pid", so +// serial-less twins of the same model are indistinguishable and share it. +QString UsbForwardingBackend::identityFromInstanceId(const QString &instanceId) +{ + const QString vidPid = vidPidFromInstanceId(instanceId); + if (vidPid.isEmpty()) { + return QString(); + } + // "USB\VID_xxxx&PID_yyyy\": a real serial number has no '&'. + // Windows invents ids such as "5&1a2b&0&2" for devices without a serial; + // those depend on the port, so they are not an identity. + const QStringList parts = instanceId.split(QLatin1Char('\\')); + if (parts.size() >= 3) { + static const QRegularExpression serialRe(QStringLiteral("\\A[0-9A-Za-z._-]{1,64}\\z")); + const QString serial = parts.at(2); + if (serialRe.match(serial).hasMatch()) { + return vidPid + QLatin1Char('#') + serial.toLower(); + } + } + return vidPid; +} + +UsbForwardingBackend::UsbForwardingBackend(QObject *parent) + : QObject(parent), m_Link(std::make_shared()) +{ + m_Link->backend = this; +} + +UsbForwardingBackend::~UsbForwardingBackend() +{ + std::lock_guard lock(m_Link->mutex); + m_Link->backend = nullptr; +} UsbForwardingBackend *UsbForwardingBackend::get() { @@ -112,6 +183,7 @@ QVariantList UsbForwardingBackend::parseUsbipdState(const QByteArray &stateJson, device.insert(QStringLiteral("description"), description); device.insert(QStringLiteral("instanceId"), instanceId); device.insert(QStringLiteral("vidPid"), vidPidFromInstanceId(instanceId)); + device.insert(QStringLiteral("identity"), identityFromInstanceId(instanceId)); device.insert(QStringLiteral("isBound"), isBound); device.insert(QStringLiteral("isConnected"), isConnected); device.insert(QStringLiteral("isAttached"), isAttached); @@ -146,8 +218,17 @@ void UsbForwardingBackend::refresh() if (m_Busy) { return; } +#ifdef USBFWD_TESTING + { + const auto hooks = currentHooks(); + if (hooks.refresh) { + hooks.refresh(); + return; + } + } +#endif #ifdef Q_OS_WIN - const QString exe = UsbForwardingEnvironment::locateUsbipd(); + const QString exe = locateUsbipdOrHook(); if (exe.isEmpty()) { m_Devices.clear(); emit devicesChanged(); @@ -207,25 +288,50 @@ void UsbForwardingBackend::refresh() void UsbForwardingBackend::runElevated(const QString &arguments, const QString &successMessage) { #ifdef Q_OS_WIN - const QString exe = UsbForwardingEnvironment::locateUsbipd(); + if (m_Busy) { + emit operationFinished(false, tr("Another USB operation is still running.")); + return; + } + const QString exe = locateUsbipdOrHook(); if (exe.isEmpty()) { emit operationFinished(false, tr("usbipd-win is not installed.")); return; } // "exe" is the validated absolute path; "arguments" came from // bindArguments()/unbindArguments(). Nothing else reaches the UAC prompt. - const std::wstring wideExe = QDir::toNativeSeparators(exe).toStdWString(); - const std::wstring wideArgs = arguments.toStdWString(); - const std::wstring wideDir = QDir::toNativeSeparators(QFileInfo(exe).absolutePath()).toStdWString(); - const HINSTANCE result = ShellExecuteW(nullptr, L"runas", wideExe.c_str(), wideArgs.c_str(), - wideDir.c_str(), SW_HIDE); - const bool ok = reinterpret_cast(result) > 32; - if (ok) { - emit operationFinished(true, successMessage); - QTimer::singleShot(1500, this, [this] { refresh(); }); - } else { - emit operationFinished(false, tr("The elevation was cancelled or failed.")); + const QString workDir = QFileInfo(exe).absolutePath(); + UsbForwarding::ElevatedLauncher launcher = UsbForwarding::launchElevated; + int timeoutMs = kElevationTimeoutMs; + int hardLimitMs = kElevationHardLimitMs; +#ifdef USBFWD_TESTING + { + const auto hooks = currentHooks(); + if (hooks.launcher) launcher = hooks.launcher; + if (hooks.elevationTimeoutMs > 0) timeoutMs = hooks.elevationTimeoutMs; + if (hooks.elevationHardLimitMs > 0) hardLimitMs = hooks.elevationHardLimitMs; } +#endif + + setBusy(true); + m_SlowReported = false; + // A plain detached thread, not a thread pool: the application must be able + // to quit while a UAC prompt is still open, and a pool would make exit wait + // for it. Results come back through the link, which the destructor clears + // under the same lock, so delivery can never touch a deleted backend. + const std::shared_ptr link = m_Link; + const auto deliver = [link](std::function call) { + std::lock_guard lock(link->mutex); + if (UsbForwardingBackend *backend = link->backend) { + QMetaObject::invokeMethod(backend, [backend, call] { call(backend); }, Qt::QueuedConnection); + } + }; + std::thread([deliver, launcher, exe, arguments, workDir, timeoutMs, hardLimitMs, successMessage] { + // Blocks this thread for as long as the UAC prompt and usbipd take. + const UsbForwarding::ElevationResult result = launcher( + exe, arguments, workDir, timeoutMs, hardLimitMs, + [deliver] { deliver([](UsbForwardingBackend *b) { b->reportSlow(); }); }); + deliver([result, successMessage](UsbForwardingBackend *b) { b->finishElevated(result, successMessage); }); + }).detach(); #else Q_UNUSED(arguments); Q_UNUSED(successMessage); @@ -233,6 +339,39 @@ void UsbForwardingBackend::runElevated(const QString &arguments, const QString & #endif } +void UsbForwardingBackend::reportSlow() +{ + // The process may still change usbipd's state: stay busy (no second + // elevated operation can start) until it really exits. + m_SlowReported = true; + emit operationFinished(false, tr("usbipd is still running; the device list will update when it finishes.")); +} + +void UsbForwardingBackend::finishElevated(const UsbForwarding::ElevationResult &result, + const QString &successMessage) +{ + const bool slowReported = m_SlowReported; + m_SlowReported = false; + setBusy(false); + if (result.timedOut) { + if (!slowReported) { + emit operationFinished(false, tr("usbipd is still running; the device list will update when it finishes.")); + } + // It exited late, or we gave up waiting: either way look at the real state now. + refresh(); + } else if (result.succeeded()) { + emit operationFinished(true, successMessage); + // The list reflects what usbipd now says, not a guess at how long it takes. + refresh(); + } else if (result.launched) { + emit operationFinished(false, tr("usbipd failed (exit %1).").arg(result.exitCode)); + } else if (result.cancelled) { + emit operationFinished(false, tr("The elevation was cancelled.")); + } else { + emit operationFinished(false, tr("The elevation was cancelled or failed.")); + } +} + void UsbForwardingBackend::bind(const QString &busId) { if (busId.isEmpty()) { @@ -243,7 +382,7 @@ void UsbForwardingBackend::bind(const QString &busId) emit operationFinished(false, tr("This USB device cannot be shared.")); return; } - runElevated(arguments, tr("Requested sharing. Refreshing device list...")); + runElevated(arguments, tr("The device is now shared.")); } void UsbForwardingBackend::unbind(const QString &busId, const QString &persistedGuid) @@ -256,5 +395,5 @@ void UsbForwardingBackend::unbind(const QString &busId, const QString &persisted emit operationFinished(false, tr("This USB device cannot be unshared.")); return; } - runElevated(arguments, tr("Requested stop sharing. Refreshing device list...")); + runElevated(arguments, tr("The device is no longer shared.")); } diff --git a/app/backend/usbforwardingbackend.h b/app/backend/usbforwardingbackend.h index 11b690b64d..ba26c30a71 100644 --- a/app/backend/usbforwardingbackend.h +++ b/app/backend/usbforwardingbackend.h @@ -2,9 +2,13 @@ // Derived from qiin2333/moonlight-qt (GPL-3.0). +#include "usbforwardingelevation.h" + #include #include #include +#include +#include // USB device sharing orchestration (Windows only; usbipd-win does the // sharing). Lists devices from `usbipd state` JSON, and binds or unbinds @@ -23,6 +27,9 @@ // description device description // instanceId Windows instance ID (USB\VID_XXXX&PID_YYYY\...) // vidPid "xxxx:yyyy" +// identity stable identity for the settings list: "xxxx:yyyy", or +// "xxxx:yyyy#serial" when the device has a real serial number. +// Unlike busId it does not change when the device moves port. // isBound bool, PersistedGuid is not empty // isConnected bool, present in the current listing // isAttached bool, in use by a client right now @@ -53,6 +60,30 @@ class UsbForwardingBackend : public QObject // validated bus id or GUID. Return an empty string for invalid input. static QString bindArguments(const QString &busId); static QString unbindArguments(const QString &busId, const QString &persistedGuid); + // Stable device identity from a Windows instance ID (see "identity" above). + // Empty when the instance ID has no usable VID/PID. + // NOTE for the settings UI: two devices of the same model that have no + // serial number share the identity "vid:pid". Selecting one selects all + // such twins, and the UI has to say so. + static QString identityFromInstanceId(const QString &instanceId); + +#ifdef USBFWD_TESTING + // Test seams, compiled only into the unit tests (USBFWD_TESTING is defined + // in their .pro files, never in the application). Hooks are process wide; + // pass a default-constructed value to restore production behaviour. + struct TestHooks + { + std::function locateUsbipd; // replaces the usbipd.exe lookup + UsbForwarding::ElevatedLauncher launcher; // replaces the elevated launch + std::function refresh; // replaces refresh() + int elevationTimeoutMs = 0; // 0 = production value + int elevationHardLimitMs = 0; // 0 = production value + }; + static void setTestHooks(TestHooks hooks); + // Tests make their own instances (the application uses get()). + explicit UsbForwardingBackend(QObject *parent = nullptr); +#endif + ~UsbForwardingBackend() override; Q_INVOKABLE void refresh(); Q_INVOKABLE void bind(const QString &busId); @@ -69,14 +100,26 @@ class UsbForwardingBackend : public QObject void operationFinished(bool success, const QString &message); private: +#ifndef USBFWD_TESTING explicit UsbForwardingBackend(QObject *parent = nullptr); +#endif + struct DeliveryLink; + void reportSlow(); void setBusy(bool busy); void setError(const QString &error); - // Runs the validated usbipd.exe elevated with the given fixed arguments. + // Runs the validated usbipd.exe elevated with the given fixed arguments on + // a worker thread (the UAC prompt and the wait never block the caller) and + // reports the real result through operationFinished(). On success the + // device list is refreshed from usbipd; on failure it is left alone. void runElevated(const QString &arguments, const QString &successMessage); + void finishElevated(const UsbForwarding::ElevationResult &result, const QString &successMessage); + QVariantList m_Devices; bool m_Busy = false; + bool m_SlowReported = false; QString m_Error; + // Lets the elevation worker post its result without racing our deletion. + std::shared_ptr m_Link; }; diff --git a/app/backend/usbforwardingelevation.cpp b/app/backend/usbforwardingelevation.cpp new file mode 100644 index 0000000000..72d8a8b80e --- /dev/null +++ b/app/backend/usbforwardingelevation.cpp @@ -0,0 +1,87 @@ +#include "usbforwardingelevation.h" + +#include + +#ifdef Q_OS_WIN +#include +#include +#include +#endif + +namespace UsbForwarding { + +ElevationResult launchAndWait(const QString &verb, const QString &exe, const QString &arguments, + const QString &workingDirectory, int timeoutMs, int hardLimitMs, + const SlowCallback &onSlow) +{ + ElevationResult result; +#ifdef Q_OS_WIN + /* ShellExecuteEx wants COM on the calling thread; this runs on a worker. */ + const HRESULT com = CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED | COINIT_DISABLE_OLE1DDE); + + const std::wstring wideVerb = verb.toStdWString(); + const std::wstring wideExe = QDir::toNativeSeparators(exe).toStdWString(); + const std::wstring wideArgs = arguments.toStdWString(); + const std::wstring wideDir = QDir::toNativeSeparators(workingDirectory).toStdWString(); + + SHELLEXECUTEINFOW info = {}; + info.cbSize = sizeof(info); + /* NOCLOSEPROCESS: we need the handle to wait on and read the exit code. + * NOASYNC: the launch completes on this thread, which then waits. */ + info.fMask = SEE_MASK_NOCLOSEPROCESS | SEE_MASK_NOASYNC | SEE_MASK_FLAG_NO_UI; + info.lpVerb = wideVerb.c_str(); + info.lpFile = wideExe.c_str(); + info.lpParameters = wideArgs.c_str(); + info.lpDirectory = wideDir.empty() ? nullptr : wideDir.c_str(); + info.nShow = SW_HIDE; + + if (ShellExecuteExW(&info) && info.hProcess != nullptr) { + result.launched = true; + const auto finish = [&](DWORD wait) { + if (wait != WAIT_OBJECT_0) { + return false; + } + DWORD code = 1; + result.exitCode = GetExitCodeProcess(info.hProcess, &code) ? static_cast(code) : 1; + result.exited = true; + return true; + }; + if (!finish(WaitForSingleObject(info.hProcess, timeoutMs < 0 ? 0 : static_cast(timeoutMs)))) { + /* Still running (or the wait failed). Say so, then keep the handle + * and keep waiting up to the hard limit: the caller stays "busy" + * for as long as the process may still change usbipd's state. */ + result.timedOut = true; + if (onSlow) { + onSlow(); + } + finish(WaitForSingleObject(info.hProcess, hardLimitMs < 0 ? 0 : static_cast(hardLimitMs))); + } + CloseHandle(info.hProcess); + } else { + result.cancelled = (GetLastError() == ERROR_CANCELLED); + } + + if (SUCCEEDED(com)) { + CoUninitialize(); + } +#else + Q_UNUSED(verb); + Q_UNUSED(exe); + Q_UNUSED(arguments); + Q_UNUSED(workingDirectory); + Q_UNUSED(timeoutMs); + Q_UNUSED(hardLimitMs); + Q_UNUSED(onSlow); +#endif + return result; +} + +ElevationResult launchElevated(const QString &exe, const QString &arguments, + const QString &workingDirectory, int timeoutMs, int hardLimitMs, + const SlowCallback &onSlow) +{ + return launchAndWait(QStringLiteral("runas"), exe, arguments, workingDirectory, timeoutMs, hardLimitMs, + onSlow); +} + +} // namespace UsbForwarding diff --git a/app/backend/usbforwardingelevation.h b/app/backend/usbforwardingelevation.h new file mode 100644 index 0000000000..27b59ea985 --- /dev/null +++ b/app/backend/usbforwardingelevation.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include + +// Runs one program elevated (UAC) or not, waits for it on the CALLING thread +// and reports the real outcome. Blocking by design: call it from a worker +// thread, never from the GUI thread. Windows only; elsewhere it reports "not +// launched". +namespace UsbForwarding { + +struct ElevationResult +{ + bool launched = false; // the process was started (UAC accepted) + bool timedOut = false; // it was still running after the normal timeout + bool exited = false; // we saw it exit (possibly after the normal timeout) + bool cancelled = false; // the user declined the UAC prompt + quint32 exitCode = 0; // valid when exited + bool succeeded() const { return launched && !timedOut && exited && exitCode == 0; } +}; + +// Called once, on the waiting thread, when the normal timeout passes and the +// process is still running. The wait then continues up to the hard limit. +using SlowCallback = std::function; + +// verb is "runas" for the real thing; tests pass "open" so nothing elevates. +// exe must be an absolute path; arguments are used verbatim, so callers build +// them from validated input only. The process handle is kept for the whole +// wait (normal timeout plus hard limit) and always closed. A process that is +// still running at the hard limit is left alone: it is not ours to kill. +ElevationResult launchAndWait(const QString &verb, const QString &exe, const QString &arguments, + const QString &workingDirectory, int timeoutMs, int hardLimitMs, + const SlowCallback &onSlow = SlowCallback()); + +// Seam used by UsbForwardingBackend so tests never really elevate. +using ElevatedLauncher = std::function; + +// The production launcher: launchAndWait("runas", ...). +ElevationResult launchElevated(const QString &exe, const QString &arguments, + const QString &workingDirectory, int timeoutMs, int hardLimitMs, + const SlowCallback &onSlow); + +} // namespace UsbForwarding diff --git a/app/backend/usbforwardingmanager.cpp b/app/backend/usbforwardingmanager.cpp new file mode 100644 index 0000000000..322ee90f46 --- /dev/null +++ b/app/backend/usbforwardingmanager.cpp @@ -0,0 +1,813 @@ +#include "usbforwardingmanager.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UsbForwarding { + +/* ------------------------------------------------------------------------- */ +/* Pure helpers */ +/* ------------------------------------------------------------------------- */ + +QList sharedDevicesFromBackendList(const QVariantList &devices) +{ + QList result; + for (const QVariant &entry : devices) { + const QVariantMap map = entry.toMap(); + const QString identity = map.value(QStringLiteral("identity")).toString(); + const QByteArray busId = map.value(QStringLiteral("busId")).toString().toUtf8(); + /* Only a device that is plugged in, shared (bound) and has a bus id + * usbipd can export, with an identity the preferences can name. */ + if (identity.isEmpty() || !map.value(QStringLiteral("isConnected")).toBool() || + !map.value(QStringLiteral("isBound")).toBool() || + !map.value(QStringLiteral("isSupported")).toBool() || !isValidBusId(busId)) { + continue; + } + result.append({identity, busId}); + } + return result; +} + +QString ManagerConfig::canonicalHostAddress(const QString &address) +{ + if (address.isEmpty() || address.size() > 260) { + return QString(); + } + for (const QChar c : address) { + if (c.isSpace() || c.unicode() < 0x20 || c.unicode() == 0x7f) { + return QString(); // no spaces or control characters, ever + } + } + /* IPv6 literal, optionally in brackets. Brackets are accepted ONLY around + * an IPv6 address. QHostAddress::toString() gives one spelling per + * address; a scope id is kept as written, so scoped addresses only + * match an identical scope. */ + QString inner = address; + const bool bracketed = inner.startsWith(QLatin1Char('[')) && inner.endsWith(QLatin1Char(']')); + if (bracketed) { + inner = inner.mid(1, inner.size() - 2); + } + const QHostAddress ip(inner); + if (!ip.isNull()) { + if (bracketed && ip.protocol() != QAbstractSocket::IPv6Protocol) { + return QString(); + } + return ip.toString(); + } + if (bracketed) { + return QString(); + } + /* A DNS name: one trailing dot is dropped, non-ASCII is converted to its + * ASCII (punycode) form, and the result is lower case. */ + QString name = address; + if (name.endsWith(QLatin1Char('.'))) { + name.chop(1); + } + if (name.isEmpty()) { + return QString(); + } + bool ascii = true; + for (const QChar c : name) { + ascii = ascii && c.unicode() < 0x80; + } + if (!ascii) { + name = QString::fromLatin1(QUrl::toAce(name)); + } + name = name.toLower(); + if (name.isEmpty() || name.size() > 253) { + return QString(); + } + /* An all-digit-and-dot string that did not parse above is a malformed IP. */ + static const QRegularExpression onlyDigitsAndDots(QStringLiteral("\\A[0-9.]+\\z")); + if (onlyDigitsAndDots.match(name).hasMatch()) { + return QString(); + } + static const QRegularExpression nameRe(QStringLiteral( + "\\A[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?(\\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*\\z")); + return nameRe.match(name).hasMatch() ? name : QString(); +} + +bool ManagerConfig::isPlausibleHostAddress(const QString &address) +{ + return !canonicalHostAddress(address).isEmpty(); +} + +bool ManagerConfig::validate(QString *reason) const +{ + const auto fail = [reason](const char *why) { + if (reason != nullptr) { + *reason = QString::fromLatin1(why); + } + return false; + }; + if (!isPlausibleHostAddress(streamAddress)) { + return fail("the stream address is empty or not a host address"); + } + if (!isPlausibleHostAddress(pairedAddress)) { + return fail("the paired address is empty or not a host address"); + } + if (canonicalHostAddress(streamAddress) != canonicalHostAddress(pairedAddress)) { + return fail("the stream address is not the paired host's address"); + } + if (pinnedServerCertificate.isNull()) { + return fail("the paired server certificate is missing"); + } + if (clientSsl.localCertificate().isNull() || clientSsl.privateKey().isNull()) { + return fail("the paired client certificate or key is missing"); + } + return true; +} + +int Snapshot::forwardingCount() const +{ + return static_cast(std::count_if(devices.begin(), devices.end(), [](const DeviceStatus &d) { + return d.state == DeviceState::Forwarding; + })); +} + +QString Manager::statusFor(State state, const QList &devices, int configuredCount) +{ + switch (state) { + case State::Off: + return tr("USB forwarding is off."); + case State::InvalidConfig: + return tr("USB forwarding could not start: the host settings are not valid."); + case State::Checking: + return tr("Checking USB forwarding on the host..."); + case State::Retrying: + return tr("Could not reach the host for USB forwarding. Trying again..."); + case State::HostDisabled: + return tr("USB forwarding is disabled on the host."); + case State::HostUnavailable: + return tr("USB forwarding is not available on the host right now."); + case State::HostForbidden: + return tr("The host does not allow USB forwarding for this device."); + case State::Paused: + return tr("USB forwarding is paused."); + case State::Failed: + return tr("USB forwarding stopped after repeated errors."); + case State::Stopped: + return tr("USB forwarding stopped."); + case State::Active: + break; + } + if (configuredCount == 0) { + return tr("No USB devices are selected for forwarding."); + } + int forwarding = 0, connecting = 0, waiting = 0, failed = 0, ended = 0, notShared = 0; + for (const DeviceStatus &d : devices) { + switch (d.state) { + case DeviceState::Forwarding: ++forwarding; break; + case DeviceState::Connecting: + case DeviceState::Pending: ++connecting; break; + case DeviceState::WaitingToRetry: ++waiting; break; + case DeviceState::Failed: ++failed; break; + case DeviceState::Ended: ++ended; break; + case DeviceState::NotShared: ++notShared; break; + } + } + if (forwarding > 0) { + return tr("Forwarding %n USB device(s).", nullptr, forwarding); + } + if (connecting > 0) { + return tr("Connecting USB devices..."); + } + if (waiting > 0) { + return tr("A USB device connection failed. Trying again..."); + } + if (failed > 0) { + return tr("A USB device could not be forwarded."); + } + if (ended > 0) { + return tr("The host closed the USB connection."); + } + Q_UNUSED(notShared); + return tr("None of the selected USB devices is shared right now."); +} + +/* ------------------------------------------------------------------------- */ +/* Real tunnel */ +/* ------------------------------------------------------------------------- */ + +namespace { + +class RealTunnelHandle final : public TunnelHandle +{ +public: + explicit RealTunnelHandle(const TunnelConfig &config) : m_Tunnel(config) + { + connect(&m_Tunnel, &Tunnel::forwarding, this, &TunnelHandle::forwarding); + connect(&m_Tunnel, &Tunnel::finished, this, &TunnelHandle::finished); + } + bool start(QString *error) override { return m_Tunnel.start(error); } + void stop() noexcept override { m_Tunnel.stop(); } + +private: + Tunnel m_Tunnel; +}; + +QString redact(QString text, const QByteArray &token) +{ + if (!token.isEmpty()) { + text.replace(QString::fromLatin1(token), QStringLiteral("[redacted]")); + } + return text; +} + +} // namespace + +/* ------------------------------------------------------------------------- */ +/* Shared state and the worker-thread engine */ +/* ------------------------------------------------------------------------- */ + +struct Manager::Shared +{ + std::mutex mutex; + Snapshot snapshot; +}; + +class Engine; +/* Lets a pool thread post a result to the engine without racing its deletion. */ +struct Link +{ + std::mutex mutex; + Engine *engine = nullptr; +}; + +class Engine final : public QObject +{ + Q_OBJECT + +public: + Engine(ManagerSeams seams, ManagerOptions options, std::shared_ptr shared, + std::shared_ptr link) + : m_Seams(std::move(seams)), m_Options(std::move(options)), m_Shared(std::move(shared)), + m_Link(std::move(link)) + { + { + std::lock_guard lock(m_Link->mutex); + m_Link->engine = this; + } + m_FetchTimer = new QTimer(this); + m_FetchTimer->setSingleShot(true); + connect(m_FetchTimer, &QTimer::timeout, this, [this] { startFetch(); }); + } + + ~Engine() override + { + std::lock_guard lock(m_Link->mutex); + m_Link->engine = nullptr; + } + + /* All of these run on the worker thread (or, for stopAll after the thread + * has finished, on the joining thread with nothing else running). */ + void begin(const ManagerConfig &config) + { + if (m_Started) { + return; + } + m_Config = config; + m_Started = true; + m_Running = true; + m_Paused = false; + m_CapFailures = 0; + m_Rechecks = 0; + setState(State::Checking); + startFetch(); + } + + void toggle() + { + if (!m_Running) { + return; + } + if (m_Paused) { + m_Paused = false; + m_CapFailures = 0; + m_Rechecks = 0; + setState(State::Checking); + startFetch(); + } else { + m_Paused = true; + teardown(); + setState(State::Paused); + } + } + + /* stop() was called from a slot running on this thread (a seam, a tunnel + * handler). Tearing down in place would free Dev objects the caller is + * still using, so only mark the session dead now and tear down once the + * running slot has returned. */ + void requestStop() + { + m_Running = false; + QMetaObject::invokeMethod(this, [this] { stopAll(); }, Qt::QueuedConnection); + } + + void stopAll() + { + if (!m_Running && m_State == State::Stopped) { + return; + } + m_Running = false; + teardown(); + setState(State::Stopped); + } + + void onFetched(quint64 id, bool ok, const Capability &capability) + { + if (!m_Running || m_Paused || id != m_ExpectedFetch) { + return; // stale: the session was stopped, paused or a newer request replaced it + } + m_ExpectedFetch = 0; + if (m_State == State::Active) { + /* A device asked for a fresh capability. The session already has + * its answer: whatever this request says only concerns the + * devices waiting for it, never the session state. */ + if (ok && capability.available) { + m_Capability = capability; + reconcile(); + } else { + failPendingDevices(); + } + return; + } + if (!ok) { + ++m_CapFailures; + if (m_CapFailures > m_Options.retryBackoffMs.size()) { + qWarning() << "USB forwarding: the host could not be reached; giving up"; + setState(State::Failed); + return; + } + qInfo() << "USB forwarding: the host could not be reached; retry" << m_CapFailures; + setState(State::Retrying); + m_FetchTimer->start(m_Options.retryBackoffMs.at(m_CapFailures - 1)); + return; + } + m_CapFailures = 0; + if (!capability.available) { + const QString reason = capability.reason.trimmed().toLower(); + if (reason == QLatin1String("disabled")) { + setState(State::HostDisabled); // the host's choice; nothing to retry + return; + } + if (reason == QLatin1String("forbidden") || reason == QLatin1String("unauthorized")) { + setState(State::HostForbidden); + return; + } + /* "unavailable": the host may simply not have seen the stream yet. + * Look again a bounded number of times, then stay quiet. */ + setState(State::HostUnavailable); + if (m_Rechecks < m_Options.retryBackoffMs.size()) { + m_FetchTimer->start(m_Options.retryBackoffMs.at(m_Rechecks)); + ++m_Rechecks; + } + return; + } + m_Rechecks = 0; + m_Capability = capability; + m_State = State::Active; + reconcile(); + } + +private: + struct Dev + { + QString identity; + QByteArray busId; + DeviceState state = DeviceState::Pending; + int attempts = 0; + std::unique_ptr tunnel; + QTimer *retry = nullptr; + QElapsedTimer forwardingSince; // valid while the tunnel has been forwarding + }; + + void setState(State state) + { + m_State = state; + publish(); + } + + QStringList wantedIdentities() const + { + QStringList wanted; + QSet seen; + for (const QString &raw : m_Config.autoDevices) { + const QString id = raw.trimmed().toLower(); + if (!id.isEmpty() && !seen.contains(id)) { + seen.insert(id); + wanted.append(id); + } + } + return wanted; + } + + void publish() + { + Snapshot snap; + snap.state = m_State; + for (const auto &dev : m_Devs) { + snap.devices.append({dev->identity, dev->busId, dev->state}); + } + snap.status = Manager::statusFor(m_State, snap.devices, wantedIdentities().size()); + { + std::lock_guard lock(m_Shared->mutex); + snap.revision = m_Shared->snapshot.revision + 1; + m_Shared->snapshot = snap; + } + emit changed(); + } + + void startFetch() + { + if (!m_Running || m_Paused || !m_Seams.fetchCapability) { + return; + } + const quint64 id = ++m_FetchSeq; + m_ExpectedFetch = id; + const CapabilityFetcher fetcher = m_Seams.fetchCapability; + const std::shared_ptr link = m_Link; + /* The request can take seconds: it must not occupy this event loop, or + * stop() would wait for it. A late answer finds no engine and is dropped. */ + QThreadPool::globalInstance()->start([fetcher, link, id] { + bool ok = false; + Capability capability; + try { + capability = fetcher(); + ok = true; + } catch (...) { + /* Deliberately no detail: nothing from the exchange is logged. */ + } + std::lock_guard lock(link->mutex); + if (Engine *engine = link->engine) { + QMetaObject::invokeMethod( + engine, [engine, id, ok, capability] { engine->onFetched(id, ok, capability); }, + Qt::QueuedConnection); + } + }); + } + + void reconcile() + { + QList shared; + try { + if (m_Seams.listDevices) { + shared = m_Seams.listDevices(); + } + } catch (...) { + shared.clear(); + } + if (!m_Running || m_Paused) { + return; // stopped or paused from inside the device lister + } + /* NotShared rows hold no resources: rebuild them from the current list. */ + m_Devs.erase(std::remove_if(m_Devs.begin(), m_Devs.end(), + [](const std::unique_ptr &d) { return d->state == DeviceState::NotShared; }), + m_Devs.end()); + + const QStringList wanted = wantedIdentities(); + QSet found; + for (const SharedDevice &sd : shared) { + const QString id = sd.identity.trimmed().toLower(); + if (!wanted.contains(id) || !isValidBusId(sd.busId)) { + continue; + } + found.insert(id); + const bool known = std::any_of(m_Devs.begin(), m_Devs.end(), + [&](const std::unique_ptr &d) { return d->busId == sd.busId; }); + if (!known) { + auto dev = std::make_unique(); + dev->identity = id; + dev->busId = sd.busId; + dev->state = DeviceState::Pending; + m_Devs.push_back(std::move(dev)); + } + } + for (const QString &id : wanted) { + if (!found.contains(id)) { + auto dev = std::make_unique(); + dev->identity = id; + dev->state = DeviceState::NotShared; + m_Devs.push_back(std::move(dev)); + } + } + /* Index loop, and a check after every seam call: a seam may have asked + * for a stop (the teardown itself waits until we return). */ + for (size_t i = 0; i < m_Devs.size() && m_Running && !m_Paused; ++i) { + if (m_Devs[i]->state == DeviceState::Pending) { + startTunnel(*m_Devs[i]); + } + } + publish(); + } + + void startTunnel(Dev &dev) + { + TunnelConfig tc; + /* The trust boundary: the host is the address the stream is connected + * to (validated equal to the paired address), the certificate is the + * pinned one; nothing here comes from a settings field. */ + tc.host = ManagerConfig::canonicalHostAddress(m_Config.streamAddress); + tc.port = m_Capability.port; + tc.sessionToken = m_Capability.token; + tc.sslConfiguration = m_Config.clientSsl; + tc.pinnedServerCertificate = m_Config.pinnedServerCertificate; + tc.busId = dev.busId; + tc.localPort = m_Options.localUsbipPort; + tc.startupTimeoutMs = m_Options.tunnelStartupTimeoutMs; + + std::unique_ptr handle; + try { + handle = m_Seams.createTunnel ? m_Seams.createTunnel(tc) + : std::unique_ptr(new RealTunnelHandle(tc)); + } catch (...) { + handle.reset(); + } + if (!m_Running || m_Paused) { + if (handle) { + handle->stop(); // the session ended while the factory ran + } + return; + } + if (!handle) { + failDevice(dev); + return; + } + Dev *p = &dev; + connect(handle.get(), &TunnelHandle::forwarding, this, [this, p] { + p->state = DeviceState::Forwarding; + p->forwardingSince.start(); + publish(); + }); + connect(handle.get(), &TunnelHandle::finished, this, + [this, p](const QString &message) { onTunnelFinished(p, message); }); + dev.tunnel = std::move(handle); + dev.state = DeviceState::Connecting; + QString error; + bool started = false; + try { + started = dev.tunnel->start(&error); + } catch (...) { + started = false; + } + if (!started && m_Running && dev.state == DeviceState::Connecting) { + failDevice(dev); + } + } + + void retire(Dev &dev) + { + if (dev.tunnel) { + dev.tunnel->disconnect(this); + dev.tunnel->stop(); + /* Possibly inside its own signal: keep the object alive until stopAll. */ + m_Graveyard.push_back(std::move(dev.tunnel)); + } + } + + void failPendingDevices() + { + for (size_t i = 0; i < m_Devs.size(); ++i) { + if (m_Devs[i]->state == DeviceState::Pending) { + failDevice(*m_Devs[i]); // counts an attempt and schedules its own backoff + } + } + publish(); + } + + void failDevice(Dev &dev) + { + retire(dev); + /* Only a tunnel that stayed up for a while earns a fresh budget, so a + * device that connects and drops at once cannot retry forever. */ + if (dev.forwardingSince.isValid()) { + if (dev.forwardingSince.elapsed() >= m_Options.stableAfterMs) { + dev.attempts = 0; + } + dev.forwardingSince.invalidate(); + } + ++dev.attempts; + if (dev.attempts > m_Options.retryBackoffMs.size()) { + dev.state = DeviceState::Failed; + } else { + dev.state = DeviceState::WaitingToRetry; + if (dev.retry == nullptr) { + dev.retry = new QTimer(this); + dev.retry->setSingleShot(true); + Dev *p = &dev; + connect(dev.retry, &QTimer::timeout, this, [this, p] { + if (!m_Running || m_Paused) { + return; + } + /* A fresh capability first: the old token may be stale. */ + p->state = DeviceState::Pending; + publish(); + startFetch(); + }); + } + dev.retry->start(m_Options.retryBackoffMs.at(dev.attempts - 1)); + } + publish(); + } + + void onTunnelFinished(Dev *dev, const QString &message) + { + if (!m_Running) { + return; + } + if (message.isEmpty() && dev->state == DeviceState::Forwarding) { + retire(*dev); + dev->state = DeviceState::Ended; // the host closed it: final + qInfo() << "USB forwarding: the host closed a device tunnel"; + publish(); + return; + } + qInfo().noquote() << "USB forwarding: a device tunnel ended:" << redact(message, m_Capability.token); + failDevice(*dev); + } + + void teardown() + { + m_ExpectedFetch = 0; + m_FetchTimer->stop(); + for (auto &dev : m_Devs) { + if (dev->retry != nullptr) { + dev->retry->stop(); + delete dev->retry; + dev->retry = nullptr; + } + if (dev->tunnel) { + dev->tunnel->disconnect(this); + dev->tunnel->stop(); + dev->tunnel.reset(); + } + } + m_Devs.clear(); + /* Tunnels retired from their own signals: safe to delete now. */ + for (auto &tunnel : m_Graveyard) { + tunnel->disconnect(this); + tunnel->stop(); + } + m_Graveyard.clear(); + } + +signals: + void changed(); + +private: + ManagerSeams m_Seams; + ManagerOptions m_Options; + std::shared_ptr m_Shared; + std::shared_ptr m_Link; + ManagerConfig m_Config; + Capability m_Capability; + State m_State = State::Off; + QTimer *m_FetchTimer = nullptr; + std::vector> m_Devs; + std::vector> m_Graveyard; + quint64 m_FetchSeq = 0; + quint64 m_ExpectedFetch = 0; + int m_CapFailures = 0; + int m_Rechecks = 0; + bool m_Started = false; + bool m_Running = false; + bool m_Paused = false; +}; + +/* ------------------------------------------------------------------------- */ +/* Manager */ +/* ------------------------------------------------------------------------- */ + +struct Manager::Runtime +{ + QThread thread; + Engine *engine = nullptr; + std::shared_ptr link; +}; + +Manager::Manager(ManagerSeams seams, ManagerOptions options, QObject *parent) + : QObject(parent), m_Seams(std::move(seams)), m_Options(std::move(options)), + m_Shared(std::make_shared()) +{ + m_Shared->snapshot.state = State::Off; + m_Shared->snapshot.status = statusFor(State::Off, {}, 0); +} + +Manager::~Manager() +{ + stop(); +} + +void Manager::publishLocal(State state) +{ + std::lock_guard lock(m_Shared->mutex); + Snapshot &snap = m_Shared->snapshot; + snap.state = state; + snap.devices.clear(); + snap.status = statusFor(state, {}, 0); + ++snap.revision; +} + +Snapshot Manager::snapshot() const +{ + std::lock_guard lock(m_Shared->mutex); + return m_Shared->snapshot; +} + +void Manager::teardownLocked() +{ + if (!m_Runtime) { + return; + } + m_Running = false; + if (m_Runtime->thread.isRunning()) { + Engine *engine = m_Runtime->engine; + QMetaObject::invokeMethod(engine, [engine] { engine->stopAll(); }, Qt::BlockingQueuedConnection); + m_Runtime->thread.quit(); + m_Runtime->thread.wait(); + } + m_WorkerThread.store(nullptr); + m_WorkerEngine.store(nullptr); + delete m_Runtime->engine; + m_Runtime.reset(); +} + +bool Manager::start(const ManagerConfig &config) +{ + bool accepted = true; + { + std::lock_guard lifecycle(m_Lifecycle); + teardownLocked(); + m_Enabled = config.enabled; + QString why; + if (!config.enabled) { + publishLocal(State::Off); // nothing at all: no thread, no request + } else if (!config.validate(&why) || !m_Seams.fetchCapability || !m_Seams.listDevices) { + qWarning().noquote() << "USB forwarding: refused to start:" + << (why.isEmpty() ? QStringLiteral("incomplete setup") : why); + publishLocal(State::InvalidConfig); + accepted = false; + } else { + auto runtime = std::make_unique(); + runtime->link = std::make_shared(); + runtime->engine = new Engine(m_Seams, m_Options, m_Shared, runtime->link); + runtime->engine->moveToThread(&runtime->thread); + connect(runtime->engine, &Engine::changed, this, &Manager::snapshotChanged); + runtime->thread.start(); + publishLocal(State::Checking); + Engine *engine = runtime->engine; + m_WorkerThread.store(&runtime->thread); + m_WorkerEngine.store(engine); + m_Runtime = std::move(runtime); + m_Running = true; + QMetaObject::invokeMethod(engine, [engine, config] { engine->begin(config); }, + Qt::QueuedConnection); + } + } + emit snapshotChanged(); + return accepted; +} + +void Manager::stop() noexcept +{ + try { + /* Called from a slot running on the worker itself (a seam, a tunnel + * handler): tear down in place; the thread is joined by the next stop(), + * start() or the destructor. */ + QThread *worker = m_WorkerThread.load(); + if (worker != nullptr && QThread::currentThread() == worker) { + m_Running = false; + static_cast(m_WorkerEngine.load())->requestStop(); + return; + } + std::lock_guard lifecycle(m_Lifecycle); + teardownLocked(); + } catch (...) { + } +} + +bool Manager::toggle() +{ + std::lock_guard lifecycle(m_Lifecycle); + if (!m_Runtime || !m_Running || !m_Enabled) { + return false; + } + Engine *engine = m_Runtime->engine; + QMetaObject::invokeMethod(engine, [engine] { engine->toggle(); }, Qt::QueuedConnection); + return true; +} + +} // namespace UsbForwarding + +#include "usbforwardingmanager.moc" diff --git a/app/backend/usbforwardingmanager.h b/app/backend/usbforwardingmanager.h new file mode 100644 index 0000000000..8bd0527669 --- /dev/null +++ b/app/backend/usbforwardingmanager.h @@ -0,0 +1,234 @@ +#pragma once + +#include "usbforwardingcapability.h" +#include "usbforwardingtunnel.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +/* + * Lifecycle of USB forwarding for ONE streaming session. + * + * Everything the manager needs is injected, so it knows nothing about Session, + * NvHTTP or the preference object: + * + * ManagerConfig what to do and WHO to talk to (see the trust boundary below) + * ManagerSeams capability fetcher, shared-device lister, tunnel factory + * ManagerOptions retry/backoff numbers + * + * Threading + * The manager lives on the caller's thread. start() only validates the + * configuration and posts the work to a private worker thread; it never + * touches the network and never blocks. When forwarding is turned off in the + * preferences, start() does nothing at all: no thread, no request. + * The capability request (up to 5 s) runs on a pool thread, never on the + * worker's event loop, so stop() is never held up by it. + * stop(), toggle() and snapshot() may be called from any thread. + * Seams are called from the worker (capability fetcher: from a pool thread); + * they must be thread safe and must own whatever they capture. + * + * Trust boundary + * The tunnel host is exactly ManagerConfig::streamAddress, the address the + * stream itself is connected to, and it must equal pairedAddress, the + * address of the paired computer record whose certificate is pinned. + * Neither is ever free text from a settings field. The pinned server + * certificate and the client certificate/key are mandatory. Anything else + * is refused before any thread or socket exists. + * + * Failure policy + * Failures never touch the stream. Retries are bounded and backed off + * (ManagerOptions::retryBackoffMs); a clean close by the host is final. + * The capability body and the token are never logged. + * + * Polling without an event loop + * snapshot() returns a plain copy taken under a mutex. The SDL/stream loop + * can poll it. snapshotChanged() is for contexts that do have an event loop. + */ +namespace UsbForwarding { + +/* A device usbipd is currently sharing. identity is the stable identity used + * in the preferences ("vid:pid" or "vid:pid#serial"); busId may change. */ +struct SharedDevice +{ + QString identity; + QByteArray busId; +}; + +/* Picks the shareable devices out of UsbForwardingBackend::devices(). */ +QList sharedDevicesFromBackendList(const QVariantList &devices); + +/* The tunnel, as the manager sees it. The real one wraps UsbForwarding::Tunnel. */ +class TunnelHandle : public QObject +{ + Q_OBJECT +public: + using QObject::QObject; + virtual bool start(QString *error) = 0; + virtual void stop() noexcept = 0; +signals: + void forwarding(); + void finished(QString message); +}; + +using CapabilityFetcher = std::function; // may throw +using DeviceLister = std::function()>; +using TunnelFactory = std::function(const TunnelConfig &)>; + +struct ManagerSeams +{ + CapabilityFetcher fetchCapability; + DeviceLister listDevices; + /* Optional: defaults to the real UsbForwarding::Tunnel. */ + TunnelFactory createTunnel; +}; + +struct ManagerOptions +{ + /* Delay before retry n (1-based); the list length is the retry budget. */ + QList retryBackoffMs = {2000, 5000, 15000, 30000, 60000}; + int tunnelStartupTimeoutMs = 15 * 1000; + /* A tunnel that stayed up this long gets a fresh retry budget when it drops. */ + int stableAfterMs = 30 * 1000; + /* The local USB/IP server (usbipd-win). Only tests change this. */ + quint16 localUsbipPort = 3240; +}; + +struct ManagerConfig +{ + /* StreamingPreferences::usbForwardingEnabled / usbForwardingAutoDevices. */ + bool enabled = false; + QStringList autoDevices; + + /* Address the stream is connected to, and the address of the paired + * computer record. They must be the same host. */ + QString streamAddress; + QString pairedAddress; + /* The paired server certificate (pinned) and the paired client identity. */ + QSslCertificate pinnedServerCertificate; + QSslConfiguration clientSsl; + + /* True when the configuration may be used. Otherwise *reason is a short + * developer-facing explanation (never shown to users, never contains secrets). */ + bool validate(QString *reason = nullptr) const; + /* The ONE spelling used for comparing and connecting; empty means refused + * (fail closed). Callers (the stream address and the paired record) must + * both go through this. Accepted: + * - IPv4 and IPv6 literals; IPv6 may be in [brackets] (brackets are + * refused around anything else); output is the canonical spelling + * - a scoped IPv6 address, kept exactly as written: "fe80::1%12" never + * equals "fe80::1" or another scope + * - a DNS name: lower-cased, one trailing dot dropped, non-ASCII + * converted to punycode (QUrl::toAce); labels of letters, digits and + * hyphens, at most 253 characters + * Refused: empty, any whitespace or control character, scheme, port, path, + * user info, a malformed IPv4 such as 999.1.1.1, empty labels ("a..b"). + * A DNS name never equals an IP literal, even if it resolves to it. */ + static QString canonicalHostAddress(const QString &address); + static bool isPlausibleHostAddress(const QString &address); +}; + +enum class State +{ + Off, // not started, or turned off in the preferences + InvalidConfig, // refused: host/certificate inputs did not pass validation + Checking, // asking the host + Retrying, // the host could not be reached; trying again later + HostDisabled, // the host has USB forwarding switched off + HostUnavailable, // the host cannot do it right now + HostForbidden, // the host does not allow it for this client + Active, // the host said yes; devices are being forwarded + Paused, // toggled off for this session + Failed, // gave up after repeated errors + Stopped, // the session ended +}; + +enum class DeviceState +{ + NotShared, // selected in the preferences but not shared right now + Pending, // waiting for a fresh capability before connecting + Connecting, + Forwarding, + WaitingToRetry, + Failed, // retry budget used up + Ended, // the host closed the tunnel; not retried +}; + +struct DeviceStatus +{ + QString identity; + QByteArray busId; + DeviceState state = DeviceState::NotShared; +}; + +struct Snapshot +{ + State state = State::Off; + /* Plain text from fixed messages; never host-provided text. */ + QString status; + QList devices; + /* Increases with every change. */ + quint64 revision = 0; + + int forwardingCount() const; +}; + +class Manager final : public QObject +{ + Q_OBJECT + +public: + explicit Manager(ManagerSeams seams, ManagerOptions options = {}, QObject *parent = nullptr); + ~Manager() override; + + Q_DISABLE_COPY(Manager) + + /* Begins a session. Never blocks and never throws. Returns false when the + * configuration was refused (state InvalidConfig); true otherwise, including + * the "turned off" case in which nothing is started. A running session is + * stopped first. */ + bool start(const ManagerConfig &config); + /* Tears everything down before returning. Idempotent; any thread. */ + void stop() noexcept; + /* Pauses or resumes forwarding for this session. Returns false when there + * is nothing to toggle (not started, stopped, or off in the preferences). */ + bool toggle(); + + /* Safe from any thread, with or without an event loop. */ + Snapshot snapshot() const; + /* The fixed text for a state (used by the snapshot). */ + static QString statusFor(State state, const QList &devices, int configuredCount); + +signals: + /* Emitted after every change, from the manager's thread via a queued connection. */ + void snapshotChanged(); + +private: + friend class Engine; + struct Shared; + struct Runtime; + void publishLocal(State state); + void teardownLocked(); + + ManagerSeams m_Seams; + ManagerOptions m_Options; + std::shared_ptr m_Shared; + std::mutex m_Lifecycle; + std::atomic m_Running{false}; + std::atomic m_Enabled{false}; + std::unique_ptr m_Runtime; + /* Identity of the worker, to detect stop() called from the worker itself. */ + std::atomic m_WorkerThread{nullptr}; + std::atomic m_WorkerEngine{nullptr}; +}; + +} // namespace UsbForwarding diff --git a/app/backend/usbforwardingtunnel.cpp b/app/backend/usbforwardingtunnel.cpp index 00f798bfae..4402325712 100644 --- a/app/backend/usbforwardingtunnel.cpp +++ b/app/backend/usbforwardingtunnel.cpp @@ -14,6 +14,15 @@ #include #include +#ifdef Q_OS_WIN +#include +#include +#include +#ifdef _MSC_VER +#pragma comment(lib, "ws2_32.lib") +#endif +#endif + namespace UsbForwarding { namespace { @@ -21,6 +30,67 @@ namespace { * back-pressures the USB/IP peer instead of us buffering without limit. */ constexpr qint64 kHighWaterMark = 4 * 1024 * 1024; +#ifdef Q_OS_WIN +/* Older SDK headers lack these; the numbers are fixed by Windows 10 1709+. */ +#ifndef TCP_KEEPIDLE +#define TCP_KEEPIDLE 3 +#endif +#ifndef TCP_KEEPCNT +#define TCP_KEEPCNT 16 +#endif +#ifndef TCP_KEEPINTVL +#define TCP_KEEPINTVL 17 +#endif + +bool setTcpInt(SOCKET s, int option, int value) +{ + return ::setsockopt(s, IPPROTO_TCP, option, reinterpret_cast(&value), sizeof(value)) == 0; +} + +int getTcpInt(SOCKET s, int level, int option) +{ + int value = 0; + int length = sizeof(value); + if (::getsockopt(s, level, option, reinterpret_cast(&value), &length) != 0) { + return 0; + } + return value; +} +#endif + +/* Turn on keepalive with the configured timing and report what the OS holds. */ +KeepAliveReport applyKeepAlive(QAbstractSocket *socket, const TunnelConfig &config) +{ + KeepAliveReport report; + socket->setSocketOption(QAbstractSocket::KeepAliveOption, 1); +#ifdef Q_OS_WIN + const SOCKET s = static_cast(socket->socketDescriptor()); + if (s != INVALID_SOCKET) { + const bool timed = setTcpInt(s, TCP_KEEPIDLE, config.keepAliveIdleSec) && + setTcpInt(s, TCP_KEEPINTVL, config.keepAliveIntervalSec) && + setTcpInt(s, TCP_KEEPCNT, config.keepAliveCount); + if (!timed) { + /* Pre-1709 fallback: idle and interval only; the probe count is fixed. */ + tcp_keepalive vals = {}; + vals.onoff = 1; + vals.keepalivetime = static_cast(config.keepAliveIdleSec) * 1000; + vals.keepaliveinterval = static_cast(config.keepAliveIntervalSec) * 1000; + DWORD returned = 0; + ::WSAIoctl(s, SIO_KEEPALIVE_VALS, &vals, sizeof(vals), nullptr, 0, &returned, nullptr, nullptr); + } + report.enabled = getTcpInt(s, SOL_SOCKET, SO_KEEPALIVE) != 0; + report.idleSec = getTcpInt(s, IPPROTO_TCP, TCP_KEEPIDLE); + report.intervalSec = getTcpInt(s, IPPROTO_TCP, TCP_KEEPINTVL); + report.count = getTcpInt(s, IPPROTO_TCP, TCP_KEEPCNT); + return report; + } +#else + Q_UNUSED(config); +#endif + report.enabled = socket->socketOption(QAbstractSocket::KeepAliveOption).toInt() != 0; + return report; +} + /* The host's error text is untrusted: it must never reach the user as free * text (it could be markup or an attempt to impersonate system messages). * Only a few known codes are mapped; everything else gets a generic message. */ @@ -60,6 +130,9 @@ bool TunnelConfig::valid() const noexcept if (startupTimeoutMs <= 0) { return false; } + if (keepAliveIdleSec <= 0 || keepAliveIntervalSec <= 0 || keepAliveCount <= 0) { + return false; + } /* The local hop is plaintext USB/IP: it must stay on this machine. */ if (!QHostAddress(localHost).isLoopback()) { return false; @@ -184,7 +257,13 @@ bool TunnelWorker::start(QString *error) connect(m_Remote, &QSslSocket::readyRead, this, &TunnelWorker::handleRemoteReadyRead); connect(m_Remote, &QSslSocket::disconnected, this, [this] { finishCleanly(); }); #if QT_VERSION >= QT_VERSION_CHECK(5, 15, 0) - connect(m_Remote, &QSslSocket::errorOccurred, this, [this](QAbstractSocket::SocketError) { + connect(m_Remote, &QSslSocket::errorOccurred, this, [this](QAbstractSocket::SocketError socketError) { + if (m_HandshakeDone && (socketError == QAbstractSocket::SocketTimeoutError || + socketError == QAbstractSocket::NetworkError)) { + /* Keepalive probes went unanswered: the host is gone. */ + failWith(Tunnel::tr("The host stopped responding.")); + return; + } failWith( Tunnel::tr("The connection to the host was lost: %1").arg(m_Remote->errorString())); }); @@ -300,6 +379,12 @@ void TunnelWorker::handleRemoteReadyRead() } m_HandshakeDone = true; m_StartupTimer->stop(); + { + const KeepAliveReport report = applyKeepAlive(m_Remote, m_Config); + if (m_Config.onKeepAliveApplied) { + m_Config.onKeepAliveApplied(report); + } + } emit forwarding(); if (m_Finished) return; diff --git a/app/backend/usbforwardingtunnel.h b/app/backend/usbforwardingtunnel.h index 0d635544e1..1d8e11e230 100644 --- a/app/backend/usbforwardingtunnel.h +++ b/app/backend/usbforwardingtunnel.h @@ -30,10 +30,21 @@ #include #include #include +#include #include namespace UsbForwarding { +/* What the OS reports for the remote socket after keepalive was applied. + * Delivered on the tunnel's worker thread; for diagnostics and tests. */ +struct KeepAliveReport +{ + bool enabled = false; + int idleSec = 0; // 0 when the platform could not report it + int intervalSec = 0; + int count = 0; +}; + struct TunnelConfig { /* Configured ApolloVibe USB endpoint. */ @@ -55,6 +66,16 @@ struct TunnelConfig /* Startup deadline in milliseconds; tests shorten it. */ int startupTimeoutMs = 15 * 1000; + /* TCP keepalive on the remote socket once the handshake is done. A host + * that vanishes without closing is noticed after about + * idle + interval * count seconds (60 s by default). There is deliberately + * no idle-bytes timer: a quiet USB device is normal. */ + int keepAliveIdleSec = 30; + int keepAliveIntervalSec = 5; + int keepAliveCount = 6; + /* Called once, on the worker thread, after keepalive was applied. */ + std::function onKeepAliveApplied; + bool valid() const noexcept; }; diff --git a/app/settings/streamingpreferences.cpp b/app/settings/streamingpreferences.cpp index 5100ddaa4f..cce7a56f6c 100644 --- a/app/settings/streamingpreferences.cpp +++ b/app/settings/streamingpreferences.cpp @@ -49,6 +49,8 @@ #define SER_MICROPHONEDEVICE "microphonedevice" #define SER_SWAPMOUSEBUTTONS "swapmousebuttons" #define SER_MUTEONFOCUSLOSS "muteonfocusloss" +#define SER_USBFORWARDING "usbforwardingenabled" +#define SER_USBFORWARDINGDEVICES "usbforwardingautodevices" #define SER_BACKGROUNDGAMEPAD "backgroundgamepad" #define SER_REVERSESCROLL "reversescroll" #define SER_SWAPFACEBUTTONS "swapfacebuttons" @@ -168,6 +170,8 @@ void StreamingPreferences::reload() packetSize = settings.value(SER_PACKETSIZE, 0).toInt(); swapMouseButtons = settings.value(SER_SWAPMOUSEBUTTONS, false).toBool(); muteOnFocusLoss = settings.value(SER_MUTEONFOCUSLOSS, false).toBool(); + usbForwardingEnabled = settings.value(SER_USBFORWARDING, false).toBool(); + usbForwardingAutoDevices = settings.value(SER_USBFORWARDINGDEVICES).toStringList(); backgroundGamepad = settings.value(SER_BACKGROUNDGAMEPAD, false).toBool(); reverseScrollDirection = settings.value(SER_REVERSESCROLL, false).toBool(); swapFaceButtons = settings.value(SER_SWAPFACEBUTTONS, false).toBool(); @@ -384,6 +388,8 @@ void StreamingPreferences::save() settings.setValue(SER_DEFAULTVER, CURRENT_DEFAULT_VER); settings.setValue(SER_SWAPMOUSEBUTTONS, swapMouseButtons); settings.setValue(SER_MUTEONFOCUSLOSS, muteOnFocusLoss); + settings.setValue(SER_USBFORWARDING, usbForwardingEnabled); + settings.setValue(SER_USBFORWARDINGDEVICES, usbForwardingAutoDevices); settings.setValue(SER_BACKGROUNDGAMEPAD, backgroundGamepad); settings.setValue(SER_REVERSESCROLL, reverseScrollDirection); settings.setValue(SER_SWAPFACEBUTTONS, swapFaceButtons); diff --git a/app/settings/streamingpreferences.h b/app/settings/streamingpreferences.h index e6a8ec4763..c073dbdf03 100644 --- a/app/settings/streamingpreferences.h +++ b/app/settings/streamingpreferences.h @@ -179,6 +179,8 @@ class StreamingPreferences : public QObject Q_PROPERTY(UIDisplayMode uiDisplayMode MEMBER uiDisplayMode NOTIFY uiDisplayModeChanged) Q_PROPERTY(bool swapMouseButtons MEMBER swapMouseButtons NOTIFY mouseButtonsChanged) Q_PROPERTY(bool muteOnFocusLoss MEMBER muteOnFocusLoss NOTIFY muteOnFocusLossChanged) + Q_PROPERTY(bool usbForwardingEnabled MEMBER usbForwardingEnabled NOTIFY usbForwardingEnabledChanged) + Q_PROPERTY(QStringList usbForwardingAutoDevices MEMBER usbForwardingAutoDevices NOTIFY usbForwardingAutoDevicesChanged) Q_PROPERTY(bool backgroundGamepad MEMBER backgroundGamepad NOTIFY backgroundGamepadChanged) Q_PROPERTY(bool reverseScrollDirection MEMBER reverseScrollDirection NOTIFY reverseScrollDirectionChanged) Q_PROPERTY(bool swapFaceButtons MEMBER swapFaceButtons NOTIFY swapFaceButtonsChanged) @@ -223,6 +225,10 @@ class StreamingPreferences : public QObject QString microphoneDevice; bool swapMouseButtons; bool muteOnFocusLoss; + // USB forwarding (Windows only, default off). Auto devices are stable + // identities ("vid:pid" or "vid:pid#serial"), never bus ids. + bool usbForwardingEnabled; + QStringList usbForwardingAutoDevices; bool backgroundGamepad; bool reverseScrollDirection; bool swapFaceButtons; @@ -277,6 +283,8 @@ class StreamingPreferences : public QObject void microphoneMonitorSignalDetectedChanged(); void mouseButtonsChanged(); void muteOnFocusLossChanged(); + void usbForwardingEnabledChanged(); + void usbForwardingAutoDevicesChanged(); void backgroundGamepadChanged(); void reverseScrollDirectionChanged(); void swapFaceButtonsChanged(); diff --git a/tests/run_usb_forwarding_tests.ps1 b/tests/run_usb_forwarding_tests.ps1 index a09ba88bb4..7f2b9d6b88 100644 --- a/tests/run_usb_forwarding_tests.ps1 +++ b/tests/run_usb_forwarding_tests.ps1 @@ -8,7 +8,8 @@ param( [Parameter(Mandatory = $true)][string]$OutDir, [string[]]$Tests = @('usb_forwarding_capability', 'usb_forwarding_environment', 'usb_forwarding_backend_list', 'usb_forwarding_tunnel', 'usb_forwarding_tunnel_clientcert', - 'usb_forwarding_nvhttp'), + 'usb_forwarding_nvhttp', 'usb_forwarding_manager', 'usb_forwarding_elevation', + 'usb_forwarding_keepalive'), [string]$QtDir = 'C:\Qt\6.10.3\msvc2022_64', [string]$VcVars = 'C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars64.bat', [string]$OpenSsl = 'C:\Program Files\Git\usr\bin\openssl.exe' diff --git a/tests/usb_forwarding_backend_list/main.cpp b/tests/usb_forwarding_backend_list/main.cpp index f7c56a30f2..0c8f08acef 100644 --- a/tests/usb_forwarding_backend_list/main.cpp +++ b/tests/usb_forwarding_backend_list/main.cpp @@ -163,6 +163,40 @@ private slots: QVERIFY(!spy.at(0).at(0).toBool()); QVERIFY(!spy.at(1).at(0).toBool()); } + + void identityIsStableAcrossPortsAndUsesARealSerial_data() + { + QTest::addColumn("instanceId"); + QTest::addColumn("expected"); + // Windows-invented ids contain '&' and change with the port: no serial. + QTest::newRow("generated id") << QStringLiteral("USB\\VID_054C&PID_0CE6\\5&1&0&2") << QStringLiteral("054c:0ce6"); + QTest::newRow("same device other port") << QStringLiteral("USB\\VID_054C&PID_0CE6\\6&2a3c&0&4") << QStringLiteral("054c:0ce6"); + QTest::newRow("real serial") << QStringLiteral("USB\\VID_0781&PID_5581\\4C530001230101105424") + << QStringLiteral("0781:5581#4c530001230101105424"); + QTest::newRow("serial case folded") << QStringLiteral("USB\\VID_0781&PID_5581\\AbC-123_x.y") + << QStringLiteral("0781:5581#abc-123_x.y"); + QTest::newRow("no third part") << QStringLiteral("USB\\VID_0001&PID_0002") << QStringLiteral("0001:0002"); + QTest::newRow("odd characters in the serial") << QStringLiteral("USB\\VID_0001&PID_0002\\a b;c") << QStringLiteral("0001:0002"); + QTest::newRow("no vid/pid") << QStringLiteral("ROOT\\HUB\\0000") << QString(); + QTest::newRow("empty") << QString() << QString(); + } + void identityIsStableAcrossPortsAndUsesARealSerial() + { + QFETCH(QString, instanceId); + QFETCH(QString, expected); + QCOMPARE(UsbForwardingBackend::identityFromInstanceId(instanceId), expected); + } + + void listedDevicesCarryTheirIdentity() + { + const QVariantMap device = onlyDevice( + R"({"Devices":[{"BusId":"1-2","ClientIPAddress":null,"Description":"Stick",)" + R"("InstanceId":"USB\\VID_0781&PID_5581\\ABC123","IsForced":false,)" + R"("PersistedGuid":"9b6a62f8-0123-4567-89ab-0123456789ab","StubInstanceId":null}]})"); + QCOMPARE(device.value("identity").toString(), QStringLiteral("0781:5581#abc123")); + // The bus id is NOT part of it: it changes when the device moves. + QVERIFY(!device.value("identity").toString().contains(QStringLiteral("1-2"))); + } }; QTEST_MAIN(BackendListTest) diff --git a/tests/usb_forwarding_backend_list/usb_forwarding_backend_list.pro b/tests/usb_forwarding_backend_list/usb_forwarding_backend_list.pro index 86c0ebddaa..62c92f5ec1 100644 --- a/tests/usb_forwarding_backend_list/usb_forwarding_backend_list.pro +++ b/tests/usb_forwarding_backend_list/usb_forwarding_backend_list.pro @@ -6,9 +6,11 @@ TEMPLATE = app TARGET = usb_forwarding_backend_list_test SOURCES += main.cpp \ ../../app/backend/usbforwardingbackend.cpp \ - ../../app/backend/usbforwardingenvironment.cpp + ../../app/backend/usbforwardingenvironment.cpp \ + ../../app/backend/usbforwardingelevation.cpp HEADERS += \ ../../app/backend/usbforwardingbackend.h \ ../../app/backend/usbforwardingcapability.h \ - ../../app/backend/usbforwardingenvironment.h -win32:LIBS += -ladvapi32 -lshell32 + ../../app/backend/usbforwardingenvironment.h \ + ../../app/backend/usbforwardingelevation.h +win32:LIBS += -ladvapi32 -lshell32 -lole32 diff --git a/tests/usb_forwarding_elevation/main.cpp b/tests/usb_forwarding_elevation/main.cpp new file mode 100644 index 0000000000..18aabf1ef1 --- /dev/null +++ b/tests/usb_forwarding_elevation/main.cpp @@ -0,0 +1,389 @@ +// Asynchronous elevation: the UAC prompt and the wait for usbipd never block +// the caller, and the REAL result is reported. The elevated launch itself is a +// seam (nothing in here ever elevates); the real wait/exit-code code is +// exercised with the non-elevating "open" verb on cmd.exe. +#include "../../app/backend/usbforwardingbackend.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using UsbForwarding::ElevationResult; +using UsbForwarding::SlowCallback; + +namespace { + +// A launcher that blocks until the test releases it, and records its call. +struct FakeLauncher +{ + std::mutex mutex; + std::condition_variable cv; + bool released = false; + bool slowRequested = false; + ElevationResult result; + std::atomic calls{0}; + QString exe, arguments, workDir; + int timeoutMs = 0; + int hardLimitMs = 0; + Qt::HANDLE thread = nullptr; + + ElevationResult run(const QString &e, const QString &a, const QString &w, int t, int hard, + const SlowCallback &onSlow) + { + { + std::lock_guard lock(mutex); + exe = e; + arguments = a; + workDir = w; + timeoutMs = t; + hardLimitMs = hard; + thread = QThread::currentThreadId(); + } + ++calls; + std::unique_lock lock(mutex); + cv.wait_for(lock, std::chrono::seconds(10), [this] { return released || slowRequested; }); + if (!released && slowRequested) { + // Like the real launcher: tell the caller the process is slow, then keep waiting. + lock.unlock(); + onSlow(); + lock.lock(); + cv.wait_for(lock, std::chrono::seconds(10), [this] { return released; }); + } + return result; + } + void triggerSlow() + { + { + std::lock_guard lock(mutex); + slowRequested = true; + } + cv.notify_all(); + } + void release() + { + { + std::lock_guard lock(mutex); + released = true; + } + cv.notify_all(); + } +}; + +struct Hooked +{ + std::shared_ptr launcher = std::make_shared(); + std::shared_ptr> refreshes = std::make_shared>(0); + std::shared_ptr> busyAtRefresh = std::make_shared>(true); + + explicit Hooked(const QString &located = QStringLiteral("C:/Program Files/usbipd-win/usbipd.exe"), + int timeoutMs = 0, int hardLimitMs = 0) + { + UsbForwardingBackend::TestHooks hooks; + hooks.locateUsbipd = [located] { return located; }; + auto l = launcher; + hooks.launcher = [l](const QString &e, const QString &a, const QString &w, int t, int hard, + const SlowCallback &onSlow) { return l->run(e, a, w, t, hard, onSlow); }; + auto count = refreshes; + auto busy = busyAtRefresh; + hooks.refresh = [count, busy] { + ++*count; + busy->store(UsbForwardingBackend::get()->busy()); + }; + hooks.elevationTimeoutMs = timeoutMs; + hooks.elevationHardLimitMs = hardLimitMs; + UsbForwardingBackend::setTestHooks(hooks); + } + ~Hooked() + { + launcher->release(); + UsbForwardingBackend::setTestHooks(UsbForwardingBackend::TestHooks{}); + } +}; + +} // namespace + +class ElevationTest : public QObject +{ + Q_OBJECT + +private slots: + void cleanup() { UsbForwardingBackend::setTestHooks(UsbForwardingBackend::TestHooks{}); } + +#ifdef Q_OS_WIN + void resultIsDeliveredAsynchronouslyAndTheCallerIsNotBlocked() + { + Hooked hooked(QStringLiteral("C:/Program Files/usbipd-win/usbipd.exe"), 4321, 8765); + auto *backend = UsbForwardingBackend::get(); + QSignalSpy finished(backend, &UsbForwardingBackend::operationFinished); + + // Keep the GUI loop observable: it must keep ticking while the prompt is open. + int ticks = 0; + QTimer ticker; + ticker.setInterval(10); + connect(&ticker, &QTimer::timeout, this, [&ticks] { ++ticks; }); + ticker.start(); + + QElapsedTimer timer; + timer.start(); + backend->bind(QStringLiteral("1-2")); + QVERIFY2(timer.elapsed() < 100, "bind() blocked the caller"); + QVERIFY(backend->busy()); + QVERIFY(finished.isEmpty()); // launching is not success + + QTest::qWait(400); // "the UAC prompt is open" + QVERIFY2(ticks >= 15, qPrintable(QStringLiteral("event loop starved: %1 ticks").arg(ticks))); + QVERIFY(finished.isEmpty()); // still waiting for the real result + QCOMPARE(hooked.launcher->calls.load(), 1); + QCOMPARE(hooked.refreshes->load(), 0); // and no fixed timer refreshes behind its back + + hooked.launcher->result.launched = true; // usbipd ran and exited 0 + hooked.launcher->result.exited = true; + hooked.launcher->release(); + QTRY_COMPARE(finished.count(), 1); + QCOMPARE(finished.first().at(0).toBool(), true); + QVERIFY(!backend->busy()); + QCOMPARE(hooked.refreshes->load(), 1); // refreshed from the real result... + QVERIFY(!hooked.busyAtRefresh->load()); // ...after the operation was over + + // The worker, not this thread, ran the launcher, with the fixed arguments. + QVERIFY(hooked.launcher->thread != QThread::currentThreadId()); + QCOMPARE(hooked.launcher->arguments, QStringLiteral("bind --busid 1-2")); + QCOMPARE(hooked.launcher->exe, QStringLiteral("C:/Program Files/usbipd-win/usbipd.exe")); + QCOMPARE(hooked.launcher->timeoutMs, 4321); + QCOMPARE(hooked.launcher->hardLimitMs, 8765); + // Nothing else follows (a leftover 1.5 s timer would refresh again). + QTest::qWait(1800); + QCOMPARE(hooked.refreshes->load(), 1); + } + + void unbindUsesTheGuidWhenThereIsNoBusId() + { + Hooked hooked; + auto *backend = UsbForwardingBackend::get(); + QSignalSpy finished(backend, &UsbForwardingBackend::operationFinished); + hooked.launcher->result.launched = true; + hooked.launcher->result.exited = true; + hooked.launcher->release(); + backend->unbind(QString(), QStringLiteral("9b6a62f8-0123-4567-89ab-0123456789ab")); + QTRY_COMPARE(finished.count(), 1); + QCOMPARE(hooked.launcher->arguments, QStringLiteral("unbind --guid 9b6a62f8-0123-4567-89ab-0123456789ab")); + } + + void failuresAreReportedForWhatTheyAreAndNeverRefresh_data() + { + QTest::addColumn("launched"); + QTest::addColumn("timedOut"); + QTest::addColumn("cancelled"); + QTest::addColumn("exitCode"); + QTest::addColumn("expectedPart"); + QTest::newRow("declined") << false << false << true << 0 << "cancelled"; + QTest::newRow("could not start") << false << false << false << 0 << "failed"; + QTest::newRow("usbipd error") << true << false << false << 5 << "exit 5"; + QTest::newRow("timeout") << true << true << false << 0 << "still running"; + } + void failuresAreReportedForWhatTheyAreAndNeverRefresh() + { + QFETCH(bool, launched); + QFETCH(bool, timedOut); + QFETCH(bool, cancelled); + QFETCH(int, exitCode); + QFETCH(QString, expectedPart); + Hooked hooked; + hooked.launcher->result.launched = launched; + hooked.launcher->result.timedOut = timedOut; + hooked.launcher->result.exited = launched && !timedOut; + hooked.launcher->result.cancelled = cancelled; + hooked.launcher->result.exitCode = static_cast(exitCode); + hooked.launcher->release(); + auto *backend = UsbForwardingBackend::get(); + QSignalSpy finished(backend, &UsbForwardingBackend::operationFinished); + backend->bind(QStringLiteral("1-2")); + QTRY_COMPARE(finished.count(), 1); + QCOMPARE(finished.first().at(0).toBool(), false); + QVERIFY2(finished.first().at(1).toString().contains(expectedPart), + qPrintable(finished.first().at(1).toString())); + QVERIFY(!backend->busy()); + QTest::qWait(100); + // A timed-out operation may still have changed usbipd: look at the real state. + QCOMPARE(hooked.refreshes->load(), timedOut ? 1 : 0); + } + + void invalidInputNeverReachesTheLauncher() + { + Hooked hooked; + hooked.launcher->release(); + auto *backend = UsbForwardingBackend::get(); + QSignalSpy finished(backend, &UsbForwardingBackend::operationFinished); + backend->bind(QStringLiteral("1-2 & calc.exe")); + backend->bind(QStringLiteral("IncompatibleHub")); + backend->unbind(QStringLiteral("x"), QStringLiteral("not-a-guid")); + QCOMPARE(finished.count(), 3); + for (const auto &args : finished) { + QCOMPARE(args.at(0).toBool(), false); + } + QTest::qWait(100); + QCOMPARE(hooked.launcher->calls.load(), 0); + } + + void aSecondOperationWhileOneIsPendingIsRefused() + { + Hooked hooked; + auto *backend = UsbForwardingBackend::get(); + QSignalSpy finished(backend, &UsbForwardingBackend::operationFinished); + backend->bind(QStringLiteral("1-2")); + QTRY_COMPARE(hooked.launcher->calls.load(), 1); + backend->bind(QStringLiteral("1-3")); + QCOMPARE(finished.count(), 1); // refused at once + QCOMPARE(finished.first().at(0).toBool(), false); + QCOMPARE(hooked.launcher->calls.load(), 1); // only one prompt + hooked.launcher->result.launched = true; + hooked.launcher->result.exited = true; + hooked.launcher->release(); + QTRY_COMPARE(finished.count(), 2); + } + + void missingUsbipdIsReportedWithoutLaunching() + { + Hooked hooked{QString()}; + auto *backend = UsbForwardingBackend::get(); + QSignalSpy finished(backend, &UsbForwardingBackend::operationFinished); + backend->bind(QStringLiteral("1-2")); + QCOMPARE(finished.count(), 1); + QCOMPARE(finished.first().at(0).toBool(), false); + QCOMPARE(hooked.launcher->calls.load(), 0); + QVERIFY(!backend->busy()); + } + + void aSlowOperationKeepsTheBackendBusyAndIsRefreshedWhenItReallyExits() + { + Hooked hooked; + auto *backend = UsbForwardingBackend::get(); + QSignalSpy finished(backend, &UsbForwardingBackend::operationFinished); + backend->bind(QStringLiteral("1-2")); + QTRY_COMPARE(hooked.launcher->calls.load(), 1); + + hooked.launcher->triggerSlow(); // the normal timeout passes, usbipd still runs + QTRY_COMPARE(finished.count(), 1); + QCOMPARE(finished.first().at(0).toBool(), false); + QVERIFY2(finished.first().at(1).toString().contains(QStringLiteral("still running")), + qPrintable(finished.first().at(1).toString())); + QVERIFY(backend->busy()); // still busy + QCOMPARE(hooked.refreshes->load(), 0); + + backend->bind(QStringLiteral("1-3")); // a second elevated operation + QCOMPARE(finished.count(), 2); + QVERIFY(finished.at(1).at(1).toString().contains(QStringLiteral("Another"))); + QCOMPARE(hooked.launcher->calls.load(), 1); // refused: only one process exists + + hooked.launcher->result.launched = true; // now it really exits + hooked.launcher->result.timedOut = true; + hooked.launcher->result.exited = true; + hooked.launcher->release(); + QTRY_VERIFY(!backend->busy()); + QCOMPARE(hooked.refreshes->load(), 1); // list refreshed from the real state + QCOMPARE(finished.count(), 2); // and no second verdict for the same operation + } + + void theWaitDoesNotUseTheGlobalThreadPoolSoQuittingCannotHang() + { + Hooked hooked; + auto *backend = UsbForwardingBackend::get(); + backend->bind(QStringLiteral("1-2")); + QTRY_COMPARE(hooked.launcher->calls.load(), 1); + // QCoreApplication's destructor waits for the global pool; it must be idle. + QVERIFY(QThreadPool::globalInstance()->waitForDone(200)); + QCOMPARE(QThreadPool::globalInstance()->activeThreadCount(), 0); + hooked.launcher->result.launched = true; + hooked.launcher->result.exited = true; + hooked.launcher->release(); + QTRY_VERIFY(!backend->busy()); + } + + void aResultArrivingAfterTheBackendIsGoneIsDroppedSafely() + { + Hooked hooked; + auto *backend = new UsbForwardingBackend; + backend->bind(QStringLiteral("1-2")); + QTRY_COMPARE(hooked.launcher->calls.load(), 1); + delete backend; // the prompt is still open + hooked.launcher->result.launched = true; + hooked.launcher->result.exited = true; + hooked.launcher->release(); + QTest::qWait(300); // delivery must find nobody and do nothing + QCOMPARE(hooked.refreshes->load(), 0); + } + + // ---- the real wait, with the non-elevating "open" verb --------------- + + void launchAndWaitReadsTheRealExitCode() + { + const QString dir = qEnvironmentVariable("SystemRoot") + QStringLiteral("/System32"); + const QString cmd = dir + QStringLiteral("/cmd.exe"); + ElevationResult three = UsbForwarding::launchAndWait(QStringLiteral("open"), cmd, QStringLiteral("/c exit 3"), dir, 10000, 10000); + if (!three.launched) { + QSKIP("could not start cmd.exe in this environment"); + } + QVERIFY(!three.timedOut); + QCOMPARE(three.exitCode, 3u); + QVERIFY(!three.succeeded()); + ElevationResult zero = UsbForwarding::launchAndWait(QStringLiteral("open"), cmd, QStringLiteral("/c exit 0"), dir, 10000, 10000); + QVERIFY(zero.succeeded()); + } + + void launchAndWaitGivesUpAtTheHardLimit() + { + const QString dir = qEnvironmentVariable("SystemRoot") + QStringLiteral("/System32"); + const QString cmd = dir + QStringLiteral("/cmd.exe"); + QElapsedTimer timer; + timer.start(); + int slow = 0; + const ElevationResult r = UsbForwarding::launchAndWait( + QStringLiteral("open"), cmd, QStringLiteral("/c ping -n 6 127.0.0.1 >nul"), dir, 300, 300, + [&slow] { ++slow; }); + if (!r.launched) { + QSKIP("could not start cmd.exe in this environment"); + } + QVERIFY(r.timedOut); + QVERIFY(!r.exited); + QVERIFY(!r.succeeded()); + QCOMPARE(slow, 1); + QVERIFY2(timer.elapsed() < 3000, "did not return at the hard limit"); + } + + void launchAndWaitKeepsWaitingAfterTheNormalTimeoutAndReadsTheLateExit() + { + const QString dir = qEnvironmentVariable("SystemRoot") + QStringLiteral("/System32"); + const QString cmd = dir + QStringLiteral("/cmd.exe"); + int slow = 0; + const ElevationResult r = UsbForwarding::launchAndWait( + QStringLiteral("open"), cmd, QStringLiteral("/c ping -n 3 127.0.0.1 >nul & exit 4"), dir, 200, 15000, + [&slow] { ++slow; }); + if (!r.launched) { + QSKIP("could not start cmd.exe in this environment"); + } + QCOMPARE(slow, 1); // told once that it is slow... + QVERIFY(r.timedOut); + QVERIFY(r.exited); // ...but the handle was kept, so the real exit was seen + QCOMPARE(r.exitCode, 4u); + QVERIFY(!r.succeeded()); + } + + void launchAndWaitReportsAProgramThatCannotStart() + { + const ElevationResult missing = UsbForwarding::launchAndWait( + QStringLiteral("open"), QStringLiteral("C:/definitely/not/here/x.exe"), QString(), QString(), 1000, 1000); + QVERIFY(!missing.launched); + QVERIFY(!missing.cancelled); + QVERIFY(!missing.succeeded()); + } +#endif +}; + +QTEST_GUILESS_MAIN(ElevationTest) +#include "main.moc" diff --git a/tests/usb_forwarding_elevation/usb_forwarding_elevation.pro b/tests/usb_forwarding_elevation/usb_forwarding_elevation.pro new file mode 100644 index 0000000000..3456fa302c --- /dev/null +++ b/tests/usb_forwarding_elevation/usb_forwarding_elevation.pro @@ -0,0 +1,17 @@ +QT += core testlib +QT -= gui +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_elevation_test +DEFINES += USBFWD_TESTING +SOURCES += main.cpp \ + ../../app/backend/usbforwardingbackend.cpp \ + ../../app/backend/usbforwardingenvironment.cpp \ + ../../app/backend/usbforwardingelevation.cpp +HEADERS += \ + ../../app/backend/usbforwardingbackend.h \ + ../../app/backend/usbforwardingcapability.h \ + ../../app/backend/usbforwardingenvironment.h \ + ../../app/backend/usbforwardingelevation.h +win32:LIBS += -ladvapi32 -lshell32 -lole32 diff --git a/tests/usb_forwarding_keepalive/main.cpp b/tests/usb_forwarding_keepalive/main.cpp new file mode 100644 index 0000000000..b644dd98eb --- /dev/null +++ b/tests/usb_forwarding_keepalive/main.cpp @@ -0,0 +1,218 @@ +// After the handshake the tunnel turns on TCP keepalive so a host that vanishes +// without closing is noticed, and it must NOT end a merely quiet device. +// +// What this cannot do: make packets vanish. On loopback the kernel always +// answers keepalive probes, so the probe-timeout path itself (the OS giving up +// on a silent peer) is not reproducible here. These tests check that the +// option and its timing are really set on the live socket (read back from the +// OS), that quiet is not fatal, and that a host that disappears abruptly ends +// the tunnel cleanly with a status. +#include "../../app/backend/usbforwardingtunnel.h" +#include "../usb_forwarding_common/faketlsserver.h" + +#include +#include +#include +#include +#include +#include + +using UsbForwarding::KeepAliveReport; +using UsbForwarding::Tunnel; +using UsbForwarding::TunnelConfig; + +namespace { +const QByteArray kToken(64, 'd'); + +struct Reports +{ + std::mutex mutex; + QList list; + int count() + { + std::lock_guard lock(mutex); + return static_cast(list.size()); + } + KeepAliveReport first() + { + std::lock_guard lock(mutex); + return list.value(0); + } +}; + +struct Rig +{ + explicit Rig(const TestIdentity &host, const TestIdentity &client) : server(host) + { + QVERIFY2(server.listen(), "fake host listen"); + QVERIFY2(localServer.listen(QHostAddress::LocalHost, 0), "fake local listen"); + QObject::connect(&localServer, &QTcpServer::newConnection, &localServer, + [this] { localSocket = localServer.nextPendingConnection(); }); + config.host = QStringLiteral("127.0.0.1"); + config.port = server.port(); + config.sessionToken = kToken; + config.pinnedServerCertificate = host.certificate; + config.busId = "1-2"; + config.localPort = localServer.serverPort(); + config.sslConfiguration.setLocalCertificate(client.certificate); + config.sslConfiguration.setPrivateKey(client.key); + auto r = reports; + config.onKeepAliveApplied = [r](const KeepAliveReport &report) { + std::lock_guard lock(r->mutex); + r->list.append(report); + }; + server.onData = [this](QSslSocket *socket, const QByteArray &all) { + if (!replied && all.contains('\n')) { + replied = true; + socket->write(reply); + } + }; + } + FakeTlsServer server; + QTcpServer localServer; + QTcpSocket *localSocket = nullptr; + TunnelConfig config; + std::shared_ptr reports = std::make_shared(); + QByteArray reply = "{\"op\":\"ready\"}\n"; + bool replied = false; +}; +} // namespace + +class KeepAliveTest : public QObject +{ + Q_OBJECT + + TestIdentity a, b; + +private slots: + void initTestCase() + { + a = loadTestIdentity(QStringLiteral("a")); + b = loadTestIdentity(QStringLiteral("b")); + QVERIFY2(a.valid() && b.valid(), "test certificates missing: run tests/run_usb_forwarding_tests.ps1"); + } + + void keepAliveIsSetOnTheLiveSocketOnlyAfterTheHandshake() + { + Rig rig(a, b); + rig.reply.clear(); // the host reads the request but does not answer yet + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QVERIFY(tunnel.start()); + QTRY_VERIFY_WITH_TIMEOUT(rig.replied, 10000); + QTest::qWait(200); + QCOMPARE(rig.reports->count(), 0); // not before the host said ready + + rig.server.lastSocket()->write("{\"op\":\"ready\"}\n"); + QTRY_COMPARE_WITH_TIMEOUT(forwarding.count(), 1, 10000); + QCOMPARE(rig.reports->count(), 1); + const KeepAliveReport report = rig.reports->first(); + QVERIFY2(report.enabled, "SO_KEEPALIVE is not set on the remote socket"); +#ifdef Q_OS_WIN + // Read back from the OS, not echoed from our own config: about a minute to notice. + QCOMPARE(report.idleSec, 30); + QCOMPARE(report.intervalSec, 5); + QCOMPARE(report.count, 6); +#endif + } + + void configuredTimingReachesTheSocket() + { + Rig rig(a, b); + rig.config.keepAliveIdleSec = 11; + rig.config.keepAliveIntervalSec = 3; + rig.config.keepAliveCount = 4; + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(forwarding.count(), 1, 10000); + const KeepAliveReport report = rig.reports->first(); + QVERIFY(report.enabled); +#ifdef Q_OS_WIN + QCOMPARE(report.idleSec, 11); + QCOMPARE(report.intervalSec, 3); + QCOMPARE(report.count, 4); +#endif + } + + void aHostThatRefusesNeverGetsKeepAliveApplied() + { + Rig rig(a, b); + rig.reply = "{\"op\":\"error\",\"reason\":\"forbidden\"}\n"; + Tunnel tunnel(rig.config); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + QCOMPARE(rig.reports->count(), 0); + } + + void invalidKeepAliveSettingsAreRefused_data() + { + QTest::addColumn("idle"); + QTest::addColumn("interval"); + QTest::addColumn("count"); + QTest::newRow("idle 0") << 0 << 5 << 6; + QTest::newRow("interval 0") << 30 << 0 << 6; + QTest::newRow("count 0") << 30 << 5 << 0; + QTest::newRow("negative") << -1 << 5 << 6; + } + void invalidKeepAliveSettingsAreRefused() + { + QFETCH(int, idle); + QFETCH(int, interval); + QFETCH(int, count); + Rig rig(a, b); + QVERIFY(rig.config.valid()); + rig.config.keepAliveIdleSec = idle; + rig.config.keepAliveIntervalSec = interval; + rig.config.keepAliveCount = count; + QVERIFY(!rig.config.valid()); + Tunnel tunnel(rig.config); + QVERIFY(!tunnel.start()); + QTest::qWait(100); + QCOMPARE(rig.server.connections(), 0); + } + + void aQuietDeviceIsNotKilledByAnIdleTimer() + { + Rig rig(a, b); + rig.config.startupTimeoutMs = 400; // the startup deadline must not outlive the handshake + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(forwarding.count(), 1, 10000); + QTRY_VERIFY_WITH_TIMEOUT(rig.localSocket != nullptr, 10000); + + QTest::qWait(1500); // several times the startup deadline with no bytes either way + QCOMPARE(finished.count(), 0); + + // And the tunnel still carries data afterwards. + rig.server.lastSocket()->write("PING"); + QTRY_COMPARE_WITH_TIMEOUT(rig.localSocket->bytesAvailable(), qint64(4), 10000); + QCOMPARE(rig.localSocket->readAll(), QByteArray("PING")); + } + + void aHostThatVanishesAbruptlyEndsTheTunnelCleanlyWithAStatus() + { + Rig rig(a, b); + Tunnel tunnel(rig.config); + QSignalSpy forwarding(&tunnel, &Tunnel::forwarding); + QSignalSpy finished(&tunnel, &Tunnel::finished); + QVERIFY(tunnel.start()); + QTRY_COMPARE_WITH_TIMEOUT(forwarding.count(), 1, 10000); + QTRY_VERIFY_WITH_TIMEOUT(rig.localSocket != nullptr, 10000); + + rig.server.lastSocket()->abort(); // no TLS shutdown, as if the process died + + QTRY_COMPARE_WITH_TIMEOUT(finished.count(), 1, 10000); + QTest::qWait(200); + QCOMPARE(finished.count(), 1); // reported once, not repeatedly + // The local USB/IP connection is released too. + QTRY_COMPARE_WITH_TIMEOUT(rig.localSocket->state(), QAbstractSocket::UnconnectedState, 10000); + tunnel.stop(); // still safe afterwards + } +}; + +QTEST_GUILESS_MAIN(KeepAliveTest) +#include "main.moc" diff --git a/tests/usb_forwarding_keepalive/usb_forwarding_keepalive.pro b/tests/usb_forwarding_keepalive/usb_forwarding_keepalive.pro new file mode 100644 index 0000000000..6578bdc191 --- /dev/null +++ b/tests/usb_forwarding_keepalive/usb_forwarding_keepalive.pro @@ -0,0 +1,12 @@ +QT += core network testlib +QT -= gui +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_keepalive_test +SOURCES += main.cpp ../../app/backend/usbforwardingtunnel.cpp +HEADERS += \ + ../../app/backend/usbforwardingtunnel.h \ + ../../app/backend/usbforwardingcapability.h \ + ../../app/backend/pairedcertificate.h \ + ../usb_forwarding_common/faketlsserver.h diff --git a/tests/usb_forwarding_manager/main.cpp b/tests/usb_forwarding_manager/main.cpp new file mode 100644 index 0000000000..e1a3718bc0 --- /dev/null +++ b/tests/usb_forwarding_manager/main.cpp @@ -0,0 +1,1162 @@ +// USB forwarding session manager: lifecycle, trust boundary, retries, snapshot. +// Everything runs with fakes (capability, device list, tunnel) except the two +// end-to-end tests, which use loopback sockets and throwaway certificates. +#include "../../app/backend/usbforwardingmanager.h" +#include "../usb_forwarding_common/faketlsserver.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace UsbForwarding; + +namespace { + +std::mutex g_LogMutex; +QStringList g_Log; +void captureMessages(QtMsgType, const QMessageLogContext &, const QString &message) +{ + std::lock_guard lock(g_LogMutex); + g_Log.append(message); +} +QStringList logLines() +{ + std::lock_guard lock(g_LogMutex); + return g_Log; +} + +const QByteArray kToken = QByteArray("a1b2c3d4").repeated(8); // 64 hex characters +const QByteArray kToken2 = QByteArray("0f1e2d3c").repeated(8); +const quint16 kTunnelPort = 40123; + +enum class Act { Forward, Fail, Hang, ForwardThenFail, ForwardThenClose }; + +// Everything the fakes record, shared with the worker and pool threads. +struct Env +{ + Env() { clock.start(); } + + std::mutex mutex; + QList started; + QList startedAt; + std::atomic alive{0}; + std::atomic stops{0}; + std::atomic fetches{0}; + std::atomic lists{0}; + std::atomic holdFetch{false}; + QElapsedTimer clock; + + int afterMs = 30; + std::function onStart; // runs inside FakeTunnel::start, on the worker + std::function act = [](int) { return Act::Forward; }; + std::function capability = [](int) { + Capability c; + c.available = true; + c.reason = QStringLiteral("ready"); + c.port = kTunnelPort; + c.token = kToken; + return c; + }; + std::function()> devices = [] { + return QList{{QStringLiteral("054c:0ce6"), "1-1"}}; + }; + + int startedCount() + { + std::lock_guard lock(mutex); + return static_cast(started.size()); + } + TunnelConfig startedConfig(int i) + { + std::lock_guard lock(mutex); + return started.at(i); + } + QList startTimes() + { + std::lock_guard lock(mutex); + return startedAt; + } +}; + +class FakeTunnel final : public TunnelHandle +{ +public: + FakeTunnel(const TunnelConfig &config, std::shared_ptr env) : m_Config(config), m_Env(std::move(env)) + { + ++m_Env->alive; + } + ~FakeTunnel() override { --m_Env->alive; } + + bool start(QString *) override + { + int index; + { + std::lock_guard lock(m_Env->mutex); + index = static_cast(m_Env->started.size()); + m_Env->started.append(m_Config); + m_Env->startedAt.append(m_Env->clock.elapsed()); + } + if (m_Env->onStart) { + m_Env->onStart(); + } + const Act act = m_Env->act(index); + switch (act) { + case Act::Forward: + QTimer::singleShot(0, this, [this] { emitForwarding(); }); + break; + case Act::Fail: + QTimer::singleShot(0, this, [this] { emitFinished(QStringLiteral("boom")); }); + break; + case Act::Hang: + break; + case Act::ForwardThenFail: + QTimer::singleShot(0, this, [this] { emitForwarding(); }); + QTimer::singleShot(m_Env->afterMs, this, [this] { emitFinished(QStringLiteral("lost")); }); + break; + case Act::ForwardThenClose: + QTimer::singleShot(0, this, [this] { emitForwarding(); }); + QTimer::singleShot(m_Env->afterMs, this, [this] { emitFinished(QString()); }); + break; + } + return true; + } + void stop() noexcept override + { + m_Stopped = true; + ++m_Env->stops; + } + +private: + void emitForwarding() + { + if (!m_Stopped) emit forwarding(); + } + void emitFinished(const QString &message) + { + if (!m_Stopped) emit finished(message); + } + TunnelConfig m_Config; + std::shared_ptr m_Env; + bool m_Stopped = false; +}; + +ManagerSeams fakeSeams(const std::shared_ptr &env) +{ + ManagerSeams seams; + seams.fetchCapability = [env] { + const int call = env->fetches++; + while (env->holdFetch) { + QThread::msleep(10); + } + return env->capability(call); + }; + seams.listDevices = [env] { + ++env->lists; + return env->devices(); + }; + seams.createTunnel = [env](const TunnelConfig &config) -> std::unique_ptr { + return std::make_unique(config, env); + }; + return seams; +} + +ManagerOptions fastOptions(QList backoff = {30, 60, 120}) +{ + ManagerOptions options; + options.retryBackoffMs = backoff; + options.stableAfterMs = 10 * 1000; + return options; +} + +template +bool waitUntil(F condition, int timeoutMs = 5000) +{ + QElapsedTimer timer; + timer.start(); + while (!condition()) { + if (timer.elapsed() > timeoutMs) { + return false; + } + QTest::qWait(5); + } + return true; +} + +DeviceState deviceState(const Snapshot &snap, const QByteArray &busId) +{ + for (const DeviceStatus &d : snap.devices) { + if (d.busId == busId) { + return d.state; + } + } + return DeviceState::NotShared; +} + +} // namespace + +class ManagerTest : public QObject +{ + Q_OBJECT + + TestIdentity a, b; + + // a is the client's paired identity, b is the paired host's certificate. + ManagerConfig config(const QStringList &devices = {QStringLiteral("054c:0ce6")}) const + { + ManagerConfig c; + c.enabled = true; + c.autoDevices = devices; + c.streamAddress = QStringLiteral("192.168.50.20"); + c.pairedAddress = QStringLiteral("192.168.50.20"); + c.pinnedServerCertificate = b.certificate; + c.clientSsl.setLocalCertificate(a.certificate); + c.clientSsl.setPrivateKey(a.key); + return c; + } + +private slots: + void initTestCase() + { + a = loadTestIdentity(QStringLiteral("a")); + b = loadTestIdentity(QStringLiteral("b")); + QVERIFY2(a.valid() && b.valid(), + "test certificates missing: run tests/run_usb_forwarding_tests.ps1"); + qInstallMessageHandler(captureMessages); + } + + void cleanup() + { + // The token is a credential: it must not appear in any log line, in any test. + for (const QString &line : logLines()) { + QVERIFY2(!line.contains(QString::fromLatin1(kToken)), "token was logged"); + QVERIFY2(!line.contains(QString::fromLatin1(kToken2)), "token was logged"); + } + } + + // ---- turned off ------------------------------------------------------- + + void disabledInThePreferencesDoesNothingAtAll() + { + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + ManagerConfig c = config(); + c.enabled = false; + QVERIFY(manager.start(c)); + QTest::qWait(300); + QCOMPARE(env->fetches.load(), 0); + QCOMPARE(env->lists.load(), 0); + QCOMPARE(env->startedCount(), 0); + QCOMPARE(QThreadPool::globalInstance()->activeThreadCount(), 0); + QCOMPARE(manager.snapshot().state, State::Off); + QVERIFY(!manager.snapshot().status.isEmpty()); + QVERIFY(!manager.toggle()); // nothing to toggle while off in the preferences + manager.stop(); + QCOMPARE(manager.snapshot().state, State::Off); + } + + void neverStartedHasNothingToToggleOrStop() + { + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + QVERIFY(!manager.toggle()); + manager.stop(); + manager.stop(); + QCOMPARE(manager.snapshot().state, State::Off); + QCOMPARE(env->fetches.load(), 0); + } + + // ---- what the host says ---------------------------------------------- + + void hostThatSaysNoLeavesThingsQuiet_data() + { + QTest::addColumn("reason"); + QTest::addColumn("expectedState"); + QTest::newRow("disabled") << "disabled" << int(State::HostDisabled); + QTest::newRow("forbidden") << "forbidden" << int(State::HostForbidden); + QTest::newRow("unauthorized") << "unauthorized" << int(State::HostForbidden); + } + void hostThatSaysNoLeavesThingsQuiet() + { + QFETCH(QString, reason); + QFETCH(int, expectedState); + auto env = std::make_shared(); + env->capability = [reason](int) { + Capability c; + c.available = false; + c.reason = reason; + return c; + }; + Manager manager(fakeSeams(env), fastOptions()); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return int(manager.snapshot().state) == expectedState; })); + QTest::qWait(400); // far longer than every backoff: nothing may happen + QCOMPARE(env->fetches.load(), 1); // asked once, never again + QCOMPARE(env->startedCount(), 0); + QCOMPARE(env->lists.load(), 0); + QCOMPARE(int(manager.snapshot().state), expectedState); + QVERIFY(manager.snapshot().devices.isEmpty()); + } + + void unavailableHostIsRecheckedAndThenStartsTunnels() + { + auto env = std::make_shared(); + const auto ready = env->capability; + env->capability = [ready](int call) { + if (call < 2) { + Capability c; + c.reason = QStringLiteral("unavailable"); // e.g. the host has not seen the stream yet + return c; + } + return ready(call); + }; + Manager manager(fakeSeams(env), fastOptions({20, 20})); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return manager.snapshot().forwardingCount() == 1; })); + QCOMPARE(env->fetches.load(), 3); + QCOMPARE(manager.snapshot().state, State::Active); + } + + void unavailableHostIsRecheckedOnlyABoundedNumberOfTimes() + { + auto env = std::make_shared(); + env->capability = [](int) { + Capability c; + c.reason = QStringLiteral("unavailable"); + return c; + }; + Manager manager(fakeSeams(env), fastOptions({20, 20})); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return env->fetches.load() == 3; })); + QTest::qWait(300); + QCOMPARE(env->fetches.load(), 3); // 1 + two rechecks, then quiet + QCOMPARE(env->startedCount(), 0); + QCOMPARE(manager.snapshot().state, State::HostUnavailable); + } + + void unreachableHostRetriesWithBackoffThenGivesUp() + { + auto env = std::make_shared(); + env->capability = [](int) -> Capability { throw std::runtime_error("network down"); }; + Manager manager(fakeSeams(env), fastOptions({20, 20})); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return manager.snapshot().state == State::Failed; })); + QTest::qWait(300); + QCOMPARE(env->fetches.load(), 3); + QCOMPARE(env->startedCount(), 0); + QCOMPARE(manager.snapshot().state, State::Failed); + } + + // ---- which devices ---------------------------------------------------- + + void readyStartsOnlyAutoDevicesThatAreShared() + { + auto env = std::make_shared(); + env->devices = [] { + return QList{{QStringLiteral("054c:0ce6"), "1-1"}, + {QStringLiteral("dead:beef"), "1-2"}, // shared but not selected + {QStringLiteral("0781:5581#abc123"), "1-3"}}; + }; + Manager manager(fakeSeams(env), fastOptions()); + // Selection is case-insensitive; 1111:2222 is selected but not shared. + QVERIFY(manager.start(config({QStringLiteral("054C:0CE6"), QStringLiteral("0781:5581#ABC123"), + QStringLiteral("1111:2222"), QStringLiteral("054c:0ce6")}))); + QVERIFY(waitUntil([&] { return manager.snapshot().forwardingCount() == 2; })); + QTest::qWait(150); + QCOMPARE(env->startedCount(), 2); + QStringList busIds; + for (int i = 0; i < 2; ++i) { + busIds << QString::fromLatin1(env->startedConfig(i).busId); + } + busIds.sort(); + QCOMPARE(busIds, (QStringList{QStringLiteral("1-1"), QStringLiteral("1-3")})); + const Snapshot snap = manager.snapshot(); + QCOMPARE(deviceState(snap, "1-1"), DeviceState::Forwarding); + QCOMPARE(deviceState(snap, "1-3"), DeviceState::Forwarding); + QCOMPARE(deviceState(snap, "1-2"), DeviceState::NotShared); // never tracked + bool sawNotShared = false; + for (const DeviceStatus &d : snap.devices) { + QVERIFY(d.busId != "1-2"); + if (d.identity == QLatin1String("1111:2222")) { + QCOMPARE(d.state, DeviceState::NotShared); + sawNotShared = true; + } + } + QVERIFY(sawNotShared); + QCOMPARE(snap.devices.size(), 3); // duplicate selection collapsed + QVERIFY(snap.status.contains(QStringLiteral("2"))); + } + + void noSelectedDevicesStartsNothingAndSaysSo() + { + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + QVERIFY(manager.start(config(QStringList()))); + QVERIFY(waitUntil([&] { return manager.snapshot().state == State::Active; })); + QTest::qWait(100); + QCOMPARE(env->startedCount(), 0); + QVERIFY(!manager.snapshot().status.isEmpty()); + } + + void backendListFilterKeepsOnlySharedConnectedSupportedDevices() + { + const auto device = [](const QString &identity, const QString &busId, bool connected, bool bound, + bool supported) { + QVariantMap m; + m.insert(QStringLiteral("identity"), identity); + m.insert(QStringLiteral("busId"), busId); + m.insert(QStringLiteral("isConnected"), connected); + m.insert(QStringLiteral("isBound"), bound); + m.insert(QStringLiteral("isSupported"), supported); + return QVariant(m); + }; + const QList result = sharedDevicesFromBackendList( + {device(QStringLiteral("054c:0ce6"), QStringLiteral("1-2"), true, true, true), + device(QStringLiteral("054c:0ce6"), QStringLiteral("1-3"), true, false, true), // not shared + device(QStringLiteral("054c:0ce6"), QStringLiteral("1-4"), false, true, true), // unplugged + device(QStringLiteral("054c:0ce6"), QStringLiteral("1-5"), true, true, false), // not supported + device(QString(), QStringLiteral("1-6"), true, true, true), // no identity + device(QStringLiteral("0001:0002"), QStringLiteral("1-2; calc"), true, true, true)}); // bad bus id + QCOMPARE(result.size(), 1); + QCOMPARE(result.first().busId, QByteArray("1-2")); + } + + // ---- trust boundary --------------------------------------------------- + + void tunnelConfigCarriesThePinnedCertificateAndTheClientIdentity() + { + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + const ManagerConfig c = config(); + QVERIFY(manager.start(c)); + QVERIFY(waitUntil([&] { return env->startedCount() == 1; })); + const TunnelConfig tc = env->startedConfig(0); + QCOMPARE(tc.host, ManagerConfig::canonicalHostAddress(c.streamAddress)); // the stream's own address + QCOMPARE(tc.port, kTunnelPort); + QCOMPARE(tc.sessionToken, kToken); + QCOMPARE(tc.busId, QByteArray("1-1")); + QVERIFY(!tc.pinnedServerCertificate.isNull()); + QCOMPARE(tc.pinnedServerCertificate.toDer(), b.certificate.toDer()); + QCOMPARE(tc.sslConfiguration.localCertificate().toDer(), a.certificate.toDer()); + QCOMPARE(tc.sslConfiguration.privateKey().toDer(), a.key.toDer()); + QVERIFY(tc.valid()); // and the real tunnel would accept it + } + + void mismatchedOrUserSuppliedAddressesAreRefused_data() + { + QTest::addColumn("stream"); + QTest::addColumn("paired"); + const QString paired = QStringLiteral("192.168.50.20"); + QTest::newRow("different host") << "203.0.113.9" << paired; + QTest::newRow("typed host name") << "evil.example.com" << paired; + QTest::newRow("empty stream") << "" << paired; + QTest::newRow("empty paired") << paired << ""; + QTest::newRow("both empty") << "" << ""; + QTest::newRow("scheme") << "https://192.168.50.20" << "https://192.168.50.20"; + QTest::newRow("port") << "192.168.50.20:47984" << "192.168.50.20:47984"; + QTest::newRow("path") << "192.168.50.20/x" << "192.168.50.20/x"; + QTest::newRow("user info") << "me@192.168.50.20" << "me@192.168.50.20"; + QTest::newRow("trailing space") << "192.168.50.20 " << "192.168.50.20 "; + QTest::newRow("inner space") << "evil host" << "evil host"; + QTest::newRow("bracketed v4") << "[10.0.0.5]" << "[10.0.0.5]"; + QTest::newRow("bracketed name") << "[pc.local]" << "[pc.local]"; + QTest::newRow("empty label") << "pc..local" << "pc..local"; + QTest::newRow("leading dot") << ".pc.local" << ".pc.local"; + QTest::newRow("ip with trailing dot") << "10.0.0.5." << "10.0.0.5."; + QTest::newRow("tab") << "pc\tlocal" << "pc\tlocal"; + QTest::newRow("malformed ip") << "999.1.1.1" << "999.1.1.1"; + QTest::newRow("stream is name, paired is its ip") << "pc.local" << paired; + } + void mismatchedOrUserSuppliedAddressesAreRefused() + { + QFETCH(QString, stream); + QFETCH(QString, paired); + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + ManagerConfig c = config(); + c.streamAddress = stream; + c.pairedAddress = paired; + QVERIFY(!manager.start(c)); + QTest::qWait(150); + QCOMPARE(manager.snapshot().state, State::InvalidConfig); + QCOMPARE(env->fetches.load(), 0); // no request, so no token + QCOMPARE(env->startedCount(), 0); + QVERIFY(!manager.toggle()); + } + + void missingCertificatesAreRefused_data() + { + QTest::addColumn("what"); + QTest::newRow("no pinned certificate") << 0; + QTest::newRow("no client certificate") << 1; + QTest::newRow("no client key") << 2; + } + void missingCertificatesAreRefused() + { + QFETCH(int, what); + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + ManagerConfig c = config(); + if (what == 0) c.pinnedServerCertificate = QSslCertificate(); + if (what == 1) c.clientSsl.setLocalCertificate(QSslCertificate()); + if (what == 2) c.clientSsl.setPrivateKey(QSslKey()); + QVERIFY(!manager.start(c)); + QCOMPARE(manager.snapshot().state, State::InvalidConfig); + QCOMPARE(env->fetches.load(), 0); + } + + void equivalentSpellingsOfTheSameHostAreAccepted_data() + { + QTest::addColumn("stream"); + QTest::addColumn("paired"); + QTest::newRow("name case") << "Gaming-PC.local" << "gaming-pc.local"; + QTest::newRow("ipv6 spelling") << "::1" << "0:0:0:0:0:0:0:1"; + QTest::newRow("ipv4") << "10.0.0.5" << "10.0.0.5"; + QTest::newRow("bracketed ipv6") << "[::1]" << "::1"; + QTest::newRow("bracketed long ipv6") << "[0:0:0:0:0:0:0:1]" << "[::1]"; + QTest::newRow("trailing dot") << "pc.local." << "pc.local"; + QTest::newRow("idn") << QStringLiteral("b\u00fccher.example") << "xn--bcher-kva.example"; + QTest::newRow("idn upper") << QStringLiteral("B\u00dcCHER.example.") << "xn--bcher-kva.example"; + QTest::newRow("scoped, same scope") << "fe80::1%12" << "[fe80::1%12]"; + } + void equivalentSpellingsOfTheSameHostAreAccepted() + { + QFETCH(QString, stream); + QFETCH(QString, paired); + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + ManagerConfig c = config(); + c.streamAddress = stream; + c.pairedAddress = paired; + QVERIFY(manager.start(c)); + QVERIFY(waitUntil([&] { return env->startedCount() == 1; })); + // Always the stream's own address, in its canonical spelling. + QCOMPARE(env->startedConfig(0).host, ManagerConfig::canonicalHostAddress(stream)); + } + + void canonicalFormIsTheSameFromEitherSide_data() + { + QTest::addColumn("a"); + QTest::addColumn("b"); + QTest::addColumn("same"); + QTest::newRow("brackets") << "[::1]" << "::1" << true; + QTest::newRow("case") << "PC.Local" << "pc.local" << true; + QTest::newRow("trailing dot") << "pc.local." << "pc.local" << true; + QTest::newRow("idn") << QStringLiteral("b\u00fccher.example") << "xn--bcher-kva.example" << true; + QTest::newRow("scope differs") << "fe80::1%12" << "fe80::1" << false; + QTest::newRow("other scope") << "fe80::1%12" << "fe80::1%13" << false; + QTest::newRow("name vs ip") << "pc.local" << "10.0.0.5" << false; + QTest::newRow("two trailing dots") << "pc.local.." << "pc.local" << false; + QTest::newRow("different hosts") << "10.0.0.5" << "10.0.0.6" << false; + } + void canonicalFormIsTheSameFromEitherSide() + { + QFETCH(QString, a); + QFETCH(QString, b); + QFETCH(bool, same); + for (int swap = 0; swap < 2; ++swap) { // both orders: stream/paired + const QString x = swap ? b : a; + const QString y = swap ? a : b; + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + ManagerConfig c = config(); + c.streamAddress = x; + c.pairedAddress = y; + QCOMPARE(manager.start(c), same); + if (same) { + QCOMPARE(ManagerConfig::canonicalHostAddress(x), ManagerConfig::canonicalHostAddress(y)); + } + manager.stop(); + } + } + + void canonicalFormOutputs() + { + QCOMPARE(ManagerConfig::canonicalHostAddress(QStringLiteral("[0:0:0:0:0:0:0:1]")), QStringLiteral("::1")); + QCOMPARE(ManagerConfig::canonicalHostAddress(QStringLiteral("PC.Local.")), QStringLiteral("pc.local")); + QCOMPARE(ManagerConfig::canonicalHostAddress(QStringLiteral("B\u00dcCHER.example")), + QStringLiteral("xn--bcher-kva.example")); + QCOMPARE(ManagerConfig::canonicalHostAddress(QStringLiteral("fe80::1%12")), QStringLiteral("fe80::1%12")); + QVERIFY(ManagerConfig::canonicalHostAddress(QStringLiteral("[10.0.0.5]")).isEmpty()); + QVERIFY(ManagerConfig::canonicalHostAddress(QStringLiteral(" pc.local")).isEmpty()); + } + + // ---- stop() from inside the manager's own callbacks --------------------- + + void stopFromInsideTheTunnelFactoryIsSafe() + { + for (int i = 0; i < 200; ++i) { + auto env = std::make_shared(); + env->devices = [] { + return QList{{QStringLiteral("054c:0ce6"), "1-1"}, {QStringLiteral("0781:5581"), "1-2"}}; + }; + ManagerSeams seams = fakeSeams(env); + Manager *self = nullptr; + std::atomic calls{0}; + seams.createTunnel = [&, env](const TunnelConfig &config) -> std::unique_ptr { + if (calls++ == 0) { + self->stop(); // on the worker, while startTunnel holds a Dev + } + return std::make_unique(config, env); + }; + Manager manager(seams, fastOptions()); + self = &manager; + QVERIFY(manager.start(config({QStringLiteral("054c:0ce6"), QStringLiteral("0781:5581")}))); + QVERIFY2(waitUntil([&] { return manager.snapshot().state == State::Stopped; }), "never stopped"); + manager.stop(); + QCOMPARE(env->alive.load(), 0); + QCOMPARE(env->startedCount(), 0); // nothing is started after the stop request + QCOMPARE(calls.load(), 1); + QCOMPARE(manager.snapshot().state, State::Stopped); + } + } + + void stopFromInsideATunnelsStartIsSafe() + { + for (int i = 0; i < 200; ++i) { + auto env = std::make_shared(); + env->devices = [] { + return QList{{QStringLiteral("054c:0ce6"), "1-1"}, {QStringLiteral("0781:5581"), "1-2"}}; + }; + Manager *self = nullptr; + std::atomic calls{0}; + env->onStart = [&] { + if (calls++ == 0) self->stop(); + }; + Manager manager(fakeSeams(env), fastOptions()); + self = &manager; + QVERIFY(manager.start(config({QStringLiteral("054c:0ce6"), QStringLiteral("0781:5581")}))); + QVERIFY2(waitUntil([&] { return manager.snapshot().state == State::Stopped; }), "never stopped"); + manager.stop(); + QCOMPARE(env->alive.load(), 0); + QVERIFY(env->startedCount() <= 1); // the second device is never started + QCOMPARE(manager.snapshot().state, State::Stopped); + } + } + + void stopFromTheCapabilityRequestItselfIsSafe() + { + for (int i = 0; i < 200; ++i) { + auto env = std::make_shared(); + Manager *self = nullptr; + const auto ready = env->capability; + std::atomic done{false}; + env->capability = [&, ready](int call) { + self->stop(); // from the pool thread, before the answer is delivered + done = true; + return ready(call); + }; + Manager manager(fakeSeams(env), fastOptions()); + self = &manager; + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return done.load(); })); + QTest::qWait(5); + manager.stop(); + QCOMPARE(env->alive.load(), 0); + QCOMPARE(env->startedCount(), 0); + QCOMPARE(manager.snapshot().state, State::Stopped); + } + } + + void stopFromTheDeviceListerAfterTheAnswerIsSafe() + { + for (int i = 0; i < 200; ++i) { + auto env = std::make_shared(); + env->devices = [] { + return QList{{QStringLiteral("054c:0ce6"), "1-1"}, {QStringLiteral("0781:5581"), "1-2"}}; + }; + ManagerSeams seams = fakeSeams(env); + Manager *self = nullptr; + const auto list = seams.listDevices; + seams.listDevices = [&, list]() -> QList { + const auto devices = list(); + self->stop(); // the result path: right after the answer, on the worker + return devices; // the caller still gets a list to iterate + }; + Manager manager(seams, fastOptions()); + self = &manager; + QVERIFY(manager.start(config({QStringLiteral("054c:0ce6"), QStringLiteral("0781:5581")}))); + QVERIFY2(waitUntil([&] { return manager.snapshot().state == State::Stopped; }), "never stopped"); + manager.stop(); + QCOMPARE(env->alive.load(), 0); + QCOMPARE(env->startedCount(), 0); + } + } + + void stopFromASnapshotChangedHandlerIsSafe() + { + for (int i = 0; i < 200; ++i) { + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + int seen = 0; + connect(&manager, &Manager::snapshotChanged, this, [&] { + // The first notification arrives inside start() itself; later ones queued. + if (++seen == (i % 2 == 0 ? 1 : 3)) manager.stop(); + }); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return manager.snapshot().state == State::Stopped; })); + QTest::qWait(2); + manager.stop(); + QCOMPARE(env->alive.load(), 0); + QCOMPARE(manager.snapshot().state, State::Stopped); + } + } + + // ---- a device's own re-fetch ------------------------------------------- + + void aDevicesFailedRefetchNeverDisturbsTheSessionOrStrandsTheDevice_data() + { + QTest::addColumn("throws"); + QTest::newRow("host unreachable") << true; + QTest::newRow("host says unavailable") << false; + } + void aDevicesFailedRefetchNeverDisturbsTheSessionOrStrandsTheDevice() + { + QFETCH(bool, throws); + auto env = std::make_shared(); + env->devices = [] { + return QList{{QStringLiteral("054c:0ce6"), "1-1"}, {QStringLiteral("0781:5581"), "1-2"}}; + }; + env->act = [](int index) { return index == 0 ? Act::Forward : Act::Fail; }; + env->capability = [throws](int call) -> Capability { + Capability c; + if (call == 0) { + c.available = true; + c.reason = QStringLiteral("ready"); + c.port = kTunnelPort; + c.token = kToken; + return c; + } + if (throws) throw std::runtime_error("down"); + c.reason = QStringLiteral("unavailable"); + return c; + }; + Manager manager(fakeSeams(env), fastOptions({20, 20})); + QVERIFY(manager.start(config({QStringLiteral("054c:0ce6"), QStringLiteral("0781:5581")}))); + bool sessionDisturbed = false; + bool seenActive = false; + bool firstDeviceDropped = false; + const bool reached = waitUntil([&] { + const Snapshot s = manager.snapshot(); + if (s.state == State::Active) seenActive = true; + else if (seenActive) sessionDisturbed = true; + if (s.state == State::Active && s.devices.size() == 2 && deviceState(s, "1-1") != DeviceState::Forwarding && + env->startedCount() >= 2) { + firstDeviceDropped = true; + } + return deviceState(s, "1-2") == DeviceState::Failed; + }); + QVERIFY2(reached, "the failing device was stranded"); + QVERIFY2(!sessionDisturbed, "a device's re-fetch changed the session state"); + QVERIFY(!firstDeviceDropped); + QTest::qWait(300); + const Snapshot s = manager.snapshot(); + QCOMPARE(s.state, State::Active); + QCOMPARE(deviceState(s, "1-1"), DeviceState::Forwarding); + QVERIFY2(s.status.contains(QStringLiteral("Forwarding")), qPrintable(s.status)); + for (const DeviceStatus &d : s.devices) { + QVERIFY(d.state != DeviceState::Pending); // nothing stuck waiting + } + QCOMPARE(env->fetches.load(), 3); // bounded: 1 + one per backoff entry + } + + // ---- lifecycle -------------------------------------------------------- + + void stopIsIdempotentTearsDownAndWorksFromAnyThread() + { + auto env = std::make_shared(); + env->devices = [] { + return QList{{QStringLiteral("054c:0ce6"), "1-1"}, {QStringLiteral("0781:5581"), "1-2"}}; + }; + Manager manager(fakeSeams(env), fastOptions()); + QVERIFY(manager.start(config({QStringLiteral("054c:0ce6"), QStringLiteral("0781:5581")}))); + QVERIFY(waitUntil([&] { return manager.snapshot().forwardingCount() == 2; })); + QCOMPARE(env->alive.load(), 2); + + std::thread t1([&] { manager.stop(); }); + std::thread t2([&] { manager.stop(); }); + manager.stop(); + t1.join(); + t2.join(); + + QCOMPARE(env->alive.load(), 0); // every tunnel is gone + QVERIFY(env->stops.load() >= 2); // and was stopped, not just dropped + const Snapshot after = manager.snapshot(); + QCOMPARE(after.state, State::Stopped); + QVERIFY(after.devices.isEmpty()); + + manager.stop(); // again: nothing changes + QCOMPARE(manager.snapshot().revision, after.revision); + QVERIFY(!manager.toggle()); // a stopped session cannot be toggled + const int started = env->startedCount(); + QTest::qWait(300); + QCOMPARE(env->startedCount(), started); // nothing starts after stop + } + + void stopDoesNotWaitForASlowCapabilityRequest() + { + auto env = std::make_shared(); + env->holdFetch = true; + Manager manager(fakeSeams(env), fastOptions()); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return env->fetches.load() == 1; })); + QElapsedTimer timer; + timer.start(); + manager.stop(); + QVERIFY2(timer.elapsed() < 1000, "stop() waited for the capability request"); + env->holdFetch = false; // the late answer arrives to a stopped session + QTest::qWait(300); + QCOMPARE(env->startedCount(), 0); + QCOMPARE(manager.snapshot().state, State::Stopped); + } + + void startReturnsWithoutWaitingForTheNetwork() + { + auto env = std::make_shared(); + env->holdFetch = true; + Manager manager(fakeSeams(env), fastOptions()); + QElapsedTimer timer; + timer.start(); + QVERIFY(manager.start(config())); + QVERIFY2(timer.elapsed() < 500, "start() blocked"); + QCOMPARE(manager.snapshot().state, State::Checking); + env->holdFetch = false; + QVERIFY(waitUntil([&] { return manager.snapshot().forwardingCount() == 1; })); + } + + void stopFromTheWorkerThreadDoesNotDeadlock() + { + auto env = std::make_shared(); + ManagerSeams seams = fakeSeams(env); + std::atomic called{false}; + Manager *self = nullptr; + seams.listDevices = [&, env]() -> QList { + // Runs on the manager's worker thread. + called = true; + self->stop(); + return {}; + }; + Manager reentrant(seams, fastOptions()); + self = &reentrant; + QVERIFY(reentrant.start(config())); + QVERIFY(waitUntil([&] { return called.load() && reentrant.snapshot().state == State::Stopped; })); + reentrant.stop(); // the thread is joined here + QCOMPARE(reentrant.snapshot().state, State::Stopped); + } + + void startingAgainReplacesTheRunningSession() + { + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return manager.snapshot().forwardingCount() == 1; })); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return env->startedCount() == 2 && manager.snapshot().forwardingCount() == 1; })); + QCOMPARE(env->alive.load(), 1); // the first session's tunnel is gone + } + + void snapshotChangedIsEmittedForEventLoopContexts() + { + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + QSignalSpy spy(&manager, &Manager::snapshotChanged); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return manager.snapshot().forwardingCount() == 1; })); + QTRY_VERIFY(spy.count() >= 2); + } + + void toggleFlipsForwardingForTheSession() + { + auto env = std::make_shared(); + Manager manager(fakeSeams(env), fastOptions()); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return manager.snapshot().forwardingCount() == 1; })); + + QVERIFY(manager.toggle()); // off + QVERIFY(waitUntil([&] { return manager.snapshot().state == State::Paused; })); + QCOMPARE(env->alive.load(), 0); + QVERIFY(manager.snapshot().devices.isEmpty()); + QTest::qWait(200); + QCOMPARE(env->startedCount(), 1); // paused means quiet + + QVERIFY(manager.toggle()); // on again: a fresh capability, a fresh tunnel + QVERIFY(waitUntil([&] { return manager.snapshot().forwardingCount() == 1; })); + QCOMPARE(env->fetches.load(), 2); + QCOMPARE(env->startedCount(), 2); + QCOMPARE(env->alive.load(), 1); + } + + void pausingWhileTheCapabilityRequestIsInFlightDropsItsAnswer() + { + auto env = std::make_shared(); + env->holdFetch = true; + Manager manager(fakeSeams(env), fastOptions()); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return env->fetches.load() == 1; })); + QVERIFY(manager.toggle()); + QVERIFY(waitUntil([&] { return manager.snapshot().state == State::Paused; })); + env->holdFetch = false; + QTest::qWait(300); + QCOMPARE(env->startedCount(), 0); // the stale answer was ignored + QCOMPARE(manager.snapshot().state, State::Paused); + } + + // ---- failures --------------------------------------------------------- + + void failingTunnelBacksOffAndIsBounded() + { + auto env = std::make_shared(); + env->act = [](int) { return Act::Fail; }; + const QList backoff = {60, 120, 240}; + Manager manager(fakeSeams(env), fastOptions(backoff)); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return deviceState(manager.snapshot(), "1-1") == DeviceState::Failed; })); + QTest::qWait(500); + QCOMPARE(env->startedCount(), 4); // the first try plus one per backoff entry, then stop + const QList times = env->startTimes(); + for (int i = 1; i < times.size(); ++i) { + // Never earlier than the configured delay (timers may be a few ms early). + QVERIFY2(times.at(i) - times.at(i - 1) >= backoff.at(i - 1) * 0.8, + qPrintable(QStringLiteral("retry %1 came after %2 ms").arg(i).arg(times.at(i) - times.at(i - 1)))); + } + QCOMPARE(manager.snapshot().state, State::Active); // the session itself is fine + QVERIFY(!manager.snapshot().status.isEmpty()); + } + + void retryFetchesAFreshCapabilityAndUsesItsToken() + { + auto env = std::make_shared(); + env->act = [](int index) { return index == 0 ? Act::Fail : Act::Forward; }; + env->capability = [](int call) { + Capability c; + c.available = true; + c.reason = QStringLiteral("ready"); + c.port = kTunnelPort; + c.token = call == 0 ? kToken : kToken2; + return c; + }; + Manager manager(fakeSeams(env), fastOptions()); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return manager.snapshot().forwardingCount() == 1; })); + QCOMPARE(env->fetches.load(), 2); + QCOMPARE(env->startedConfig(0).sessionToken, kToken); + QCOMPARE(env->startedConfig(1).sessionToken, kToken2); + } + + void aDeviceThatConnectsAndDropsAtOnceCannotRetryForever() + { + auto env = std::make_shared(); + env->act = [](int) { return Act::ForwardThenFail; }; + env->afterMs = 5; + Manager manager(fakeSeams(env), fastOptions({20, 20, 20})); // stableAfterMs is 10 s + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return deviceState(manager.snapshot(), "1-1") == DeviceState::Failed; })); + QTest::qWait(400); + QCOMPARE(env->startedCount(), 4); + } + + void aTunnelThatStayedUpEarnsAFreshRetryBudget() + { + auto env = std::make_shared(); + env->act = [](int index) { + if (index == 0) return Act::Fail; // attempt 1 of the budget + if (index == 1) return Act::ForwardThenFail; // up for a while, then lost + return Act::Fail; + }; + env->afterMs = 80; + ManagerOptions options = fastOptions({20, 20}); + options.stableAfterMs = 20; + Manager manager(fakeSeams(env), options); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return deviceState(manager.snapshot(), "1-1") == DeviceState::Failed; })); + QTest::qWait(300); + QCOMPARE(env->startedCount(), 4); // 3 without the reset + } + + void hostClosingTheTunnelCleanlyIsFinal() + { + auto env = std::make_shared(); + env->act = [](int) { return Act::ForwardThenClose; }; + env->afterMs = 30; + Manager manager(fakeSeams(env), fastOptions()); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return deviceState(manager.snapshot(), "1-1") == DeviceState::Ended; })); + QTest::qWait(400); + QCOMPARE(env->startedCount(), 1); // revoked or finished by the host: not retried + QCOMPARE(env->fetches.load(), 1); + } + + // ---- snapshot --------------------------------------------------------- + + void snapshotCanBePolledFromAnotherThreadWhileStateChanges() + { + auto env = std::make_shared(); + env->act = [](int index) { return index % 3 == 0 ? Act::ForwardThenFail : Act::Forward; }; + env->afterMs = 10; + env->devices = [] { + return QList{{QStringLiteral("054c:0ce6"), "1-1"}, {QStringLiteral("0781:5581"), "1-2"}}; + }; + Manager manager(fakeSeams(env), fastOptions({5, 5, 5, 5})); + + std::atomic stopPolling{false}; + std::atomic polls{0}; + std::atomic inconsistent{false}; + std::thread poller([&] { + quint64 lastRevision = 0; + while (!stopPolling) { + const Snapshot s = manager.snapshot(); + ++polls; + if (s.revision < lastRevision) inconsistent = true; // never goes backwards + lastRevision = s.revision; + if (s.status.isEmpty()) inconsistent = true; // always readable text + // Devices exist only while the host said yes; counts agree with states. + if (s.state != State::Active && !s.devices.isEmpty()) inconsistent = true; + int forwarding = 0; + for (const DeviceStatus &d : s.devices) forwarding += d.state == DeviceState::Forwarding; + if (forwarding != s.forwardingCount()) inconsistent = true; + } + }); + + QVERIFY(manager.start(config({QStringLiteral("054c:0ce6"), QStringLiteral("0781:5581")}))); + for (int i = 0; i < 12; ++i) { + QTest::qWait(15); + manager.toggle(); // pause, resume, pause... + } + QTest::qWait(100); + manager.stop(); + stopPolling = true; + poller.join(); + QVERIFY(polls.load() > 100); + QVERIFY2(!inconsistent.load(), "snapshot was inconsistent while polled concurrently"); + QCOMPARE(manager.snapshot().state, State::Stopped); + } + + // ---- logging ---------------------------------------------------------- + + void theTokenAndTheCapabilityAreNeverLogged() + { + auto env = std::make_shared(); + // The first request fails with the token in the exception text. + env->capability = [](int call) -> Capability { + if (call == 0) { + throw std::runtime_error(std::string("failed with ") + kToken.constData()); + } + Capability c; + c.available = true; + c.reason = QStringLiteral("ready"); + c.port = kTunnelPort; + c.token = kToken; + return c; + }; + ManagerSeams seams = fakeSeams(env); + // A tunnel that fails with a message that (wrongly) contains the token. + class LeakyTunnel final : public TunnelHandle + { + public: + bool start(QString *) override + { + QTimer::singleShot(0, this, [this] { + emit finished(QStringLiteral("rejected token ") + QString::fromLatin1(kToken)); + }); + return true; + } + void stop() noexcept override {} + }; + seams.createTunnel = [](const TunnelConfig &) -> std::unique_ptr { + return std::make_unique(); + }; + Manager manager(seams, fastOptions({20, 20})); + QVERIFY(manager.start(config())); + QVERIFY(waitUntil([&] { return deviceState(manager.snapshot(), "1-1") == DeviceState::Failed; })); + manager.stop(); + bool sawRedaction = false; + for (const QString &line : logLines()) { + QVERIFY2(!line.contains(QString::fromLatin1(kToken)), qPrintable(line)); + sawRedaction = sawRedaction || line.contains(QStringLiteral("[redacted]")); + } + QVERIFY(sawRedaction); // the failure was logged, with the secret removed + // The user-visible status never carries free text either. + QVERIFY(!manager.snapshot().status.contains(QString::fromLatin1(kToken))); + } + + // ---- end to end with the real tunnel --------------------------------- + + void realTunnelThroughTheManagerReachesThePinnedHostOnly() + { + FakeTlsServer host(b); // the paired host presents certificate b + QVERIFY(host.listen()); + QTcpServer local; // stands in for usbipd-win + QVERIFY(local.listen(QHostAddress::LocalHost, 0)); + bool replied = false; + host.onData = [&](QSslSocket *socket, const QByteArray &all) { + if (!replied && all.contains('\n')) { + replied = true; + socket->write("{\"op\":\"ready\"}\n"); + } + }; + auto env = std::make_shared(); + env->capability = [&host](int) { + Capability c; + c.available = true; + c.reason = QStringLiteral("ready"); + c.port = host.port(); + c.token = kToken; + return c; + }; + ManagerSeams seams = fakeSeams(env); + seams.createTunnel = nullptr; // use the real tunnel + ManagerOptions options = fastOptions(); + options.localUsbipPort = local.serverPort(); + Manager manager(seams, options); + ManagerConfig c = config(); + c.streamAddress = c.pairedAddress = QStringLiteral("127.0.0.1"); + QVERIFY(manager.start(c)); + QVERIFY(waitUntil([&] { return manager.snapshot().forwardingCount() == 1; }, 10000)); + const QJsonObject hello = QJsonDocument::fromJson(host.received().left(host.received().indexOf('\n'))).object(); + QCOMPARE(hello.value(QStringLiteral("op")).toString(), QStringLiteral("forward")); + QCOMPARE(hello.value(QStringLiteral("busid")).toString(), QStringLiteral("1-1")); + QCOMPARE(hello.value(QStringLiteral("token")).toString(), QString::fromLatin1(kToken)); + manager.stop(); + } + + void realTunnelToAnImpostorHostNeverSendsTheToken() + { + FakeTlsServer impostor(a); // presents certificate a, but the pin is b + QVERIFY(impostor.listen()); + QTcpServer local; + QVERIFY(local.listen(QHostAddress::LocalHost, 0)); + auto env = std::make_shared(); + env->capability = [&impostor](int) { + Capability c; + c.available = true; + c.reason = QStringLiteral("ready"); + c.port = impostor.port(); + c.token = kToken; + return c; + }; + ManagerSeams seams = fakeSeams(env); + seams.createTunnel = nullptr; + ManagerOptions options = fastOptions({20}); + options.localUsbipPort = local.serverPort(); + Manager manager(seams, options); + ManagerConfig c = config(); + c.streamAddress = c.pairedAddress = QStringLiteral("127.0.0.1"); + QVERIFY(manager.start(c)); + QVERIFY(waitUntil([&] { return deviceState(manager.snapshot(), "1-1") == DeviceState::Failed; }, 15000)); + QVERIFY2(!impostor.received().contains(kToken), "token reached a host that is not the pinned one"); + QCOMPARE(manager.snapshot().forwardingCount(), 0); + manager.stop(); + } +}; + +QTEST_GUILESS_MAIN(ManagerTest) +#include "main.moc" diff --git a/tests/usb_forwarding_manager/usb_forwarding_manager.pro b/tests/usb_forwarding_manager/usb_forwarding_manager.pro new file mode 100644 index 0000000000..de127ccb52 --- /dev/null +++ b/tests/usb_forwarding_manager/usb_forwarding_manager.pro @@ -0,0 +1,15 @@ +QT += core network testlib +QT -= gui +CONFIG += c++17 console testcase +CONFIG -= app_bundle +TEMPLATE = app +TARGET = usb_forwarding_manager_test +SOURCES += main.cpp \ + ../../app/backend/usbforwardingmanager.cpp \ + ../../app/backend/usbforwardingtunnel.cpp +HEADERS += \ + ../../app/backend/usbforwardingmanager.h \ + ../../app/backend/usbforwardingtunnel.h \ + ../../app/backend/usbforwardingcapability.h \ + ../../app/backend/pairedcertificate.h \ + ../usb_forwarding_common/faketlsserver.h