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