From 423fb5f87caa7074d3c1fb7b7fd64617f43515f2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 4 Nov 2026 05:44:50 +0700 Subject: [PATCH] fix(studio): resolve Npcap GUIDs to Windows NIC identities and guard adapter selection --- apps/arstack_studio/CMakeLists.txt | 8 + apps/arstack_studio/qml/SvCapturePanel.qml | 10 ++ .../src/CaptureAdapterCatalog.cpp | 140 ++++++++++++++++++ .../src/CaptureAdapterCatalog.hpp | 25 ++++ .../src/SvCaptureController.cpp | 49 +++--- .../src/SvCaptureController.hpp | 3 + apps/arstack_studio/src/SvCaptureHarness.cpp | 36 +++++ 7 files changed, 242 insertions(+), 29 deletions(-) create mode 100644 apps/arstack_studio/src/CaptureAdapterCatalog.cpp create mode 100644 apps/arstack_studio/src/CaptureAdapterCatalog.hpp diff --git a/apps/arstack_studio/CMakeLists.txt b/apps/arstack_studio/CMakeLists.txt index 055b56d42..a07e53b99 100644 --- a/apps/arstack_studio/CMakeLists.txt +++ b/apps/arstack_studio/CMakeLists.txt @@ -48,6 +48,8 @@ qt_add_executable(arstack_studio src/StudioClipboard.hpp src/SvCaptureController.cpp src/SvCaptureController.hpp + src/CaptureAdapterCatalog.cpp + src/CaptureAdapterCatalog.hpp ${ARSTACK_STUDIO_CORE_SOURCES} ) @@ -77,11 +79,17 @@ qt_add_executable(arstack_capture_harness src/SvCaptureHarness.cpp src/SvCaptureController.cpp src/SvCaptureController.hpp + src/CaptureAdapterCatalog.cpp + src/CaptureAdapterCatalog.hpp ) target_link_libraries(arstack_capture_harness PRIVATE Qt6::Core Qt6::Gui Qt6::Concurrent ) target_compile_features(arstack_capture_harness PRIVATE cxx_std_20) +if(WIN32) + target_link_libraries(arstack_studio PRIVATE iphlpapi) + target_link_libraries(arstack_capture_harness PRIVATE iphlpapi) +endif() # RC2 is a fail-closed evidence evaluator, not a hardware simulator. CI self-tests # its acceptance policy; a physical run feeds the same executable a machine-readable diff --git a/apps/arstack_studio/qml/SvCapturePanel.qml b/apps/arstack_studio/qml/SvCapturePanel.qml index f409c6240..235aa6677 100644 --- a/apps/arstack_studio/qml/SvCapturePanel.qml +++ b/apps/arstack_studio/qml/SvCapturePanel.qml @@ -138,6 +138,16 @@ Rectangle { onClicked: capture.openCaptureSupportHelp() } } + Label { + Layout.fillWidth: true + text: adapter.currentIndex >= 0 && adapter.currentIndex < capture.interfaceDetails.length + ? capture.interfaceDetails[adapter.currentIndex] + : "Select the physical Ethernet port receiving SV/PTP packets." + wrapMode: Text.WordWrap + color: panel.theme.textSoft + font.family: panel.uiFont + font.pixelSize: 9 + } RowLayout { Layout.fillWidth: true spacing: 8 diff --git a/apps/arstack_studio/src/CaptureAdapterCatalog.cpp b/apps/arstack_studio/src/CaptureAdapterCatalog.cpp new file mode 100644 index 000000000..a562d7c84 --- /dev/null +++ b/apps/arstack_studio/src/CaptureAdapterCatalog.cpp @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "CaptureAdapterCatalog.hpp" +#include +#include +#include +#include +#ifdef Q_OS_WIN +#include +#include +#include +#endif + +namespace { +QString nicKey(const QString& text) { + static const QRegularExpression guid( + QStringLiteral(R"(([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))")); + const auto found = guid.match(text); + return found.hasMatch() ? found.captured(1).toUpper() : text.trimmed().toCaseFolded(); +} +bool isVirtual(const QString& text) { + const QString lower = text.toLower(); + for (const auto* token : {"virtual", "hyper-v", "vmware", "virtualbox", + "wintun", "loopback", "bluetooth", "tap-windows", "miniport", "vpn"}) { + if (lower.contains(QString::fromLatin1(token))) return true; + } + return false; +} +struct Candidate { + QString id, label, detail; + int priority{3}; + bool physicalUp{false}; +}; +} // namespace + +QVector discoverSystemCaptureNics() { + QVector result; +#ifdef Q_OS_WIN + ULONG size = 16384; + std::vector bytes(size); + auto* list = reinterpret_cast(bytes.data()); + ULONG status = GetAdaptersAddresses( + AF_UNSPEC, GAA_FLAG_INCLUDE_ALL_INTERFACES, nullptr, list, &size); + if (status == ERROR_BUFFER_OVERFLOW && size <= 2 * 1024 * 1024) { + bytes.resize(size); + list = reinterpret_cast(bytes.data()); + status = GetAdaptersAddresses( + AF_UNSPEC, GAA_FLAG_INCLUDE_ALL_INTERFACES, nullptr, list, &size); + } + if (status != NO_ERROR) return result; + for (const auto* item = list; item; item = item->Next) { + if (!item->AdapterName) continue; + CaptureNicMetadata nic; + nic.interfaceKey = QString::fromLocal8Bit(item->AdapterName); + if (item->FriendlyName) nic.alias = QString::fromWCharArray(item->FriendlyName).trimmed(); + if (item->Description) nic.hardware = QString::fromWCharArray(item->Description).trimmed(); + nic.kind = item->IfType == IF_TYPE_ETHERNET_CSMACD + ? CaptureNicKind::Ethernet + : item->IfType == IF_TYPE_IEEE80211 + ? CaptureNicKind::Wireless : CaptureNicKind::Other; + nic.linkUp = item->OperStatus == IfOperStatusUp; + nic.virtualAdapter = isVirtual(nic.alias + QLatin1Char(' ') + nic.hardware); + result.append(nic); + } +#endif + return result; +} + +CaptureAdapterCatalog buildCaptureAdapterCatalog( + const QString& dumpcapList, const QVector& nics, + const QString& preferredId) { + CaptureAdapterCatalog result; + QHash byId; + for (const auto& nic : nics) byId.insert(nicKey(nic.interfaceKey), nic); + static const QRegularExpression linePattern( + QStringLiteral(R"(^\s*\d+\.\s+(\S+)(?:\s+\((.*)\))?)")); + QVector rows; + for (const auto& line : dumpcapList.split(QLatin1Char('\n'))) { + const auto match = linePattern.match(line); + if (!match.hasMatch()) continue; + const QString id = match.captured(1); + if (id.isEmpty()) continue; + const auto it = byId.constFind(nicKey(id)); + const bool osKnown = it != byId.constEnd(); + const CaptureNicMetadata nic = osKnown ? it.value() : CaptureNicMetadata{}; + const QString dumpcapLabel = match.captured(2).trimmed(); + const QString hardware = nic.hardware.isEmpty() ? dumpcapLabel : nic.hardware; + const QString name = !nic.alias.isEmpty() && !hardware.isEmpty() && + nic.alias.compare(hardware, Qt::CaseInsensitive) != 0 + ? nic.alias + QStringLiteral(" — ") + hardware + : !hardware.isEmpty() ? hardware : !nic.alias.isEmpty() ? nic.alias + : QStringLiteral("Unidentified capture interface"); + const bool virtualNic = nic.virtualAdapter || (!osKnown && isVirtual(dumpcapLabel)); + const bool ethernet = osKnown && nic.kind == CaptureNicKind::Ethernet && !virtualNic; + const bool wireless = osKnown && nic.kind == CaptureNicKind::Wireless; + const bool physicalUp = ethernet && nic.linkUp; + const QString type = ethernet ? QStringLiteral("Ethernet") + : wireless ? QStringLiteral("Wi-Fi") + : virtualNic ? QStringLiteral("Virtual") : QStringLiteral("Other"); + Candidate row; + row.id = id; + row.label = type + QStringLiteral(" · ") + name; + row.detail = osKnown + ? QStringLiteral("%1 · %2 · Npcap capture available") + .arg(nic.linkUp ? QStringLiteral("Connected") : QStringLiteral("Disconnected"), + ethernet ? QStringLiteral("Wired network adapter") + : wireless ? QStringLiteral("Wireless network adapter") + : virtualNic ? QStringLiteral("Virtual network adapter") + : QStringLiteral("Other network adapter")) + : QStringLiteral("Windows NIC identity unavailable; verify the capture interface."); + row.priority = physicalUp ? 0 : ethernet ? 1 : wireless ? 2 : 3; + row.physicalUp = physicalUp; + rows.append(row); + } + std::stable_sort(rows.begin(), rows.end(), + [](const Candidate& a, const Candidate& b) { return a.priority < b.priority; }); + for (const auto& row : rows) { + result.ids.append(row.id); + result.labels.append(row.label); + result.details.append(row.detail); + if (row.physicalUp) ++result.physicalUpCount; + } + const auto preferred = nicKey(preferredId); + if (!preferredId.isEmpty()) { + for (int i = 0; i < rows.size(); ++i) { + if (nicKey(rows[i].id) == preferred) { + result.suggested = i; + return result; + } + } + } + if (result.physicalUpCount == 1) { + for (int i = 0; i < rows.size(); ++i) { + if (rows[i].physicalUp) { + result.suggested = i; + break; + } + } + } + return result; +} diff --git a/apps/arstack_studio/src/CaptureAdapterCatalog.hpp b/apps/arstack_studio/src/CaptureAdapterCatalog.hpp new file mode 100644 index 000000000..438567507 --- /dev/null +++ b/apps/arstack_studio/src/CaptureAdapterCatalog.hpp @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once +#include +#include +#include + +// Npcap ID remains the capture identity. The OS metadata is presentation only. +enum class CaptureNicKind { Ethernet, Wireless, Other }; +struct CaptureNicMetadata { + QString interfaceKey; + QString alias; + QString hardware; + CaptureNicKind kind{CaptureNicKind::Other}; + bool linkUp{false}; + bool virtualAdapter{false}; +}; +struct CaptureAdapterCatalog { + QStringList ids, labels, details; + int suggested{-1}; + int physicalUpCount{0}; +}; +QVector discoverSystemCaptureNics(); +CaptureAdapterCatalog buildCaptureAdapterCatalog( + const QString& dumpcapList, const QVector& nics, + const QString& preferredId = {}); diff --git a/apps/arstack_studio/src/SvCaptureController.cpp b/apps/arstack_studio/src/SvCaptureController.cpp index 1e2a60d5e..d945b2f16 100644 --- a/apps/arstack_studio/src/SvCaptureController.cpp +++ b/apps/arstack_studio/src/SvCaptureController.cpp @@ -1,5 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later #include "SvCaptureController.hpp" +#include "CaptureAdapterCatalog.hpp" #include #include @@ -14,6 +15,7 @@ #include #include #include +#include #include #include #include @@ -187,38 +189,24 @@ SvCaptureController::SvCaptureController(QObject* parent) : QObject(parent) { setStatus(QStringLiteral("Adapter discovery failed. Install Wireshark/Npcap and check capture permissions.")); return; } - interfaceIds_.clear(); - interfaceLabels_.clear(); - suggestedInterface_ = -1; - const QRegularExpression lineRe(QStringLiteral(R"(^\s*\d+\.\s+(\S+)(?:\s+\((.*)\))?)")); - const QString output = QString::fromUtf8(discovery_.readAllStandardOutput()); - for (const auto& line : output.split(QLatin1Char('\n'))) { - const auto match = lineRe.match(line); - if (!match.hasMatch()) continue; - const QString id = match.captured(1); - const QString label = match.captured(2).trimmed(); - interfaceIds_.append(id); - interfaceLabels_.append(label.isEmpty() ? id : label + QStringLiteral(" · ") + id); - } - int eligible = 0; - for (int i = 0; i < interfaceLabels_.size(); ++i) { - const QString label = interfaceLabels_.at(i).toLower(); - if (label.contains(QStringLiteral("loopback")) || - label.contains(QStringLiteral("bluetooth")) || - label.contains(QStringLiteral("wan miniport")) || - label.contains(QStringLiteral("wireless")) || - label.contains(QStringLiteral("wi-fi")) || - label.contains(QStringLiteral("wifi"))) continue; - ++eligible; - suggestedInterface_ = i; - } - if (eligible != 1) suggestedInterface_ = -1; + // Display name comes from Windows, capture ID comes from dumpcap. + // Persist only the stable NPF interface ID, never the -D list index. + QSettings prefs(QSettings::IniFormat, QSettings::UserScope, + QStringLiteral("ARStack"), QStringLiteral("Studio")); + const auto catalog = buildCaptureAdapterCatalog( + QString::fromUtf8(discovery_.readAllStandardOutput()), + discoverSystemCaptureNics(), + prefs.value(QStringLiteral("capture/lastInterface")).toString()); + interfaceIds_ = catalog.ids; + interfaceLabels_ = catalog.labels; + interfaceDetails_ = catalog.details; + suggestedInterface_ = catalog.suggested; emit interfacesChanged(); setStatus(interfaceIds_.isEmpty() ? QStringLiteral("No Npcap capture interfaces found.") - : eligible == 1 - ? QStringLiteral("Ethernet adapter identified. One-click capture is ready.") - : QStringLiteral("Select the mirrored/TAP Ethernet interface once; automatic selection is ambiguous.")); + : suggestedInterface_ >= 0 + ? QStringLiteral("Adapter identified. Verify actual SV/PTP traffic on this port before acceptance.") + : QStringLiteral("Choose the correct Ethernet adapter by its Windows hardware name.")); }); connect(&discovery_, &QProcess::errorOccurred, this, [this](QProcess::ProcessError) { setStatus(QStringLiteral("Unable to enumerate network capture adapters. Install Wireshark with Npcap.")); @@ -333,6 +321,9 @@ bool SvCaptureController::startCapture(int index, int seconds, const QVariantMap activeEvents_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_events.csv")); activeJson_ = QDir(outputFolder_).filePath(filename + QStringLiteral("_report.json")); activeAdapter_ = interfaceIds_.at(index); + QSettings prefs(QSettings::IniFormat, QSettings::UserScope, + QStringLiteral("ARStack"), QStringLiteral("Studio")); + prefs.setValue(QStringLiteral("capture/lastInterface"), activeAdapter_); activeStudioTelemetry_ = studioTelemetry; activeStudioTelemetrySeries_.clear(); activeStudioTelemetrySeries_.append(studioTelemetry); diff --git a/apps/arstack_studio/src/SvCaptureController.hpp b/apps/arstack_studio/src/SvCaptureController.hpp index 743e58fc7..a085e8cd8 100644 --- a/apps/arstack_studio/src/SvCaptureController.hpp +++ b/apps/arstack_studio/src/SvCaptureController.hpp @@ -11,6 +11,7 @@ class SvCaptureController : public QObject { Q_OBJECT Q_PROPERTY(QStringList interfaces READ interfaces NOTIFY interfacesChanged) + Q_PROPERTY(QStringList interfaceDetails READ interfaceDetails NOTIFY interfacesChanged) Q_PROPERTY(int suggestedInterface READ suggestedInterface NOTIFY interfacesChanged) Q_PROPERTY(QString status READ status NOTIFY statusChanged) Q_PROPERTY(bool capturing READ capturing NOTIFY stateChanged) @@ -24,6 +25,7 @@ class SvCaptureController : public QObject { ~SvCaptureController() override; [[nodiscard]] QStringList interfaces() const { return interfaceLabels_; } + [[nodiscard]] QStringList interfaceDetails() const { return interfaceDetails_; } [[nodiscard]] int suggestedInterface() const { return suggestedInterface_; } [[nodiscard]] QString status() const { return status_; } [[nodiscard]] bool capturing() const { return capture_.state() != QProcess::NotRunning; } @@ -62,6 +64,7 @@ class SvCaptureController : public QObject { QProcess capture_; QFutureWatcher decoder_; QStringList interfaceLabels_; + QStringList interfaceDetails_; QStringList interfaceIds_; int suggestedInterface_{-1}; QString status_{QStringLiteral("Discover an Ethernet capture adapter to begin.")}; diff --git a/apps/arstack_studio/src/SvCaptureHarness.cpp b/apps/arstack_studio/src/SvCaptureHarness.cpp index b1b1a2dd8..886eca328 100644 --- a/apps/arstack_studio/src/SvCaptureHarness.cpp +++ b/apps/arstack_studio/src/SvCaptureHarness.cpp @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later // Deterministic fixture uses the exact production capture decoder; no Npcap needed. #include "SvCaptureController.hpp" +#include "CaptureAdapterCatalog.hpp" #include #include @@ -79,6 +80,41 @@ class SvCaptureHarness { static bool run() { QTemporaryDir dir; if (!dir.isValid()) return false; + // Realtek must be shown instead of Local Area Connection* / GUID. + // Reordering dumpcap -D must never change the chosen NPF identity. + const QString realId = QStringLiteral(R"(\Device\NPF_{992B837A-63FA-4D5C-9EF1-92B687EA1053})"); + const QString virtualId = QStringLiteral(R"(\Device\NPF_{00000000-0000-0000-0000-000000000001})"); + const QVector known{ + {QStringLiteral("{992B837A-63FA-4D5C-9EF1-92B687EA1053}"), + QStringLiteral("Ethernet"), QStringLiteral("Realtek PCIe GbE Family Controller"), + CaptureNicKind::Ethernet, true, false}, + {QStringLiteral("{00000000-0000-0000-0000-000000000001}"), + QStringLiteral("vEthernet"), QStringLiteral("Hyper-V Virtual Ethernet"), + CaptureNicKind::Ethernet, true, true} + }; + const auto catalog = buildCaptureAdapterCatalog( + QStringLiteral("1. ") + virtualId + QStringLiteral(" (Local Area Connection* 10)\n") + + QStringLiteral("2. ") + realId + QStringLiteral(" (Local Area Connection* 9)\n"), known); + const auto reordered = buildCaptureAdapterCatalog( + QStringLiteral("1. ") + realId + QStringLiteral(" (Local Area Connection* 9)\n") + + QStringLiteral("2. ") + virtualId + QStringLiteral(" (Local Area Connection* 10)\n"), + known, realId); + const auto ambiguous = buildCaptureAdapterCatalog( + QStringLiteral("1. ") + realId + QStringLiteral(" (LAN)\n") + + QStringLiteral("2. ") + virtualId + QStringLiteral(" (LAN)\n"), + {{known[0].interfaceKey, known[0].alias, known[0].hardware, + CaptureNicKind::Ethernet, true, false}, + {known[1].interfaceKey, QStringLiteral("Ethernet 2"), QStringLiteral("Intel I225-V"), + CaptureNicKind::Ethernet, true, false}}); + if (catalog.suggested != 0 || catalog.ids[0] != realId || + !catalog.labels[0].contains(QStringLiteral("Realtek PCIe GbE")) || + catalog.labels[0].contains(QStringLiteral("NPF_")) || + reordered.suggested != 0 || reordered.ids[0] != realId || + ambiguous.suggested != -1) { + qCritical() << "NIC discovery failed: identity, display or safe selection regression"; + return false; + } + // Regression: actual 2026-10-22 24-byte PCAP despite old cached // 3999 FPS. Capture must block while publisher STOPPED, not silently // make a successful-looking 30s measurement.