From b8428a5b3ce180d8e88fc1a646adcfc0ffd570c2 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:25:22 +0700 Subject: [PATCH 1/8] feat(workbench): bind read-only GOOSE subscribe to canonical sniffer and exact stream identity --- apps/ied_simulator/qml/BrowserGoosePane.qml | 153 +++++++++++++++++- .../ied_simulator/qml/IedBrowserWorkspace.qml | 4 +- apps/ied_simulator/qml/Main.qml | 1 + .../src/GooseMonitorController.cpp | 84 ++++++++++ .../src/GooseMonitorController.hpp | 10 ++ 5 files changed, 250 insertions(+), 2 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserGoosePane.qml b/apps/ied_simulator/qml/BrowserGoosePane.qml index 8ff12d889..a951cb045 100644 --- a/apps/ied_simulator/qml/BrowserGoosePane.qml +++ b/apps/ied_simulator/qml/BrowserGoosePane.qml @@ -2,11 +2,31 @@ import QtQuick import QtQuick.Controls import QtQuick.Layouts +import ARStack.IedSimulator 1.0 Rectangle { id: root required property var theme required property var context + required property GooseMonitorController monitor + + // One shared monitor is owned by Main.qml/Sniffer. This pane never + // starts an independent raw socket, MMS Read, or background poller. + property int evidenceGeneration: 0 + Connections { + target: root.monitor + function onMonitorChanged() { root.evidenceGeneration++ } + } + property var observation: { + const generation = root.evidenceGeneration + if (root.selectedIndex < 0 || !root.selectedStream.reference) + return ({matched: false, reason: "Select a configured GOOSE stream"}) + return root.monitor.observedForStream( + root.selectedStream.reference || "", + root.selectedStream.dataSet || "", + root.selectedStream.appId || "", + root.selectedStream.destinationMac || "") + } property var visibleStreams: context.gooseStreams property int selectedIndex: -1 @@ -148,6 +168,104 @@ Rectangle { Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + Rectangle { + Layout.fillWidth: true + implicitHeight: subscriptionPanel.implicitHeight + 16 + color: theme.surface + border.width: 1 + border.color: root.observation.matched + ? (root.observation.timedOut ? theme.amber : theme.green) + : theme.line + + ColumnLayout { + id: subscriptionPanel + anchors.left: parent.left + anchors.right: parent.right + anchors.margins: 8 + spacing: 6 + + RowLayout { + Layout.fillWidth: true + Label { + text: "GOOSE Subscribe · read-only observation" + color: theme.text + font.pixelSize: 10 + font.weight: Font.DemiBold + } + Item { Layout.fillWidth: true } + Label { + text: root.monitor.capturing ? "CAPTURING" : "STOPPED" + color: root.monitor.capturing ? theme.green : theme.muted + font.pixelSize: 8 + } + } + + RowLayout { + Layout.fillWidth: true + Label { + text: "NIC" + color: theme.muted + font.pixelSize: 9 + } + ComboBox { + id: gooseNicSelector + Layout.fillWidth: true + model: root.monitor.interfaces + enabled: !root.monitor.capturing + currentIndex: root.monitor.interfaces.indexOf(root.monitor.interfaceName) + displayText: currentIndex < 0 ? "Select capture interface" : currentText + onActivated: root.monitor.interfaceName = currentText + } + Button { + text: root.monitor.capturing ? "Stop" : "GOOSE Subscribe" + enabled: root.monitor.capturing || root.monitor.interfaceName.length > 0 + onClicked: root.monitor.capturing + ? root.monitor.stopCapture() + : root.monitor.startCapture() + ToolTip.visible: hovered + ToolTip.text: "Starts/stops the shared passive Ethernet capture; does not send GOOSE or MMS commands." + } + } + + Label { + Layout.fillWidth: true + text: root.observation.matched + ? (root.observation.reason + " · " + root.observation.appIdText + + " · st/sq " + root.observation.stNum + "/" + root.observation.sqNum + + " · frames " + root.observation.packets) + : (root.monitor.capturing + ? "Waiting for matching Ethernet GOOSE · " + root.observation.reason + : "No live capture · select NIC and press GOOSE Subscribe") + color: root.observation.matched && !root.observation.timedOut + ? theme.green : theme.textSoft + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + visible: root.observation.matched && + root.selectedStream.members && + root.selectedStream.members.length !== root.observation.valueCount + text: "DataSet count mismatch · configured " + + root.selectedStream.members.length + + " · observed " + root.observation.valueCount + + " (do not assume aligned signals)" + color: theme.amber + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + Label { + Layout.fillWidth: true + text: root.monitor.statusText + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + } + + Rectangle { Layout.fillWidth: true; Layout.preferredHeight: 1; color: theme.lineSoft } + Rectangle { Layout.fillWidth: true implicitHeight: gooseAttributes.implicitHeight @@ -272,13 +390,46 @@ Rectangle { font.pixelSize: 9 elide: Text.ElideMiddle } + Label { + Layout.preferredWidth: 175 + visible: root.observation.matched + text: root.observation.valueCount === (root.selectedStream.members || []).length + && index < root.observation.values.length + ? root.observation.values[index] + : "Unresolved · count mismatch" + color: root.observation.timedOut ? theme.amber : theme.green + font.pixelSize: 9 + elide: Text.ElideRight + } } } } Label { Layout.fillWidth: true - text: "Engineering-model evidence only. Live Ethernet capture remains in Sniffer; publication remains in IED Simulator." + visible: root.observation.matched + && (!root.selectedStream.members || root.selectedStream.members.length === 0) + text: "Unbound wire allData (ordered, no guessed signal names)" + color: theme.textSoft + font.pixelSize: 9 + } + Repeater { + model: root.observation.matched + && (!root.selectedStream.members || root.selectedStream.members.length === 0) + ? root.observation.values : [] + delegate: Label { + required property string modelData + Layout.fillWidth: true + text: modelData + color: theme.text + font.pixelSize: 9 + elide: Text.ElideRight + } + } + + Label { + Layout.fillWidth: true + text: "Observed values come from the shared passive Sniffer, bound only by exact GOOSE identity. No GOOSE publish, MMS writes or implicit reads." color: theme.muted font.pixelSize: 8 wrapMode: Text.WordWrap diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 5630e9169..8ac1037bd 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -12,6 +12,7 @@ Item { required property var session required property var client required property var context + required property var gooseMonitor required property var reports required property var utilities required property var controls @@ -732,7 +733,7 @@ Item { Label { visible: root.activeSection === 5 - text: "Configured model view" + text: gooseMonitor.capturing ? "Read-only GOOSE capture active" : "GOOSE capture idle" color: root.theme.muted font.pixelSize: 8 } @@ -797,6 +798,7 @@ Item { id: goosePane theme: root.theme context: root.context + monitor: root.gooseMonitor selectedIndex: root.selectedGooseIndex } diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 409dd6ba3..194bfe036 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -478,6 +478,7 @@ ApplicationWindow { session: root.browserFleet.sessionAt(index) client: root.browserFleet.clientAt(index) context: root.browserFleet.contextAt(index) + gooseMonitor: gooseMonitor reports: root.browserFleet.reportsAt(index) utilities: root.browserFleet.utilitiesAt(index) controls: root.browserFleet.controlsAt(index) diff --git a/apps/ied_simulator/src/GooseMonitorController.cpp b/apps/ied_simulator/src/GooseMonitorController.cpp index 928aded79..008ff1932 100644 --- a/apps/ied_simulator/src/GooseMonitorController.cpp +++ b/apps/ied_simulator/src/GooseMonitorController.cpp @@ -159,6 +159,90 @@ void GooseMonitorController::setInterfaceName(const QString& value) { emit monitorChanged(); } +QVariantMap GooseMonitorController::observedForStream( + const QString& goCbRef, + const QString& dataSet, + const QString& appId, + const QString& destinationMac) const { + QVariantMap result; + result.insert(QStringLiteral("matched"), false); + result.insert(QStringLiteral("reason"), QStringLiteral("Awaiting an exact GOOSE frame")); + const auto reference = goCbRef.trimmed(); + if (reference.isEmpty()) { + result.insert(QStringLiteral("reason"), + QStringLiteral("Configured goCBRef unavailable; no identity binding")); + return result; + } + + // IEC 61850 SCL APPID is a hexadecimal octet-string representation, + // even when it contains digits only (e.g. '1001' = 0x1001). + const auto appIdText = appId.trimmed(); + bool appIdValid = true; + unsigned int requestedAppId = 0U; + if (!appIdText.isEmpty()) { + requestedAppId = appIdText.toUInt(&appIdValid, 16); + if (!appIdValid || requestedAppId > 0xFFFFU) { + result.insert(QStringLiteral("reason"), + QStringLiteral("Invalid configured hexadecimal GOOSE APPID")); + return result; + } + } + + const auto normalizeMac = [](QString mac) { + mac.remove(QLatin1Char(':')); + mac.remove(QLatin1Char('-')); + mac.remove(QLatin1Char('.')); + return mac.trimmed().toUpper(); + }; + const auto requestedMac = normalizeMac(destinationMac); + if (!requestedMac.isEmpty()) { + if (requestedMac.size() != 12) { + result.insert(QStringLiteral("reason"), + QStringLiteral("Invalid configured destination MAC")); + return result; + } + for (const QChar ch : requestedMac) { + if (!ch.isDigit() && (ch < QLatin1Char('A') || ch > QLatin1Char('F'))) { + result.insert(QStringLiteral("reason"), + QStringLiteral("Invalid configured destination MAC")); + return result; + } + } + } + + const auto requestedDataSet = dataSet.trimmed(); + const StreamSlot* found = nullptr; + for (const auto& slot : streams_) { + const auto& frame = slot.frame; + // Never infer the publisher from APPID alone. Use exact goCBRef + // plus all available binding constraints; retain original case. + if (QString::fromStdString(frame.pdu.go_cb_ref) != reference || + (!requestedDataSet.isEmpty() && + QString::fromStdString(frame.pdu.data_set_reference) != requestedDataSet) || + (!appIdText.isEmpty() && frame.app_id != requestedAppId) || + (!requestedMac.isEmpty() && + normalizeMac(QString::fromStdString(frame.destination.to_string())) != requestedMac)) { + continue; + } + if (found != nullptr) { + result.insert(QStringLiteral("reason"), + QStringLiteral("Ambiguous GOOSE publisher identity; multiple sources match")); + return result; + } + found = &slot; + } + if (found == nullptr) return result; + + result = streamMap(*found); + result.insert(QStringLiteral("matched"), true); + result.insert(QStringLiteral("values"), found->values); + result.insert(QStringLiteral("reason"), + found->timedOut + ? QStringLiteral("Observed frame expired (TimeAllowedToLive)") + : QStringLiteral("Exact wire-identity match (read-only)")); + return result; +} + bool GooseMonitorController::startCapture() { if (capturing_) return true; if (interfaceName_.isEmpty()) { diff --git a/apps/ied_simulator/src/GooseMonitorController.hpp b/apps/ied_simulator/src/GooseMonitorController.hpp index 23a3ef4f2..a267a00d6 100644 --- a/apps/ied_simulator/src/GooseMonitorController.hpp +++ b/apps/ied_simulator/src/GooseMonitorController.hpp @@ -113,6 +113,16 @@ class GooseMonitorController : public QAbstractListModel { Q_INVOKABLE void select(int row); Q_INVOKABLE bool exportPcap(const QUrl& fileUrl); + // Passive engineering binding, not a new subscriber or MMS operation. + // A frame is exposed only if its case-sensitive goCBRef and every + // configured address/identity constraint match exactly and uniquely. + // Never substitutes APPID-only guesses for a verified IED source. + Q_INVOKABLE QVariantMap observedForStream( + const QString& goCbRef, + const QString& dataSet, + const QString& appId, + const QString& destinationMac) const; + // Deterministic QA/offline evidence path. This uses the same decoder, // supervisor, bounded model and retention path as live raw-Ethernet capture. [[nodiscard]] bool ingestFrameBytes( From 96cc466b7b8c1899ee8f97bf97aa27392c5ac20b Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:28:12 +0700 Subject: [PATCH 2/8] test(workbench): verify exact GOOSE publisher binding, ambiguity rejection and retained capture state --- apps/ied_simulator/qml/BrowserGoosePane.qml | 7 ++- .../ied_simulator/qml/IedBrowserWorkspace.qml | 6 +- apps/ied_simulator/qml/Main.qml | 2 +- .../src/GooseMonitorController.cpp | 5 +- .../src/ied_goose_workspace_qa.cpp | 63 ++++++++++++++++++- 5 files changed, 74 insertions(+), 9 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserGoosePane.qml b/apps/ied_simulator/qml/BrowserGoosePane.qml index a951cb045..23a1061ad 100644 --- a/apps/ied_simulator/qml/BrowserGoosePane.qml +++ b/apps/ied_simulator/qml/BrowserGoosePane.qml @@ -174,7 +174,7 @@ Rectangle { color: theme.surface border.width: 1 border.color: root.observation.matched - ? (root.observation.timedOut ? theme.amber : theme.green) + ? (root.observation.timedOut || !root.monitor.capturing ? theme.amber : theme.green) : theme.line ColumnLayout { @@ -237,7 +237,7 @@ Rectangle { ? "Waiting for matching Ethernet GOOSE · " + root.observation.reason : "No live capture · select NIC and press GOOSE Subscribe") color: root.observation.matched && !root.observation.timedOut - ? theme.green : theme.textSoft + && root.monitor.capturing ? theme.green : theme.textSoft font.pixelSize: 9 wrapMode: Text.WordWrap } @@ -397,7 +397,8 @@ Rectangle { && index < root.observation.values.length ? root.observation.values[index] : "Unresolved · count mismatch" - color: root.observation.timedOut ? theme.amber : theme.green + color: root.observation.timedOut || !root.monitor.capturing + ? theme.amber : theme.green font.pixelSize: 9 elide: Text.ElideRight } diff --git a/apps/ied_simulator/qml/IedBrowserWorkspace.qml b/apps/ied_simulator/qml/IedBrowserWorkspace.qml index 8ac1037bd..6856c4fb2 100644 --- a/apps/ied_simulator/qml/IedBrowserWorkspace.qml +++ b/apps/ied_simulator/qml/IedBrowserWorkspace.qml @@ -12,7 +12,7 @@ Item { required property var session required property var client required property var context - required property var gooseMonitor + required property var gooseBackend required property var reports required property var utilities required property var controls @@ -733,7 +733,7 @@ Item { Label { visible: root.activeSection === 5 - text: gooseMonitor.capturing ? "Read-only GOOSE capture active" : "GOOSE capture idle" + text: root.gooseBackend.capturing ? "Read-only GOOSE capture active" : "GOOSE capture idle" color: root.theme.muted font.pixelSize: 8 } @@ -798,7 +798,7 @@ Item { id: goosePane theme: root.theme context: root.context - monitor: root.gooseMonitor + monitor: root.gooseBackend selectedIndex: root.selectedGooseIndex } diff --git a/apps/ied_simulator/qml/Main.qml b/apps/ied_simulator/qml/Main.qml index 194bfe036..cdc00d06c 100644 --- a/apps/ied_simulator/qml/Main.qml +++ b/apps/ied_simulator/qml/Main.qml @@ -478,7 +478,7 @@ ApplicationWindow { session: root.browserFleet.sessionAt(index) client: root.browserFleet.clientAt(index) context: root.browserFleet.contextAt(index) - gooseMonitor: gooseMonitor + gooseBackend: gooseMonitor reports: root.browserFleet.reportsAt(index) utilities: root.browserFleet.utilitiesAt(index) controls: root.browserFleet.controlsAt(index) diff --git a/apps/ied_simulator/src/GooseMonitorController.cpp b/apps/ied_simulator/src/GooseMonitorController.cpp index 008ff1932..a3b0430c8 100644 --- a/apps/ied_simulator/src/GooseMonitorController.cpp +++ b/apps/ied_simulator/src/GooseMonitorController.cpp @@ -236,10 +236,13 @@ QVariantMap GooseMonitorController::observedForStream( result = streamMap(*found); result.insert(QStringLiteral("matched"), true); result.insert(QStringLiteral("values"), found->values); + result.insert(QStringLiteral("captureActive"), capturing_); result.insert(QStringLiteral("reason"), found->timedOut ? QStringLiteral("Observed frame expired (TimeAllowedToLive)") - : QStringLiteral("Exact wire-identity match (read-only)")); + : (!capturing_ + ? QStringLiteral("Last observed frame retained; capture not active") + : QStringLiteral("Exact wire-identity match (read-only)"))); return result; } diff --git a/apps/ied_simulator/src/ied_goose_workspace_qa.cpp b/apps/ied_simulator/src/ied_goose_workspace_qa.cpp index 283a5788a..6c2ec8504 100644 --- a/apps/ied_simulator/src/ied_goose_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_goose_workspace_qa.cpp @@ -76,6 +76,66 @@ int main(int argc, char** argv) { REQUIRE(publisherEvidence.value(QStringLiteral("valueCount")).toInt() > 0, "decoded allData values were not exposed"); + + // Browser GOOSE reuses the Sniffer decoder and must not guess + // identities from APPID alone or swap publishers across IEDs. + const auto goRef = field(publisherEvidence, "goCbRef"); + const auto dataSet = field(publisherEvidence, "dataSetReference"); + const auto dst = field(publisherEvidence, "destinationMac"); + const auto bound = monitor.observedForStream( + goRef, dataSet, QStringLiteral("1001"), dst); + REQUIRE(bound.value(QStringLiteral("matched")).toBool(), + "configured GOOSE stream did not bind to decoded PCAP evidence"); + REQUIRE(bound.value(QStringLiteral("values")).toStringList().size() == + publisherEvidence.value(QStringLiteral("valueCount")).toInt(), + "bound allData order or member count not preserved"); + REQUIRE(bound.value(QStringLiteral("stNum")).toULongLong() == 2U, + "bound stream did not expose last observed state"); + REQUIRE(!monitor.observedForStream(QString{}, dataSet, "1001", dst) + .value(QStringLiteral("matched")).toBool(), + "missing goCBRef must not match"); + REQUIRE(!monitor.observedForStream(goRef.toLower(), dataSet, "1001", dst) + .value(QStringLiteral("matched")).toBool(), + "GOOSE goCBRef lost case-sensitive identity"); + REQUIRE(!monitor.observedForStream(goRef, dataSet + "WRONG", "1001", dst) + .value(QStringLiteral("matched")).toBool(), + "wrong DataSet incorrectly adopted an observed value"); + REQUIRE(!monitor.observedForStream(goRef, dataSet, "1002", dst) + .value(QStringLiteral("matched")).toBool(), + "APPID mismatch leaked another stream's values"); + REQUIRE(!monitor.observedForStream(goRef, dataSet, "not-hex", dst) + .value(QStringLiteral("matched")).toBool(), + "malformed configured APPID was accepted"); + REQUIRE(!monitor.observedForStream(goRef, dataSet, "1001", "XX-00") + .value(QStringLiteral("matched")).toBool(), + "invalid configured multicast MAC was accepted"); + REQUIRE(!monitor.observedForStream(goRef, dataSet, "1001", "01-0C-CD-01-00-FF") + .value(QStringLiteral("matched")).toBool(), + "destination MAC mismatch leaked a stream"); + + GooseMonitorController ambiguousMonitor; + REQUIRE(ambiguousMonitor.ingestFrameBytes( + packets.back().frame, base + std::chrono::seconds{1}), + "secondary identity test failed first import"); + goose::GooseFrame alternate; + REQUIRE(goose::GooseFrameCodec::try_decode(packets.back().frame, alternate), + "could not decode alternate publisher identity"); + alternate.source = ethernet::MacAddress::parse("02-11-22-33-44-55"); + alternate.destination = ethernet::MacAddress::parse("01-0C-CD-01-00-FE"); + REQUIRE(ambiguousMonitor.ingestFrameBytes( + goose::GooseFrameCodec::encode(alternate), + base + std::chrono::seconds{2}), + "secondary identity test rejected valid other publisher"); + REQUIRE(!ambiguousMonitor.observedForStream(goRef, dataSet, "1001", "") + .value(QStringLiteral("matched")).toBool(), + "ambiguous publisher identity must fail closed"); + REQUIRE(ambiguousMonitor.observedForStream(goRef, dataSet, "1001", dst) + .value(QStringLiteral("matched")).toBool(), + "exact multicast MAC failed to disambiguate publishers"); + REQUIRE(ambiguousMonitor.observedForStream( + goRef, dataSet, "1001", "01-0C-CD-01-00-FE") + .value(QStringLiteral("matched")).toBool(), + "alternate exact publisher could not be bound"); goose::GooseFrame baseFrame; REQUIRE(goose::GooseFrameCodec::try_decode(packets.back().frame, baseFrame), "could not decode Stage M evidence for sequence QA"); @@ -169,7 +229,8 @@ int main(int argc, char** argv) { std::cout << "GOOSE_WORKSPACE_PASS pcap_packets=3 streams=" << monitor.rowCount() << " appid=0x1001 values=decoded duplicate=pass gap=pass regression=pass" - " state_jump=pass state_regression=pass timeout=pass pcap_export=pass\n"; + " state_jump=pass state_regression=pass timeout=pass pcap_export=pass" + " browser_subscribe_binding=pass publisher_isolation=pass\n"; std::cout << "GOOSE_MONITOR_NEGATIVE_PASS malformed=rejected oversize=rejected capacity=rejected" " missing_interface=rejected explicit_binding=required bounded_streams=" From 9f21787414b9adb15ff7d772205b35120ebe4fef Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:30:15 +0700 Subject: [PATCH 3/8] fix(goose): fail closed on ConfRev or DataSet-count mismatch before labeling wire values --- apps/ied_simulator/qml/BrowserGoosePane.qml | 35 +++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserGoosePane.qml b/apps/ied_simulator/qml/BrowserGoosePane.qml index 23a1061ad..64ef78d30 100644 --- a/apps/ied_simulator/qml/BrowserGoosePane.qml +++ b/apps/ied_simulator/qml/BrowserGoosePane.qml @@ -28,6 +28,21 @@ Rectangle { root.selectedStream.destinationMac || "") } + // A matching frame is not evidence that the configured FCDA order is + // unchanged. Only align member names when ConfRev and counts agree. + property bool modelAligned: { + if (!root.observation.matched || !root.selectedStream.members || + root.selectedStream.members.length === 0 || + root.selectedStream.members.length !== root.observation.valueCount) + return false + const configured = root.selectedStream.confRev + if (configured === undefined || configured === null || String(configured).length === 0) + return false + const expected = Number(configured) + return Number.isSafeInteger(expected) && expected >= 0 + && expected === Number(root.observation.confRev) + } + property var visibleStreams: context.gooseStreams property int selectedIndex: -1 property var selectedStream: selectedIndex >= 0 && selectedIndex < visibleStreams.length @@ -254,6 +269,17 @@ Rectangle { font.pixelSize: 9 wrapMode: Text.WordWrap } + Label { + Layout.fillWidth: true + visible: root.observation.matched && !root.modelAligned + text: "Raw values are not assigned to configured signals until " + + "ConfRev and DataSet member count agree. " + + "Configured ConfRev=" + root.text(root.selectedStream.confRev) + + " · observed=" + root.observation.confRev + color: theme.amber + font.pixelSize: 9 + wrapMode: Text.WordWrap + } Label { Layout.fillWidth: true text: root.monitor.statusText @@ -393,8 +419,7 @@ Rectangle { Label { Layout.preferredWidth: 175 visible: root.observation.matched - text: root.observation.valueCount === (root.selectedStream.members || []).length - && index < root.observation.values.length + text: root.modelAligned && index < root.observation.values.length ? root.observation.values[index] : "Unresolved · count mismatch" color: root.observation.timedOut || !root.monitor.capturing @@ -408,15 +433,13 @@ Rectangle { Label { Layout.fillWidth: true - visible: root.observation.matched - && (!root.selectedStream.members || root.selectedStream.members.length === 0) + visible: root.observation.matched && !root.modelAligned text: "Unbound wire allData (ordered, no guessed signal names)" color: theme.textSoft font.pixelSize: 9 } Repeater { - model: root.observation.matched - && (!root.selectedStream.members || root.selectedStream.members.length === 0) + model: root.observation.matched && !root.modelAligned ? root.observation.values : [] delegate: Label { required property string modelData From 28050d302fb90917f02307bca6f01cf3c0dd323f Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:30:34 +0700 Subject: [PATCH 4/8] fix(qml): use portable finite integer check for GOOSE ConfRev alignment --- apps/ied_simulator/qml/BrowserGoosePane.qml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/qml/BrowserGoosePane.qml b/apps/ied_simulator/qml/BrowserGoosePane.qml index 64ef78d30..f0b068144 100644 --- a/apps/ied_simulator/qml/BrowserGoosePane.qml +++ b/apps/ied_simulator/qml/BrowserGoosePane.qml @@ -39,7 +39,7 @@ Rectangle { if (configured === undefined || configured === null || String(configured).length === 0) return false const expected = Number(configured) - return Number.isSafeInteger(expected) && expected >= 0 + return isFinite(expected) && Math.floor(expected) === expected && expected >= 0 && expected === Number(root.observation.confRev) } From 29613f49d568eb3e588fe59ee2e80ef2e5519e77 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:33:41 +0700 Subject: [PATCH 5/8] fix(goose): enforce SCL goID and VLAN identity before Browser subscription binding --- apps/ied_simulator/qml/BrowserGoosePane.qml | 6 +-- .../src/GooseMonitorController.cpp | 38 +++++++++++++++++++ .../src/GooseMonitorController.hpp | 6 +++ .../src/ied_goose_workspace_qa.cpp | 37 ++++++++++++++++++ 4 files changed, 82 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserGoosePane.qml b/apps/ied_simulator/qml/BrowserGoosePane.qml index f0b068144..7c8c88f53 100644 --- a/apps/ied_simulator/qml/BrowserGoosePane.qml +++ b/apps/ied_simulator/qml/BrowserGoosePane.qml @@ -21,11 +21,7 @@ Rectangle { const generation = root.evidenceGeneration if (root.selectedIndex < 0 || !root.selectedStream.reference) return ({matched: false, reason: "Select a configured GOOSE stream"}) - return root.monitor.observedForStream( - root.selectedStream.reference || "", - root.selectedStream.dataSet || "", - root.selectedStream.appId || "", - root.selectedStream.destinationMac || "") + return root.monitor.observedForConfiguredStream(root.selectedStream) } // A matching frame is not evidence that the configured FCDA order is diff --git a/apps/ied_simulator/src/GooseMonitorController.cpp b/apps/ied_simulator/src/GooseMonitorController.cpp index a3b0430c8..c623199ac 100644 --- a/apps/ied_simulator/src/GooseMonitorController.cpp +++ b/apps/ied_simulator/src/GooseMonitorController.cpp @@ -246,6 +246,44 @@ QVariantMap GooseMonitorController::observedForStream( return result; } +QVariantMap GooseMonitorController::observedForConfiguredStream( + const QVariantMap& configured) const { + const auto str = [&](const char* key) { + return configured.value(QString::fromLatin1(key)).toString(); + }; + auto evidence = observedForStream( + str("reference"), str("dataSet"), str("appId"), str("destinationMac")); + if (!evidence.value(QStringLiteral("matched")).toBool()) return evidence; + + const auto fail = [&](const QString& reason) { + QVariantMap rejected; + rejected.insert(QStringLiteral("matched"), false); + rejected.insert(QStringLiteral("reason"), reason); + return rejected; + }; + const auto goId = str("goId"); + if (!goId.isEmpty() && goId != evidence.value(QStringLiteral("goId")).toString()) { + return fail(QStringLiteral("Configured goID differs from observed GOOSE stream")); + } + for (const auto& binding : { + std::pair{"vlanId", "vlanId"}, + std::pair{"vlanPriority", "vlanPriority"}}) { + const auto expected = configured.value(QString::fromLatin1(binding.first)); + if (!expected.isValid() || expected.isNull()) continue; + bool ok = false; + const auto numeric = expected.toInt(&ok); + if (!ok || numeric < 0 || + numeric > (std::string_view{binding.first} == "vlanId" ? 4094 : 7)) { + return fail(QStringLiteral("Invalid configured GOOSE VLAN field")); + } + const auto observed = evidence.value(QString::fromLatin1(binding.second)); + if (!observed.isValid() || observed.isNull() || observed.toInt() != numeric) { + return fail(QStringLiteral("Configured GOOSE VLAN field differs from observed frame")); + } + } + return evidence; +} + bool GooseMonitorController::startCapture() { if (capturing_) return true; if (interfaceName_.isEmpty()) { diff --git a/apps/ied_simulator/src/GooseMonitorController.hpp b/apps/ied_simulator/src/GooseMonitorController.hpp index a267a00d6..95be32eec 100644 --- a/apps/ied_simulator/src/GooseMonitorController.hpp +++ b/apps/ied_simulator/src/GooseMonitorController.hpp @@ -123,6 +123,12 @@ class GooseMonitorController : public QAbstractListModel { const QString& appId, const QString& destinationMac) const; + // Full configured SCL/live identity, including optional goID and VLAN + // constraints. A partial or inconsistent SCL record never causes an + // APPID-only guess or an accidental cross-VLAN publisher binding. + Q_INVOKABLE QVariantMap observedForConfiguredStream( + const QVariantMap& configured) const; + // Deterministic QA/offline evidence path. This uses the same decoder, // supervisor, bounded model and retention path as live raw-Ethernet capture. [[nodiscard]] bool ingestFrameBytes( diff --git a/apps/ied_simulator/src/ied_goose_workspace_qa.cpp b/apps/ied_simulator/src/ied_goose_workspace_qa.cpp index 6c2ec8504..2c8d8e0a3 100644 --- a/apps/ied_simulator/src/ied_goose_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_goose_workspace_qa.cpp @@ -113,6 +113,43 @@ int main(int argc, char** argv) { .value(QStringLiteral("matched")).toBool(), "destination MAC mismatch leaked a stream"); + + // Full SCL constraints: goID, VLAN ID/priority must not silently + // bind a different multicast virtual LAN or publishing control block. + QVariantMap configured; + configured.insert(QStringLiteral("reference"), goRef); + configured.insert(QStringLiteral("dataSet"), dataSet); + configured.insert(QStringLiteral("appId"), QStringLiteral("1001")); + configured.insert(QStringLiteral("destinationMac"), dst); + configured.insert(QStringLiteral("goId"), field(publisherEvidence, "goId")); + if (publisherEvidence.value(QStringLiteral("vlanId")).isValid()) { + configured.insert(QStringLiteral("vlanId"), + publisherEvidence.value(QStringLiteral("vlanId"))); + } + if (publisherEvidence.value(QStringLiteral("vlanPriority")).isValid()) { + configured.insert(QStringLiteral("vlanPriority"), + publisherEvidence.value(QStringLiteral("vlanPriority"))); + } + REQUIRE(monitor.observedForConfiguredStream(configured) + .value(QStringLiteral("matched")).toBool(), + "canonical SCL identity did not bind to observed GOOSE"); + auto conflicting = configured; + conflicting.insert(QStringLiteral("goId"), QStringLiteral("wrong-publisher")); + REQUIRE(!monitor.observedForConfiguredStream(conflicting) + .value(QStringLiteral("matched")).toBool(), + "wrong goID leaked stream from a different publisher"); + conflicting = configured; + conflicting.insert(QStringLiteral("vlanId"), 4094); + if (configured.value(QStringLiteral("vlanId")).toInt() == 4094) + conflicting.insert(QStringLiteral("vlanId"), 4093); + REQUIRE(!monitor.observedForConfiguredStream(conflicting) + .value(QStringLiteral("matched")).toBool(), + "wrong VLAN identity adopted an observed GOOSE frame"); + conflicting = configured; + conflicting.insert(QStringLiteral("vlanPriority"), 99); + REQUIRE(!monitor.observedForConfiguredStream(conflicting) + .value(QStringLiteral("matched")).toBool(), + "invalid configured VLAN priority was accepted"); GooseMonitorController ambiguousMonitor; REQUIRE(ambiguousMonitor.ingestFrameBytes( packets.back().frame, base + std::chrono::seconds{1}), From 8a80cfacdac59dd0aef01b93192a2cd43d8e6a39 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:34:03 +0700 Subject: [PATCH 6/8] chore(goose): make VLAN identity utility includes explicit --- apps/ied_simulator/src/GooseMonitorController.cpp | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/ied_simulator/src/GooseMonitorController.cpp b/apps/ied_simulator/src/GooseMonitorController.cpp index c623199ac..e3d066db4 100644 --- a/apps/ied_simulator/src/GooseMonitorController.cpp +++ b/apps/ied_simulator/src/GooseMonitorController.cpp @@ -15,6 +15,7 @@ #include #include #include +#include #include #include From e2c329d21222f55fe75b7660a2f5188081a819dc Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:36:47 +0700 Subject: [PATCH 7/8] fix(goose): typed MMS-to-wire identity and cached NIC enumeration under burst traffic --- apps/ied_simulator/qml/BrowserGoosePane.qml | 5 +++ apps/ied_simulator/qml/GooseWorkspace.qml | 7 +++ .../src/GooseMonitorController.cpp | 45 +++++++++++++++++-- .../src/GooseMonitorController.hpp | 7 ++- .../src/ied_goose_workspace_qa.cpp | 27 +++++++++++ 5 files changed, 85 insertions(+), 6 deletions(-) diff --git a/apps/ied_simulator/qml/BrowserGoosePane.qml b/apps/ied_simulator/qml/BrowserGoosePane.qml index 7c8c88f53..240cba125 100644 --- a/apps/ied_simulator/qml/BrowserGoosePane.qml +++ b/apps/ied_simulator/qml/BrowserGoosePane.qml @@ -227,6 +227,11 @@ Rectangle { displayText: currentIndex < 0 ? "Select capture interface" : currentText onActivated: root.monitor.interfaceName = currentText } + Button { + text: "Refresh NICs" + enabled: !root.monitor.capturing + onClicked: root.monitor.refreshInterfaces() + } Button { text: root.monitor.capturing ? "Stop" : "GOOSE Subscribe" enabled: root.monitor.capturing || root.monitor.interfaceName.length > 0 diff --git a/apps/ied_simulator/qml/GooseWorkspace.qml b/apps/ied_simulator/qml/GooseWorkspace.qml index ec32f6bed..32cd3c005 100644 --- a/apps/ied_simulator/qml/GooseWorkspace.qml +++ b/apps/ied_simulator/qml/GooseWorkspace.qml @@ -94,6 +94,13 @@ Item { } } + ActionButton { + theme: root.theme + text: "Refresh NICs" + enabled: !root.monitor.capturing + onClicked: root.monitor.refreshInterfaces() + } + ActionButton { theme: root.theme text: root.monitor.capturing ? "Stop Capture" : "Start Capture" diff --git a/apps/ied_simulator/src/GooseMonitorController.cpp b/apps/ied_simulator/src/GooseMonitorController.cpp index e3d066db4..d456d6560 100644 --- a/apps/ied_simulator/src/GooseMonitorController.cpp +++ b/apps/ied_simulator/src/GooseMonitorController.cpp @@ -52,11 +52,44 @@ bool sequenceIssue(const GooseSequenceStatus status) noexcept { status == GooseSequenceStatus::state_jump || status == GooseSequenceStatus::state_regression; } + +[[nodiscard]] QString wireGoCbRef(const QString& reference) { + // Live MMS control inventory uses LD/LN.GO.control; on-wire GOOSE + // uses LD/LN$GO$control. Rewriting arbitrary '.' globally would corrupt + // user-defined names or DataSet member paths. Only convert typed GO. + if (reference.contains(QLatin1Char('$'))) return reference; + QString converted = reference; + const auto slash = converted.indexOf(QLatin1Char('/')); + if (slash < 0) return converted; + const auto first = converted.indexOf(QLatin1Char('.'), slash + 1); + if (first <= slash + 1) return converted; + const auto second = converted.indexOf(QLatin1Char('.'), first + 1); + if (second < 0 || converted.mid(first + 1, second - first - 1) != QStringLiteral("GO") || + second + 1 >= converted.size()) return converted; + converted[first] = QLatin1Char('$'); + converted[second] = QLatin1Char('$'); + return converted; +} + +[[nodiscard]] QString wireDataSetRef(const QString& reference) { + // Live MMS canonical DataSet LD/LN.dataSet -> wire LD/LN$dataSet. + // The suffix must be one typed leaf; do not normalize arbitrary DO/DA. + if (reference.contains(QLatin1Char('$'))) return reference; + QString converted = reference; + const auto slash = converted.indexOf(QLatin1Char('/')); + if (slash < 0) return converted; + const auto dot = converted.indexOf(QLatin1Char('.'), slash + 1); + if (dot <= slash + 1 || dot + 1 >= converted.size() || + converted.indexOf(QLatin1Char('.'), dot + 1) >= 0) return converted; + converted[dot] = QLatin1Char('$'); + return converted; +} } // namespace GooseMonitorController::GooseMonitorController(QObject* parent) : QAbstractListModel(parent) { monitorClock_.start(); + refreshInterfaces(); captureTimer_ = new QTimer(this); captureTimer_->setInterval(kPollIntervalMilliseconds); captureTimer_->setTimerType(Qt::PreciseTimer); @@ -129,7 +162,9 @@ QHash GooseMonitorController::roleNames() const { }; } -QStringList GooseMonitorController::interfaces() const { +void GooseMonitorController::refreshInterfaces() { + if (capturing_) return; + QStringList result; for (const auto& networkInterface : QNetworkInterface::allInterfaces()) { const auto flags = networkInterface.flags(); @@ -142,7 +177,9 @@ QStringList GooseMonitorController::interfaces() const { if (!name.isEmpty() && !result.contains(name)) result.push_back(name); } std::sort(result.begin(), result.end()); - return result; + if (interfaces_ == result) return; + interfaces_ = std::move(result); + emit interfacesChanged(); } void GooseMonitorController::setInterfaceName(const QString& value) { @@ -168,7 +205,7 @@ QVariantMap GooseMonitorController::observedForStream( QVariantMap result; result.insert(QStringLiteral("matched"), false); result.insert(QStringLiteral("reason"), QStringLiteral("Awaiting an exact GOOSE frame")); - const auto reference = goCbRef.trimmed(); + const auto reference = wireGoCbRef(goCbRef.trimmed()); if (reference.isEmpty()) { result.insert(QStringLiteral("reason"), QStringLiteral("Configured goCBRef unavailable; no identity binding")); @@ -211,7 +248,7 @@ QVariantMap GooseMonitorController::observedForStream( } } - const auto requestedDataSet = dataSet.trimmed(); + const auto requestedDataSet = wireDataSetRef(dataSet.trimmed()); const StreamSlot* found = nullptr; for (const auto& slot : streams_) { const auto& frame = slot.frame; diff --git a/apps/ied_simulator/src/GooseMonitorController.hpp b/apps/ied_simulator/src/GooseMonitorController.hpp index 95be32eec..69f383627 100644 --- a/apps/ied_simulator/src/GooseMonitorController.hpp +++ b/apps/ied_simulator/src/GooseMonitorController.hpp @@ -26,7 +26,7 @@ class GooseMonitorController : public QAbstractListModel { Q_OBJECT QML_ELEMENT - Q_PROPERTY(QStringList interfaces READ interfaces NOTIFY monitorChanged) + Q_PROPERTY(QStringList interfaces READ interfaces NOTIFY interfacesChanged) Q_PROPERTY(QString interfaceName READ interfaceName WRITE setInterfaceName NOTIFY monitorChanged) Q_PROPERTY(bool capturing READ capturing NOTIFY monitorChanged) Q_PROPERTY(QString statusText READ statusText NOTIFY monitorChanged) @@ -82,7 +82,8 @@ class GooseMonitorController : public QAbstractListModel { [[nodiscard]] QVariant data(const QModelIndex& index, int role) const override; [[nodiscard]] QHash roleNames() const override; - [[nodiscard]] QStringList interfaces() const; + [[nodiscard]] QStringList interfaces() const { return interfaces_; } + Q_INVOKABLE void refreshInterfaces(); [[nodiscard]] QString interfaceName() const { return interfaceName_; } void setInterfaceName(const QString& value); [[nodiscard]] bool capturing() const noexcept { return capturing_; } @@ -137,6 +138,7 @@ class GooseMonitorController : public QAbstractListModel { void checkTimeoutsAt(clock::time_point now); signals: + void interfacesChanged(); void monitorChanged(); void selectionChanged(); @@ -182,6 +184,7 @@ class GooseMonitorController : public QAbstractListModel { std::vector streams_; std::deque retainedPackets_; QStringList recentEvents_; + QStringList interfaces_; QString interfaceName_; QString statusText_{QStringLiteral("Stopped")}; ar::iec61850::goose::RawEthernetSubscriber transport_; diff --git a/apps/ied_simulator/src/ied_goose_workspace_qa.cpp b/apps/ied_simulator/src/ied_goose_workspace_qa.cpp index 2c8d8e0a3..c8aba8532 100644 --- a/apps/ied_simulator/src/ied_goose_workspace_qa.cpp +++ b/apps/ied_simulator/src/ied_goose_workspace_qa.cpp @@ -133,6 +133,33 @@ int main(int argc, char** argv) { REQUIRE(monitor.observedForConfiguredStream(configured) .value(QStringLiteral("matched")).toBool(), "canonical SCL identity did not bind to observed GOOSE"); + + const auto wireToCanonicalGo = [](QString ref) { + const auto first = ref.indexOf(QLatin1Char('$')); + const auto second = ref.indexOf(QLatin1Char('$'), first + 1); + REQUIRE(first >= 0 && second >= 0, "fixture requires typed wire GO reference"); + ref[first] = QLatin1Char('.'); + ref[second] = QLatin1Char('.'); + return ref; + }; + const auto wireToCanonicalSet = [](QString ref) { + const auto slash = ref.indexOf(QLatin1Char('/')); + const auto dollar = ref.indexOf(QLatin1Char('$'), slash + 1); + REQUIRE(dollar >= 0, "fixture requires canonical DataSet translation"); + ref[dollar] = QLatin1Char('.'); + return ref; + }; + auto discovered = configured; + discovered.insert(QStringLiteral("reference"), wireToCanonicalGo(goRef)); + discovered.insert(QStringLiteral("dataSet"), wireToCanonicalSet(dataSet)); + REQUIRE(monitor.observedForConfiguredStream(discovered) + .value(QStringLiteral("matched")).toBool(), + "typed live-model GO/DataSet identifiers failed wire normalization"); + discovered.insert(QStringLiteral("reference"), + QStringLiteral("OtherIED/LLN0.GO.gcb01")); + REQUIRE(!monitor.observedForConfiguredStream(discovered) + .value(QStringLiteral("matched")).toBool(), + "normalization crossed IED model identity"); auto conflicting = configured; conflicting.insert(QStringLiteral("goId"), QStringLiteral("wrong-publisher")); REQUIRE(!monitor.observedForConfiguredStream(conflicting) From 4df620874bdab9c0bf280d31a342c12dfa6ab38a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Fri, 16 Oct 2026 18:38:45 +0700 Subject: [PATCH 8/8] fix(goose): require deliberate NIC selection in Sniffer instead of silent first-adapter capture --- apps/ied_simulator/qml/GooseWorkspace.qml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/apps/ied_simulator/qml/GooseWorkspace.qml b/apps/ied_simulator/qml/GooseWorkspace.qml index 32cd3c005..31fcc426b 100644 --- a/apps/ied_simulator/qml/GooseWorkspace.qml +++ b/apps/ied_simulator/qml/GooseWorkspace.qml @@ -86,12 +86,9 @@ Item { Layout.preferredWidth: 240 model: root.monitor.interfaces enabled: !root.monitor.capturing - currentIndex: Math.max(0, root.monitor.interfaces.indexOf(root.monitor.interfaceName)) + currentIndex: root.monitor.interfaces.indexOf(root.monitor.interfaceName) + displayText: currentIndex < 0 ? "Select capture interface" : currentText onActivated: root.monitor.interfaceName = currentText - Component.onCompleted: { - if (root.monitor.interfaceName.length === 0 && count > 0) - root.monitor.interfaceName = currentText - } } ActionButton {