From ca90866493c2dbdaac3ea4bc6b9baa7d56c98244 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:29:10 +0700 Subject: [PATCH 01/18] P6D: add isolated passive TCP/102 packet receiver --- .../capture/raw_mms_packet_receiver.hpp | 394 ++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 include/ariec61850/capture/raw_mms_packet_receiver.hpp diff --git a/include/ariec61850/capture/raw_mms_packet_receiver.hpp b/include/ariec61850/capture/raw_mms_packet_receiver.hpp new file mode 100644 index 000000000..39c037861 --- /dev/null +++ b/include/ariec61850/capture/raw_mms_packet_receiver.hpp @@ -0,0 +1,394 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/ethernet/ethernet.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#if defined(__linux__) +#include +#include +#include +#include +#include +#include +#include +#include +#elif defined(_WIN32) +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include +#include +#endif + +namespace ar::iec61850::capture { + +enum class RawMmsReceiveResult : std::uint8_t { + none, + frame, + failure, +}; + +// Minimal passive Layer-2 receiver for TCP/102. Linux AF_PACKET and Windows +// dynamic Npcap require explicit interface selection. No client association, +// socket send, or packet injection. The parser filters TCP/102 on Linux. +class RawMmsPacketReceiver final { +public: + RawMmsPacketReceiver() = default; + ~RawMmsPacketReceiver() { close(); } + + RawMmsPacketReceiver(const RawMmsPacketReceiver&) = delete; + RawMmsPacketReceiver& operator=(const RawMmsPacketReceiver&) = delete; + + RawMmsPacketReceiver(RawMmsPacketReceiver&& other) noexcept { move_from(other); } + RawMmsPacketReceiver& operator=(RawMmsPacketReceiver&& other) noexcept { + if (this != &other) { + close(); + move_from(other); + } + return *this; + } + + [[nodiscard]] bool open(std::string_view interface_name, std::string& error) { + close(); + error.clear(); + if (interface_name.empty()) { + error = "MMS monitoring requires an explicit Ethernet interface."; + return false; + } +#if defined(__linux__) + if (interface_name.size() >= IFNAMSIZ) { + error = "Ethernet interface name exceeds IFNAMSIZ."; + return false; + } + const std::string name{interface_name}; + const unsigned int index = ::if_nametoindex(name.c_str()); + if (index == 0U) { + error = "Ethernet interface was not found: " + name; + return false; + } + + const int socket_fd = ::socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); + if (socket_fd < 0) { + error = "Could not open AF_PACKET raw socket for MMS monitoring: " + + std::string{std::strerror(errno)}; + return false; + } + + struct sockaddr_ll bind_address {}; + bind_address.sll_family = AF_PACKET; + bind_address.sll_protocol = htons(ETH_P_ALL); + bind_address.sll_ifindex = static_cast(index); + if (::bind( + socket_fd, + reinterpret_cast(&bind_address), + sizeof(bind_address)) != 0) { + error = "Could not bind MMS monitor to " + name + ": " + + std::string{std::strerror(errno)}; + ::close(socket_fd); + return false; + } + + // Port and protocol filtering is performed by the bounded decoder. + // Do not attach the GOOSE EtherType filter to an MMS receiver. + const int flags = ::fcntl(socket_fd, F_GETFL, 0); + if (flags < 0 || ::fcntl(socket_fd, F_SETFL, flags | O_NONBLOCK) != 0) { + error = "Could not make the MMS monitor socket non-blocking on " + name + ": " + + std::string{std::strerror(errno)}; + ::close(socket_fd); + return false; + } + + socket_fd_ = socket_fd; + interface_index_ = static_cast(index); + interface_name_ = name; + return true; +#elif defined(_WIN32) + const std::string requested_name{interface_name}; + wpcap_module_ = ::LoadLibraryA("wpcap.dll"); + if (wpcap_module_ == nullptr) { + error = "Npcap wpcap.dll was not found. Install Npcap to monitor Layer-2 MMS on Windows."; + return false; + } + pcap_open_live_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_open_live")); + pcap_next_ex_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_next_ex")); + pcap_close_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_close")); + pcap_geterr_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_geterr")); + pcap_findalldevs_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_findalldevs")); + pcap_freealldevs_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_freealldevs")); + pcap_compile_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_compile")); + pcap_setfilter_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_setfilter")); + pcap_freecode_ = reinterpret_cast( + ::GetProcAddress(wpcap_module_, "pcap_freecode")); + if (pcap_open_live_ == nullptr || pcap_next_ex_ == nullptr || pcap_close_ == nullptr || + pcap_geterr_ == nullptr || pcap_findalldevs_ == nullptr || pcap_freealldevs_ == nullptr || + pcap_compile_ == nullptr || pcap_setfilter_ == nullptr || pcap_freecode_ == nullptr) { + error = "Npcap is present but required packet-capture/filter exports are unavailable."; + close(); + return false; + } + + std::array errbuf{}; + PcapIf* devices{}; + if (pcap_findalldevs_(&devices, errbuf.data()) != 0 || devices == nullptr) { + error = "Npcap could not enumerate Ethernet adapters"; + if (errbuf.front() != '\0') error += ": " + std::string{errbuf.data()}; + close(); + return false; + } + + std::string pcap_name; + for (auto* device = devices; device != nullptr; device = device->next) { + const std::string device_name = device->name == nullptr ? std::string{} : device->name; + const std::string description = + device->description == nullptr ? std::string{} : device->description; + if (!windows_interface_matches(requested_name, device_name, description)) continue; + pcap_name = device_name; + break; + } + pcap_freealldevs_(devices); + devices = nullptr; + if (pcap_name.empty()) { + error = "Npcap could not resolve the selected Ethernet interface: " + requested_name; + close(); + return false; + } + + errbuf.fill('\0'); + pcap_handle_ = pcap_open_live_(pcap_name.c_str(), 65'535, 1, 1, errbuf.data()); + if (pcap_handle_ == nullptr) { + error = "Npcap could not open adapter " + requested_name + ": " + std::string{errbuf.data()}; + close(); + return false; + } + + BpfProgram program{}; + constexpr const char* filter = "tcp port 102 or (vlan and tcp port 102)"; + if (pcap_compile_(pcap_handle_, &program, filter, 1, 0xFFFFFFFFU) != 0) { + error = "Npcap could not compile the MMS EtherType filter"; + const char* detail = pcap_geterr_(pcap_handle_); + if (detail != nullptr && *detail != '\0') error += ": " + std::string{detail}; + close(); + return false; + } + const int filter_result = pcap_setfilter_(pcap_handle_, &program); + pcap_freecode_(&program); + if (filter_result != 0) { + error = "Npcap could not install the MMS EtherType filter"; + const char* detail = pcap_geterr_(pcap_handle_); + if (detail != nullptr && *detail != '\0') error += ": " + std::string{detail}; + close(); + return false; + } + + interface_name_ = requested_name; + return true; +#else + (void)interface_name; + error = "Raw Ethernet MMS monitoring is unavailable on this platform."; + return false; +#endif + } + + [[nodiscard]] RawMmsReceiveResult receive( + std::vector& ethernet_frame, + std::string& error) noexcept { + ethernet_frame.clear(); + error.clear(); +#if defined(__linux__) + if (socket_fd_ < 0 || interface_index_ <= 0) { + error = "MMS raw Ethernet subscriber is not open."; + return RawMmsReceiveResult::failure; + } + std::array buffer{}; + const auto received = ::recv(socket_fd_, buffer.data(), buffer.size(), 0); + if (received < 0) { + if (errno == EAGAIN || errno == EWOULDBLOCK) { + return RawMmsReceiveResult::none; + } + error = "MMS raw Ethernet receive failed on " + interface_name_ + ": " + + std::string{std::strerror(errno)}; + return RawMmsReceiveResult::failure; + } + if (received == 0) return RawMmsReceiveResult::none; + ethernet_frame.assign(buffer.begin(), buffer.begin() + received); + return RawMmsReceiveResult::frame; +#elif defined(_WIN32) + if (pcap_handle_ == nullptr || pcap_next_ex_ == nullptr) { + error = "MMS Npcap subscriber is not open."; + return RawMmsReceiveResult::failure; + } + PcapPacketHeader* header{}; + const unsigned char* data{}; + const int result = pcap_next_ex_(pcap_handle_, &header, &data); + if (result == 0) return RawMmsReceiveResult::none; + if (result != 1 || header == nullptr || data == nullptr) { + error = "MMS Npcap receive failed on " + interface_name_; + const char* detail = pcap_geterr_ != nullptr ? pcap_geterr_(pcap_handle_) : nullptr; + if (detail != nullptr && *detail != '\0') error += ": " + std::string{detail}; + return RawMmsReceiveResult::failure; + } + if (header->caplen < 14U || header->caplen > 65'535U) { + error = "MMS Npcap capture returned an invalid frame length."; + return RawMmsReceiveResult::failure; + } + ethernet_frame.assign(data, data + header->caplen); + return RawMmsReceiveResult::frame; +#else + error = "Raw Ethernet MMS monitoring is unavailable on this platform."; + return RawMmsReceiveResult::failure; +#endif + } + + void close() noexcept { +#if defined(__linux__) + if (socket_fd_ >= 0) ::close(socket_fd_); + socket_fd_ = -1; + interface_index_ = 0; +#elif defined(_WIN32) + if (pcap_handle_ != nullptr && pcap_close_ != nullptr) pcap_close_(pcap_handle_); + pcap_handle_ = nullptr; + pcap_open_live_ = nullptr; + pcap_next_ex_ = nullptr; + pcap_close_ = nullptr; + pcap_geterr_ = nullptr; + pcap_findalldevs_ = nullptr; + pcap_freealldevs_ = nullptr; + pcap_compile_ = nullptr; + pcap_setfilter_ = nullptr; + pcap_freecode_ = nullptr; + if (wpcap_module_ != nullptr) ::FreeLibrary(wpcap_module_); + wpcap_module_ = nullptr; +#endif + interface_name_.clear(); + } + + [[nodiscard]] bool active() const noexcept { +#if defined(__linux__) + return socket_fd_ >= 0 && interface_index_ > 0; +#elif defined(_WIN32) + return pcap_handle_ != nullptr; +#else + return false; +#endif + } + + [[nodiscard]] const std::string& interface_name() const noexcept { return interface_name_; } + +private: +#if defined(_WIN32) + struct pcap; + struct pcap_addr; + struct PcapIf final { + PcapIf* next; + char* name; + char* description; + pcap_addr* addresses; + unsigned int flags; + }; + struct PcapPacketHeader final { + timeval ts; + unsigned int caplen; + unsigned int len; + }; + struct BpfProgram final { + unsigned int bf_len; + void* bf_insns; + }; + using PcapHandle = pcap; + using PcapOpenLive = PcapHandle* (__cdecl *)(const char*, int, int, int, char*); + using PcapNextEx = int (__cdecl *)(PcapHandle*, PcapPacketHeader**, const unsigned char**); + using PcapClose = void (__cdecl *)(PcapHandle*); + using PcapGetErr = char* (__cdecl *)(PcapHandle*); + using PcapFindAllDevs = int (__cdecl *)(PcapIf**, char*); + using PcapFreeAllDevs = void (__cdecl *)(PcapIf*); + using PcapCompile = int (__cdecl *)(PcapHandle*, BpfProgram*, const char*, int, unsigned int); + using PcapSetFilter = int (__cdecl *)(PcapHandle*, BpfProgram*); + using PcapFreeCode = void (__cdecl *)(BpfProgram*); + + [[nodiscard]] static std::string ascii_lower(std::string value) { + std::transform(value.begin(), value.end(), value.begin(), [](const unsigned char ch) { + return static_cast(std::tolower(ch)); + }); + return value; + } + + [[nodiscard]] static std::string normalized_windows_interface(std::string value) { + value = ascii_lower(std::move(value)); + constexpr std::string_view prefix{"\\\\device\\npf_"}; + if (value.rfind(prefix.data(), 0U) == 0U) value.erase(0U, prefix.size()); + return value; + } + + [[nodiscard]] static bool windows_interface_matches( + const std::string& requested, + const std::string& pcap_name, + const std::string& description) { + const auto wanted = normalized_windows_interface(requested); + const auto device = normalized_windows_interface(pcap_name); + const auto label = ascii_lower(description); + if (wanted == device || ascii_lower(requested) == label) return true; + const auto brace = device.find('{'); + return brace != std::string::npos && wanted.find(device.substr(brace)) != std::string::npos; + } + + HMODULE wpcap_module_{}; + PcapHandle* pcap_handle_{}; + PcapOpenLive pcap_open_live_{}; + PcapNextEx pcap_next_ex_{}; + PcapClose pcap_close_{}; + PcapGetErr pcap_geterr_{}; + PcapFindAllDevs pcap_findalldevs_{}; + PcapFreeAllDevs pcap_freealldevs_{}; + PcapCompile pcap_compile_{}; + PcapSetFilter pcap_setfilter_{}; + PcapFreeCode pcap_freecode_{}; +#elif defined(__linux__) + int socket_fd_{-1}; + int interface_index_{}; +#endif + std::string interface_name_; + + void move_from(RawMmsPacketReceiver& other) noexcept { +#if defined(__linux__) + socket_fd_ = std::exchange(other.socket_fd_, -1); + interface_index_ = std::exchange(other.interface_index_, 0); +#elif defined(_WIN32) + wpcap_module_ = std::exchange(other.wpcap_module_, nullptr); + pcap_handle_ = std::exchange(other.pcap_handle_, nullptr); + pcap_open_live_ = std::exchange(other.pcap_open_live_, nullptr); + pcap_next_ex_ = std::exchange(other.pcap_next_ex_, nullptr); + pcap_close_ = std::exchange(other.pcap_close_, nullptr); + pcap_geterr_ = std::exchange(other.pcap_geterr_, nullptr); + pcap_findalldevs_ = std::exchange(other.pcap_findalldevs_, nullptr); + pcap_freealldevs_ = std::exchange(other.pcap_freealldevs_, nullptr); + pcap_compile_ = std::exchange(other.pcap_compile_, nullptr); + pcap_setfilter_ = std::exchange(other.pcap_setfilter_, nullptr); + pcap_freecode_ = std::exchange(other.pcap_freecode_, nullptr); +#endif + interface_name_ = std::move(other.interface_name_); + } +}; + +} // namespace ar::iec61850::capture From 74806ae1ce023dc054bfa236af0e2a9c948fcab6 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:29:39 +0700 Subject: [PATCH 02/18] P6D: define bounded passive MMS flow decoder --- include/ariec61850/capture/passive_mms.hpp | 96 ++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 include/ariec61850/capture/passive_mms.hpp diff --git a/include/ariec61850/capture/passive_mms.hpp b/include/ariec61850/capture/passive_mms.hpp new file mode 100644 index 000000000..33a962be4 --- /dev/null +++ b/include/ariec61850/capture/passive_mms.hpp @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/capture/pcap.hpp" +#include "ariec61850/osi/tpkt.hpp" +#include "ariec61850/osi/cotp.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::capture { + +struct PassiveMmsEvent final { + std::chrono::system_clock::time_point timestamp; + std::string source; + std::string destination; + std::string kind; + std::string service; + std::string detail; + std::optional invoke_id; + std::size_t wire_bytes{}; +}; + +struct PassiveMmsCounters final { + std::uint64_t ethernet_packets{}; + std::uint64_t tcp_packets{}; + std::uint64_t mms_messages{}; + std::uint64_t decoded_requests{}; + std::uint64_t decoded_responses{}; + std::uint64_t decoded_reports{}; + std::uint64_t retransmissions{}; + std::uint64_t out_of_order{}; + std::uint64_t gaps{}; + std::uint64_t malformed{}; + std::uint64_t dropped{}; +}; + +class PassiveMmsDecoder final { +public: + static constexpr std::size_t maximum_flows = 128; + static constexpr std::size_t maximum_events = 4096; + static constexpr std::size_t maximum_tcp_buffer = 1024U * 1024U; + static constexpr std::size_t maximum_pending_segments = 64; + static constexpr std::size_t maximum_packet_bytes = 65535; + + void ingest(const PcapPacket& packet); + void ingest(std::span ethernet, + std::chrono::system_clock::time_point timestamp); + void reset() noexcept; + + [[nodiscard]] const std::vector& events() const noexcept { + return events_; + } + [[nodiscard]] const PassiveMmsCounters& counters() const noexcept { + return counters_; + } + +private: + struct Direction final { + bool sequence_initialized{}; + std::uint32_t next_sequence{}; + std::map> pending; + osi::TpktStreamDecoder tpkt{maximum_tcp_buffer}; + osi::CotpDataReassembler cotp{ + maximum_tcp_buffer, maximum_pending_segments, 8}; + }; + struct Flow final { + Direction directions[2]; + std::map outstanding; + }; + + void process_segment( + Flow& flow, int direction, std::uint32_t sequence, + bool syn, bool fin, bool rst, + std::span payload, + std::chrono::system_clock::time_point timestamp, + const std::string& source, const std::string& destination); + void append_contiguous( + Flow& flow, int direction, + std::span payload, + std::chrono::system_clock::time_point timestamp, + const std::string& source, const std::string& destination); + void record(PassiveMmsEvent event); + + std::map flows_; + std::vector events_; + PassiveMmsCounters counters_{}; +}; + +} // namespace ar::iec61850::capture From f09e903d6d8776a1f6364fd66a4d519e98570070 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:30:36 +0700 Subject: [PATCH 03/18] P6D: decode passive TCP TPKT COTP Session MMS flows --- src/capture/passive_mms.cpp | 316 ++++++++++++++++++++++++++++++++++++ 1 file changed, 316 insertions(+) create mode 100644 src/capture/passive_mms.cpp diff --git a/src/capture/passive_mms.cpp b/src/capture/passive_mms.cpp new file mode 100644 index 000000000..3c68263a6 --- /dev/null +++ b/src/capture/passive_mms.cpp @@ -0,0 +1,316 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/capture/passive_mms.hpp" + +#include "ariec61850/mms/pdu.hpp" +#include "ariec61850/mms/services.hpp" +#include "ariec61850/osi/session.hpp" + +#include +#include +#include +#include +#include +#include + +namespace ar::iec61850::capture { +namespace { +std::uint16_t be16(std::span b, std::size_t n) { + return static_cast( + (static_cast(b[n]) << 8U) | b[n + 1U]); +} +std::uint32_t be32(std::span b, std::size_t n) { + return (static_cast(b[n]) << 24U) | + (static_cast(b[n+1U]) << 16U) | + (static_cast(b[n+2U]) << 8U) | + static_cast(b[n+3U]); +} +std::string endpoint(std::span b, std::size_t ip, std::uint16_t port) { + return std::to_string(b[ip]) + "." + std::to_string(b[ip+1U]) + "." + + std::to_string(b[ip+2U]) + "." + std::to_string(b[ip+3U]) + + ":" + std::to_string(port); +} +std::string service_name(std::int32_t tag) { + switch (tag) { + case 0: return "Status"; + case 1: return "GetNameList"; + case 2: return "Identify"; + case 4: return "Read"; + case 5: return "Write"; + case 6: return "GetVariableAccessAttributes"; + case 11: return "DefineNamedVariableList"; + case 12: return "GetNamedVariableListAttributes"; + case 13: return "DeleteNamedVariableList"; + case 72: return "FileOpen"; + case 73: return "FileRead"; + case 74: return "FileClose"; + case 77: return "FileDirectory"; + default: return "MMS service " + std::to_string(tag); + } +} +std::string kind_name(mms::MmsPduKind kind) { + switch (kind) { + case mms::MmsPduKind::confirmed_request: return "Request"; + case mms::MmsPduKind::confirmed_response: return "Response"; + case mms::MmsPduKind::confirmed_error: return "Error"; + case mms::MmsPduKind::unconfirmed: return "Unconfirmed"; + case mms::MmsPduKind::reject: return "Reject"; + case mms::MmsPduKind::initiate_request: return "Initiate request"; + case mms::MmsPduKind::initiate_response: return "Initiate response"; + default: return "MMS"; + } +} +bool control_reference(const std::string& value) { + return value.find("$CO$") != std::string::npos || + value.find("$Oper") != std::string::npos || + value.find("$SBO") != std::string::npos || + value.find("$Cancel") != std::string::npos; +} +std::string request_detail(std::span payload, std::int32_t tag, + std::string& service) { + try { + if (tag == 4) { + const auto request = mms::MmsServiceCodec::decode_read_request(payload); + return std::to_string(request.variables.size()) + " variable(s)"; + } + if (tag == 5) { + const auto request = mms::MmsServiceCodec::decode_write_request(payload); + for (const auto& variable : request.variables) { + if (control_reference(variable.reference())) { + service = "Control"; + break; + } + } + return std::to_string(request.variables.size()) + " variable(s)"; + } + } catch (const std::exception&) { + return "Service payload not fully decoded"; + } + return {}; +} +} // namespace + +void PassiveMmsDecoder::record(PassiveMmsEvent event) { + if (events_.size() >= maximum_events) { + events_.erase(events_.begin()); + ++counters_.dropped; + } + events_.push_back(std::move(event)); +} + +void PassiveMmsDecoder::reset() noexcept { + flows_.clear(); + events_.clear(); + counters_ = {}; +} + +void PassiveMmsDecoder::ingest(const PcapPacket& packet) { + ingest(packet.frame, packet.timestamp); +} + +void PassiveMmsDecoder::ingest( + const std::span ethernet, + const std::chrono::system_clock::time_point timestamp) { + ++counters_.ethernet_packets; + if (ethernet.size() < 14U || ethernet.size() > maximum_packet_bytes) { + ++counters_.malformed; + return; + } + + std::size_t offset = 14U; + auto ether_type = be16(ethernet, 12U); + for (int tag = 0; tag < 2 && (ether_type == 0x8100U || ether_type == 0x88A8U); ++tag) { + if (ethernet.size() < offset + 4U) { + ++counters_.malformed; + return; + } + ether_type = be16(ethernet, offset + 2U); + offset += 4U; + } + if (ether_type != 0x0800U) return; // IPv4 only; never infer unsupported traffic. + if (ethernet.size() < offset + 20U) { + ++counters_.malformed; + return; + } + const auto ip = ethernet.subspan(offset); + if ((ip[0] >> 4U) != 4U) return; + const auto ihl = static_cast(ip[0] & 0x0FU) * 4U; + if (ihl < 20U || ip.size() < ihl) { + ++counters_.malformed; + return; + } + const auto ip_length = static_cast(be16(ip, 2U)); + if (ip_length < ihl || ip.size() < ip_length) { + ++counters_.malformed; + return; + } + if (ip[9] != 6U) return; + if ((be16(ip, 6U) & 0x3FFFU) != 0U) { + ++counters_.dropped; // Fragmented IPv4 requires IP defragmentation. + return; + } + const auto tcp = ip.subspan(ihl, ip_length - ihl); + if (tcp.size() < 20U) { + ++counters_.malformed; + return; + } + const auto src_port = be16(tcp, 0U); + const auto dst_port = be16(tcp, 2U); + if (src_port != 102U && dst_port != 102U) return; + ++counters_.tcp_packets; + const auto data_offset = static_cast(tcp[12] >> 4U) * 4U; + if (data_offset < 20U || data_offset > tcp.size()) { + ++counters_.malformed; + return; + } + const auto source = endpoint(ip, 12U, src_port); + const auto destination = endpoint(ip, 16U, dst_port); + const auto first = std::min(source, destination); + const auto second = std::max(source, destination); + const auto key = first + "|" + second; + const int direction = source == first ? 0 : 1; + if (!flows_.contains(key) && flows_.size() >= maximum_flows) { + ++counters_.dropped; + return; + } + auto& flow = flows_[key]; + const auto flags = tcp[13]; + process_segment(flow, direction, be32(tcp, 4U), + (flags & 0x02U) != 0U, + (flags & 0x01U) != 0U, + (flags & 0x04U) != 0U, + tcp.subspan(data_offset), timestamp, source, destination); + if ((flags & 0x05U) != 0U) flows_.erase(key); +} + +void PassiveMmsDecoder::process_segment( + Flow& flow, int direction, std::uint32_t sequence, + bool syn, bool fin, bool rst, + std::span payload, + std::chrono::system_clock::time_point timestamp, + const std::string& source, const std::string& destination) { + auto& stream = flow.directions[direction]; + if (rst) return; + if (syn) { + stream = Direction{}; + sequence += 1U; + } + if (payload.empty()) return; + if (!stream.sequence_initialized) { + stream.sequence_initialized = true; + stream.next_sequence = sequence; + } + if (sequence < stream.next_sequence) { + const auto overlap = static_cast(stream.next_sequence - sequence); + ++counters_.retransmissions; + if (overlap >= payload.size()) return; + payload = payload.subspan(overlap); + sequence = stream.next_sequence; + } + if (sequence > stream.next_sequence) { + ++counters_.out_of_order; + if (stream.pending.size() >= maximum_pending_segments || + payload.size() > maximum_tcp_buffer) { + stream.pending.clear(); + stream.tpkt.reset(); + stream.cotp.reset(); + ++counters_.gaps; + ++counters_.dropped; + return; + } + stream.pending.emplace( + sequence, std::vector{payload.begin(), payload.end()}); + return; + } + stream.next_sequence += static_cast(payload.size()); + append_contiguous(flow, direction, payload, timestamp, source, destination); + while (!stream.pending.empty()) { + auto it = stream.pending.begin(); + if (it->first > stream.next_sequence) break; + auto part = std::move(it->second); + const auto pending_sequence = it->first; + stream.pending.erase(it); + const auto overlap = static_cast(stream.next_sequence - pending_sequence); + if (overlap >= part.size()) { + ++counters_.retransmissions; + continue; + } + const auto next = std::span{part}.subspan(overlap); + stream.next_sequence += static_cast(next.size()); + append_contiguous(flow, direction, next, timestamp, source, destination); + } + (void)fin; +} + +void PassiveMmsDecoder::append_contiguous( + Flow& flow, int direction, + const std::span payload, + const std::chrono::system_clock::time_point timestamp, + const std::string& source, const std::string& destination) { + auto& stream = flow.directions[direction]; + try { + stream.tpkt.append(payload); + osi::TpktFrame tpkt; + while (stream.tpkt.try_pop(tpkt)) { + const auto cotp = osi::CotpFrameCodec::decode(tpkt.payload); + if (cotp.kind != osi::CotpTpduKind::data) { + record({timestamp, source, destination, "OSI", "COTP", + "Transport handshake/control", std::nullopt, tpkt.declared_length}); + continue; + } + stream.cotp.append(cotp); + if (!stream.cotp.is_complete()) continue; + const auto session_payload = stream.cotp.complete(); + stream.cotp.reset(); + + osi::SessionDataTransfer transfer; + std::string error; + if (!osi::SessionCodec::try_decode_data_transfer( + session_payload, transfer, &error)) { + // Association negotiation is observable but not an MMS data-transfer. + record({timestamp, source, destination, "Association", "ACSE", + "Session negotiation/control", std::nullopt, session_payload.size()}); + continue; + } + + const auto envelope = mms::MmsPduCodec::decode_envelope( + transfer.presentation_payload); + std::string service = envelope.service_tag + ? service_name(*envelope.service_tag) + : "MMS"; + std::string detail; + if (envelope.kind == mms::MmsPduKind::confirmed_request && + envelope.service_tag) { + detail = request_detail( + transfer.presentation_payload, *envelope.service_tag, service); + if (envelope.invoke_id) flow.outstanding[*envelope.invoke_id] = service; + ++counters_.decoded_requests; + } else if (envelope.kind == mms::MmsPduKind::confirmed_response || + envelope.kind == mms::MmsPduKind::confirmed_error) { + if (envelope.invoke_id) { + if (auto it = flow.outstanding.find(*envelope.invoke_id); + it != flow.outstanding.end()) { + service = it->second; + flow.outstanding.erase(it); + } + } + ++counters_.decoded_responses; + } + if (envelope.information_report) { + service = "InformationReport"; + ++counters_.decoded_reports; + } + if (flow.outstanding.size() > 512U) flow.outstanding.clear(); + ++counters_.mms_messages; + record({timestamp, source, destination, kind_name(envelope.kind), + service, detail, envelope.invoke_id, envelope.mms_payload.size()}); + } + } catch (const std::exception& ex) { + ++counters_.malformed; + record({timestamp, source, destination, "Decode error", "MMS/OSI", + ex.what(), std::nullopt, payload.size()}); + stream.tpkt.reset(); + stream.cotp.reset(); + } +} + +} // namespace ar::iec61850::capture From 76d527659da5ac8ff2aff191cca1664f9f048722 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:31:00 +0700 Subject: [PATCH 04/18] P6D: build shared passive MMS decoder in core --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 67714eb50..fa4791148 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -17,6 +17,7 @@ add_library(ariec61850_core src/time_sync/ptp.cpp src/time_sync/ptp_monitor.cpp src/capture/pcap.cpp + src/capture/passive_mms.cpp src/goose/retransmission_schedule.cpp src/goose/pdu_codec.cpp src/goose/frame_codec.cpp From 9d7ed2950b0abd03378598b4fc0bb10de4b6257e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:31:39 +0700 Subject: [PATCH 05/18] P6D: prove passive TCP reassembly and MMS decode --- tests/test_passive_mms.cpp | 97 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/test_passive_mms.cpp diff --git a/tests/test_passive_mms.cpp b/tests/test_passive_mms.cpp new file mode 100644 index 000000000..bf516fb22 --- /dev/null +++ b/tests/test_passive_mms.cpp @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "ariec61850/capture/passive_mms.hpp" +#include "ariec61850/mms/services.hpp" +#include "ariec61850/osi/cotp.hpp" +#include "ariec61850/osi/session.hpp" +#include "ariec61850/osi/tpkt.hpp" + +#include +#include +#include +#include +#include +#include +#include + +namespace { +using Bytes = std::vector; +void require(bool ok, const char* label) { + if (!ok) { + std::cerr << "PASSIVE_MMS_FAIL " << label << '\n'; + std::exit(1); + } +} +void put16(Bytes& b, std::size_t i, std::uint16_t n) { + b[i] = static_cast(n >> 8U); + b[i+1U] = static_cast(n); +} +void put32(Bytes& b, std::size_t i, std::uint32_t n) { + b[i] = static_cast(n >> 24U); + b[i+1U] = static_cast(n >> 16U); + b[i+2U] = static_cast(n >> 8U); + b[i+3U] = static_cast(n); +} +Bytes packet(std::span payload, std::uint32_t sequence, + std::uint16_t source_port = 41000U, std::uint16_t dest_port = 102U) { + Bytes b(14U+20U+20U+payload.size(), 0U); + put16(b,12U,0x0800U); + b[14U]=0x45U; + put16(b,16U,static_cast(40U+payload.size())); + b[22U]=64U; + b[23U]=6U; + b[26U]=192U; b[27U]=0U; b[28U]=2U; b[29U]=10U; + b[30U]=192U; b[31U]=0U; b[32U]=2U; b[33U]=20U; + put16(b,34U,source_port); put16(b,36U,dest_port); + put32(b,38U,sequence); + b[46U]=0x50U; b[47U]=0x18U; + std::copy(payload.begin(),payload.end(),b.begin()+54); + return b; +} +Bytes request() { + namespace mms=ar::iec61850::mms; + namespace osi=ar::iec61850::osi; + mms::MmsReadRequest req; + req.invoke_id=42U; + req.variables.push_back(mms::MmsObjectName::domain_specific( + "LD0","LLN0$ST$Mod$stVal")); + const auto p_data=mms::MmsServiceCodec::encode_read_request_p_data(req); + const auto session=osi::SessionCodec::encode_data_transfer(p_data); + const auto cotp=osi::CotpFrameCodec::encode_data(session); + return osi::TpktFrameCodec::encode(cotp); +} +} // namespace + +int main() { + using ar::iec61850::capture::PassiveMmsDecoder; + const auto now=std::chrono::system_clock::now(); + const auto wire=request(); + require(wire.size()>24U,"fixture_size"); + PassiveMmsDecoder decoder; + decoder.ingest(packet(std::span{wire}.first(8U),1000U),now); + decoder.ingest(packet(std::span{wire}.subspan(16U),1016U),now); + require(decoder.counters().out_of_order==1U,"out_of_order_count"); + decoder.ingest(packet(std::span{wire}.subspan(8U,8U),1008U),now); + require(decoder.counters().mms_messages==1U,"reassembled_message"); + require(decoder.counters().decoded_requests==1U,"read_request"); + require(decoder.events().size()==1U,"event_count"); + require(decoder.events().front().service=="Read","read_service"); + require(decoder.events().front().invoke_id.value_or(0U)==42U,"invoke_id"); + decoder.ingest(packet(wire,1000U),now); + require(decoder.counters().mms_messages==1U,"deduplicated_retransmission"); + require(decoder.counters().retransmissions>=1U,"retransmission_count"); + + PassiveMmsDecoder coalesced; + Bytes double_wire=wire; + double_wire.insert(double_wire.end(),wire.begin(),wire.end()); + coalesced.ingest(packet(double_wire,500U),now); + require(coalesced.counters().mms_messages==2U,"coalesced_tpkt"); + coalesced.ingest(packet({},9999U,80U,443U),now); + require(coalesced.counters().mms_messages==2U,"unrelated_tcp_ignored"); + auto bad=packet(wire,3000U); + bad.resize(25U); + coalesced.ingest(bad,now); + require(coalesced.counters().malformed>=1U,"truncated_ipv4"); + std::cout<<"PASSIVE_MMS_PASS split=true out_of_order=true retransmission=true " + <<"coalesced=true read=true invoke=42 unrelated_ignored=true malformed=true " + <<"active_association=false\n"; +} From ad76c58b1ae8e23b047a9c98cc716debc2905d76 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:32:04 +0700 Subject: [PATCH 06/18] P6D: add passive MMS decoder behavioral test --- CMakeLists.txt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index fa4791148..6cacbdfb1 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -317,6 +317,10 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_comtrade_tests) add_test(NAME ariec61850_comtrade_tests COMMAND ariec61850_comtrade_tests) + add_executable(ariec61850_passive_mms_tests tests/test_passive_mms.cpp) + target_link_libraries(ariec61850_passive_mms_tests PRIVATE ARIEC61850::core) + add_test(NAME ariec61850_passive_mms_tests COMMAND ariec61850_passive_mms_tests) + add_executable(ariec61850_osi_transport_tests tests/test_osi_transport.cpp) target_link_libraries(ariec61850_osi_transport_tests PRIVATE ARIEC61850::core) target_compile_features(ariec61850_osi_transport_tests PRIVATE cxx_std_20) From e34cdfd3fdd94d82f716931728fd215559d2429e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:32:46 +0700 Subject: [PATCH 07/18] P6D: expose passive MMS capture and PCAP replay controller --- .../src/MmsPassiveSnifferController.hpp | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 apps/ied_simulator/src/MmsPassiveSnifferController.hpp diff --git a/apps/ied_simulator/src/MmsPassiveSnifferController.hpp b/apps/ied_simulator/src/MmsPassiveSnifferController.hpp new file mode 100644 index 000000000..9fe0008a6 --- /dev/null +++ b/apps/ied_simulator/src/MmsPassiveSnifferController.hpp @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/capture/passive_mms.hpp" +#include "ariec61850/capture/raw_mms_packet_receiver.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include + +class MmsPassiveSnifferController final : public QObject { + Q_OBJECT + QML_ELEMENT + + Q_PROPERTY(QStringList interfaces READ interfaces CONSTANT) + Q_PROPERTY(QString interfaceName READ interfaceName WRITE setInterfaceName NOTIFY stateChanged) + Q_PROPERTY(bool capturing READ capturing NOTIFY stateChanged) + Q_PROPERTY(bool busy READ busy NOTIFY stateChanged) + Q_PROPERTY(QString statusText READ statusText NOTIFY stateChanged) + Q_PROPERTY(QString lastError READ lastError NOTIFY stateChanged) + Q_PROPERTY(QString filter READ filter WRITE setFilter NOTIFY eventsChanged) + Q_PROPERTY(QVariantList events READ events NOTIFY eventsChanged) + Q_PROPERTY(QVariantMap counters READ counters NOTIFY eventsChanged) + Q_PROPERTY(int selectedIndex READ selectedIndex NOTIFY selectionChanged) + Q_PROPERTY(QVariantMap selectedEvent READ selectedEvent NOTIFY selectionChanged) + +public: + explicit MmsPassiveSnifferController(QObject* parent = nullptr); + ~MmsPassiveSnifferController() override; + + [[nodiscard]] QStringList interfaces() const; + [[nodiscard]] QString interfaceName() const { return interfaceName_; } + void setInterfaceName(const QString& value); + [[nodiscard]] bool capturing() const noexcept { return capturing_; } + [[nodiscard]] bool busy() const noexcept { return busy_; } + [[nodiscard]] QString statusText() const { return statusText_; } + [[nodiscard]] QString lastError() const { return lastError_; } + [[nodiscard]] QString filter() const { return filter_; } + void setFilter(const QString& value); + [[nodiscard]] QVariantList events() const; + [[nodiscard]] QVariantMap counters() const; + [[nodiscard]] int selectedIndex() const noexcept { return selectedIndex_; } + [[nodiscard]] QVariantMap selectedEvent() const; + + Q_INVOKABLE bool startCapture(); + Q_INVOKABLE void stopCapture(); + Q_INVOKABLE bool importPcap(const QUrl& url); + Q_INVOKABLE void clear(); + Q_INVOKABLE void select(int index); + +signals: + void stateChanged(); + void eventsChanged(); + void selectionChanged(); + +private: + static QVariantMap eventMap(const ar::iec61850::capture::PassiveMmsEvent& item); + static QVariantMap counterMap(const ar::iec61850::capture::PassiveMmsCounters& item); + void refreshLive(); + void failCapture(const QString& error); + + static constexpr int kPollIntervalMs = 10; + static constexpr int kPollBudget = 64; + static constexpr std::uintmax_t kMaxPcapBytes = 64U * 1024U * 1024U; + static constexpr std::size_t kMaxPcapPackets = 100000U; + + ar::iec61850::capture::RawMmsPacketReceiver receiver_; + ar::iec61850::capture::PassiveMmsDecoder decoder_; + QTimer timer_; + QThreadPool workerPool_; + QVariantList rows_; + QVariantMap counters_; + QString interfaceName_; + QString statusText_{QStringLiteral("Stopped")}; + QString lastError_; + QString filter_{QStringLiteral("All")}; + bool capturing_{}; + bool busy_{}; + int selectedIndex_{-1}; + quint64 generation_{}; +}; From 7ed6b32f78bbc8846d6c6eea1f25e3b02f1f458a Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:33:38 +0700 Subject: [PATCH 08/18] P6D: add live passive MMS capture and bounded offline replay --- .../src/MmsPassiveSnifferController.cpp | 308 ++++++++++++++++++ 1 file changed, 308 insertions(+) create mode 100644 apps/ied_simulator/src/MmsPassiveSnifferController.cpp diff --git a/apps/ied_simulator/src/MmsPassiveSnifferController.cpp b/apps/ied_simulator/src/MmsPassiveSnifferController.cpp new file mode 100644 index 000000000..5896e4dd9 --- /dev/null +++ b/apps/ied_simulator/src/MmsPassiveSnifferController.cpp @@ -0,0 +1,308 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#include "MmsPassiveSnifferController.hpp" + +#include "ariec61850/capture/pcap.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +namespace capture = ar::iec61850::capture; + +namespace { +std::filesystem::path nativePath(const QString& path) { +#ifdef _WIN32 + return std::filesystem::path{path.toStdWString()}; +#else + return std::filesystem::path{path.toStdString()}; +#endif +} +bool matchesFilter(const QVariantMap& item, const QString& filter) { + if (filter == QStringLiteral("All")) return true; + const auto service = item.value(QStringLiteral("service")).toString(); + const auto kind = item.value(QStringLiteral("kind")).toString(); + if (filter == QStringLiteral("Reports")) { + return service == QStringLiteral("InformationReport"); + } + if (filter == QStringLiteral("Read")) return service == QStringLiteral("Read"); + if (filter == QStringLiteral("Write")) return service == QStringLiteral("Write"); + if (filter == QStringLiteral("Control")) return service == QStringLiteral("Control"); + if (filter == QStringLiteral("Association")) { + return kind == QStringLiteral("Association") || + kind == QStringLiteral("OSI") || + kind.startsWith(QStringLiteral("Initiate")); + } + if (filter == QStringLiteral("Errors")) { + return kind == QStringLiteral("Decode error") || + kind == QStringLiteral("Error") || + kind == QStringLiteral("Reject"); + } + return false; +} +} // namespace + +MmsPassiveSnifferController::MmsPassiveSnifferController(QObject* parent) + : QObject(parent) { + timer_.setInterval(kPollIntervalMs); + timer_.setTimerType(Qt::PreciseTimer); + connect(&timer_, &QTimer::timeout, this, + &MmsPassiveSnifferController::refreshLive); + workerPool_.setMaxThreadCount(1); + workerPool_.setExpiryTimeout(-1); +} + +MmsPassiveSnifferController::~MmsPassiveSnifferController() { + ++generation_; + timer_.stop(); + receiver_.close(); + workerPool_.clear(); + workerPool_.waitForDone(); +} + +QStringList MmsPassiveSnifferController::interfaces() const { + QStringList result; + for (const auto& adapter : QNetworkInterface::allInterfaces()) { + if (adapter.flags().testFlag(QNetworkInterface::IsLoopBack)) continue; + if (!adapter.flags().testFlag(QNetworkInterface::IsUp)) continue; + result.push_back(adapter.name()); + } + result.removeDuplicates(); + result.sort(); + return result; +} + +void MmsPassiveSnifferController::setInterfaceName(const QString& value) { + if (capturing_ || busy_) return; + const auto normalized = value.trimmed(); + if (interfaceName_ == normalized) return; + interfaceName_ = normalized; + emit stateChanged(); +} + +void MmsPassiveSnifferController::setFilter(const QString& value) { + static const QStringList allowed{ + QStringLiteral("All"), QStringLiteral("Reports"), + QStringLiteral("Read"), QStringLiteral("Write"), + QStringLiteral("Control"), QStringLiteral("Association"), + QStringLiteral("Errors")}; + if (!allowed.contains(value) || filter_ == value) return; + filter_ = value; + selectedIndex_ = -1; + emit eventsChanged(); + emit selectionChanged(); +} + +QVariantMap MmsPassiveSnifferController::eventMap( + const capture::PassiveMmsEvent& item) { + QVariantMap result; + const auto milliseconds = + std::chrono::duration_cast( + item.timestamp.time_since_epoch()).count(); + result.insert(QStringLiteral("timestamp"), + QDateTime::fromMSecsSinceEpoch(milliseconds).toString(Qt::ISODateWithMs)); + result.insert(QStringLiteral("source"), QString::fromStdString(item.source)); + result.insert(QStringLiteral("destination"), QString::fromStdString(item.destination)); + result.insert(QStringLiteral("kind"), QString::fromStdString(item.kind)); + result.insert(QStringLiteral("service"), QString::fromStdString(item.service)); + result.insert(QStringLiteral("detail"), QString::fromStdString(item.detail)); + result.insert(QStringLiteral("invokeId"), + item.invoke_id ? QString::number(*item.invoke_id) : QStringLiteral("—")); + result.insert(QStringLiteral("bytes"), static_cast(item.wire_bytes)); + return result; +} + +QVariantMap MmsPassiveSnifferController::counterMap( + const capture::PassiveMmsCounters& item) { + return { + {QStringLiteral("ethernet"), static_cast(item.ethernet_packets)}, + {QStringLiteral("tcp"), static_cast(item.tcp_packets)}, + {QStringLiteral("mms"), static_cast(item.mms_messages)}, + {QStringLiteral("requests"), static_cast(item.decoded_requests)}, + {QStringLiteral("responses"), static_cast(item.decoded_responses)}, + {QStringLiteral("reports"), static_cast(item.decoded_reports)}, + {QStringLiteral("retransmissions"), static_cast(item.retransmissions)}, + {QStringLiteral("outOfOrder"), static_cast(item.out_of_order)}, + {QStringLiteral("gaps"), static_cast(item.gaps)}, + {QStringLiteral("malformed"), static_cast(item.malformed)}, + {QStringLiteral("dropped"), static_cast(item.dropped)} + }; +} + +QVariantList MmsPassiveSnifferController::events() const { + if (filter_ == QStringLiteral("All")) return rows_; + QVariantList filtered; + for (const auto& row : rows_) { + if (matchesFilter(row.toMap(), filter_)) filtered.push_back(row); + } + return filtered; +} + +QVariantMap MmsPassiveSnifferController::counters() const { + return counters_; +} + +QVariantMap MmsPassiveSnifferController::selectedEvent() const { + const auto visible = events(); + return selectedIndex_ >= 0 && selectedIndex_ < visible.size() + ? visible.at(selectedIndex_).toMap() : QVariantMap{}; +} + +void MmsPassiveSnifferController::select(int index) { + const auto visible = events(); + selectedIndex_ = index >= 0 && index < visible.size() ? index : -1; + emit selectionChanged(); +} + +void MmsPassiveSnifferController::clear() { + if (busy_) return; + decoder_.reset(); + rows_.clear(); + counters_ = counterMap(decoder_.counters()); + selectedIndex_ = -1; + lastError_.clear(); + emit eventsChanged(); + emit selectionChanged(); + emit stateChanged(); +} + +bool MmsPassiveSnifferController::startCapture() { + if (busy_ || capturing_ || interfaceName_.isEmpty()) return false; + if (!interfaces().contains(interfaceName_)) { + lastError_ = QStringLiteral("Select an available active network interface."); + emit stateChanged(); + return false; + } + std::string error; + if (!receiver_.open(interfaceName_.toStdString(), error)) { + lastError_ = QString::fromStdString(error); + statusText_ = QStringLiteral("Capture unavailable"); + emit stateChanged(); + return false; + } + decoder_.reset(); + rows_.clear(); + selectedIndex_ = -1; + counters_ = counterMap(decoder_.counters()); + lastError_.clear(); + capturing_ = true; + statusText_ = QStringLiteral("Passive TCP/102 capture"); + timer_.start(); + emit stateChanged(); + emit eventsChanged(); + emit selectionChanged(); + return true; +} + +void MmsPassiveSnifferController::stopCapture() { + timer_.stop(); + receiver_.close(); + capturing_ = false; + statusText_ = QStringLiteral("Stopped"); + emit stateChanged(); +} + +void MmsPassiveSnifferController::failCapture(const QString& error) { + stopCapture(); + lastError_ = error; + statusText_ = QStringLiteral("Capture error"); + emit stateChanged(); +} + +void MmsPassiveSnifferController::refreshLive() { + if (!capturing_) return; + bool changed = false; + for (int i = 0; i < kPollBudget; ++i) { + std::vector frame; + std::string error; + const auto received = receiver_.receive(frame, error); + if (received == capture::RawMmsReceiveResult::none) break; + if (received == capture::RawMmsReceiveResult::failure) { + failCapture(QString::fromStdString(error)); + return; + } + decoder_.ingest(frame, std::chrono::system_clock::now()); + changed = true; + } + if (!changed) return; + rows_.clear(); + for (const auto& item : decoder_.events()) { + rows_.push_back(eventMap(item)); + } + counters_ = counterMap(decoder_.counters()); + if (selectedIndex_ >= events().size()) { + selectedIndex_ = -1; + emit selectionChanged(); + } + emit eventsChanged(); +} + +bool MmsPassiveSnifferController::importPcap(const QUrl& url) { + if (capturing_ || busy_) return false; + if (!url.isLocalFile()) { + lastError_ = QStringLiteral("PCAP import requires a local file."); + emit stateChanged(); + return false; + } + const auto path = url.toLocalFile(); + const QFileInfo info(path); + if (!info.isFile() || info.size() <= 0 || + static_cast(info.size()) > kMaxPcapBytes) { + lastError_ = QStringLiteral("PCAP must be a local Ethernet capture of 1..64 MiB."); + emit stateChanged(); + return false; + } + const auto generation = ++generation_; + const QPointer self{this}; + busy_ = true; + lastError_.clear(); + statusText_ = QStringLiteral("Decoding PCAP…"); + emit stateChanged(); + + workerPool_.start([self, path, generation] { + try { + const auto packets = capture::PcapReader::read_all(nativePath(path)); + if (packets.size() > kMaxPcapPackets) { + throw std::runtime_error("PCAP packet count exceeds the 100000 packet bound."); + } + capture::PassiveMmsDecoder local; + for (const auto& packet : packets) local.ingest(packet); + QVariantList rows; + for (const auto& item : local.events()) rows.push_back(eventMap(item)); + const auto counters = counterMap(local.counters()); + if (self) { + QMetaObject::invokeMethod( + self, [self, generation, rows, counters] { + if (!self || self->generation_ != generation) return; + self->rows_ = rows; + self->counters_ = counters; + self->selectedIndex_ = -1; + self->busy_ = false; + self->statusText_ = QStringLiteral("Offline PCAP decoded"); + emit self->eventsChanged(); + emit self->selectionChanged(); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } catch (const std::exception& ex) { + if (self) { + const auto message = QString::fromUtf8(ex.what()); + QMetaObject::invokeMethod( + self, [self, generation, message] { + if (!self || self->generation_ != generation) return; + self->busy_ = false; + self->lastError_ = message; + self->statusText_ = QStringLiteral("PCAP import failed"); + emit self->stateChanged(); + }, Qt::QueuedConnection); + } + } + }); + return true; +} From a83aa1a3d46e5704c6213be3ff648fcf9cbfabe0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:34:45 +0700 Subject: [PATCH 09/18] P6D: add passive MMS capture and analysis workspace --- .../ied_simulator/qml/MmsSnifferWorkspace.qml | 301 ++++++++++++++++++ 1 file changed, 301 insertions(+) create mode 100644 apps/ied_simulator/qml/MmsSnifferWorkspace.qml diff --git a/apps/ied_simulator/qml/MmsSnifferWorkspace.qml b/apps/ied_simulator/qml/MmsSnifferWorkspace.qml new file mode 100644 index 000000000..d95c857a1 --- /dev/null +++ b/apps/ied_simulator/qml/MmsSnifferWorkspace.qml @@ -0,0 +1,301 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +import QtQuick +import QtQuick.Controls +import QtQuick.Dialogs +import QtQuick.Layouts +import ARStack.IedSimulator 1.0 + +Item { + id: root + required property var theme + required property MmsPassiveSnifferController sniffer + + FileDialog { + id: openCapture + title: "Open Ethernet MMS capture" + fileMode: FileDialog.OpenFile + nameFilters: ["PCAP Ethernet capture (*.pcap *.cap)", "All files (*)"] + onAccepted: root.sniffer.importPcap(selectedFile) + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 8 + + RowLayout { + Layout.fillWidth: true + ColumnLayout { + Layout.fillWidth: true + spacing: 2 + Label { + text: "MMS Sniffer" + color: theme.text + font.pixelSize: theme.titleSize + font.weight: Font.DemiBold + } + Label { + text: "Passive TCP/102 · TPKT / COTP / Session / MMS · PCAP replay or live interface capture" + color: theme.muted + font.pixelSize: theme.captionSize + } + } + Label { + text: sniffer.statusText + color: sniffer.capturing ? theme.green : theme.textSoft + font.pixelSize: 9 + font.weight: Font.DemiBold + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 52 + radius: theme.controlRadius + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + RowLayout { + anchors.fill: parent + anchors.margins: 8 + spacing: 7 + + Label { text: "Interface"; color: theme.textSoft; font.pixelSize: 9 } + ComboBox { + id: interfaceSelector + Layout.preferredWidth: 210 + model: sniffer.interfaces + enabled: !sniffer.capturing && !sniffer.busy + currentIndex: Math.max(0, sniffer.interfaces.indexOf(sniffer.interfaceName)) + onActivated: sniffer.interfaceName = currentText + Component.onCompleted: { + if (!sniffer.interfaceName.length && count > 0) + sniffer.interfaceName = currentText + } + } + Button { + text: sniffer.capturing ? "Stop capture" : "Capture live" + enabled: !sniffer.busy + onClicked: sniffer.capturing ? sniffer.stopCapture() : sniffer.startCapture() + } + Button { + text: "Open PCAP…" + enabled: !sniffer.capturing && !sniffer.busy + onClicked: openCapture.open() + } + Button { + text: "Clear" + enabled: !sniffer.busy + onClicked: sniffer.clear() + } + Item { Layout.fillWidth: true } + ComboBox { + id: filterBox + Layout.preferredWidth: 140 + model: ["All", "Reports", "Read", "Write", "Control", "Association", "Errors"] + currentIndex: Math.max(0, model.indexOf(sniffer.filter)) + onActivated: sniffer.filter = currentText + } + } + } + + Rectangle { + visible: sniffer.lastError.length > 0 + Layout.fillWidth: true + implicitHeight: errorText.implicitHeight + 16 + radius: 5 + color: theme.redSoft + border.width: 1 + border.color: theme.red + Label { + id: errorText + anchors.fill: parent + anchors.margins: 8 + text: sniffer.lastError + color: theme.red + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 36 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + spacing: 15 + Label { text: "TCP " + (sniffer.counters.tcp || 0); color: theme.textSoft; font.pixelSize: 9 } + Label { text: "MMS " + (sniffer.counters.mms || 0); color: theme.text; font.pixelSize: 9; font.weight: Font.DemiBold } + Label { text: "Requests " + (sniffer.counters.requests || 0); color: theme.textSoft; font.pixelSize: 9 } + Label { text: "Responses " + (sniffer.counters.responses || 0); color: theme.textSoft; font.pixelSize: 9 } + Label { text: "Reports " + (sniffer.counters.reports || 0); color: theme.green; font.pixelSize: 9 } + Item { Layout.fillWidth: true } + Label { text: "Retrans " + (sniffer.counters.retransmissions || 0); color: theme.muted; font.pixelSize: 8 } + Label { text: "Gap " + (sniffer.counters.gaps || 0); color: theme.muted; font.pixelSize: 8 } + Label { text: "Malformed " + (sniffer.counters.malformed || 0); color: theme.red; font.pixelSize: 8 } + Label { text: "Dropped " + (sniffer.counters.dropped || 0); color: theme.red; font.pixelSize: 8 } + } + } + + SplitView { + Layout.fillWidth: true + Layout.fillHeight: true + orientation: Qt.Horizontal + + Rectangle { + SplitView.fillWidth: true + SplitView.minimumWidth: 490 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + + ColumnLayout { + anchors.fill: parent + spacing: 0 + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 30 + color: theme.chrome + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + spacing: 5 + Label { Layout.preferredWidth: 106; text: "TIME"; color: theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + Label { Layout.fillWidth: true; text: "SOURCE → DESTINATION"; color: theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 114; text: "SERVICE"; color: theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + Label { Layout.preferredWidth: 90; text: "TYPE"; color: theme.muted; font.pixelSize: 8; font.weight: Font.DemiBold } + } + } + ListView { + id: eventList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + reuseItems: true + model: sniffer.events + ScrollBar.vertical: ScrollBar {} + delegate: Rectangle { + required property int index + required property var modelData + width: eventList.width + height: 42 + color: sniffer.selectedIndex === index ? theme.surfaceSoft + : index % 2 ? theme.surfaceSoft : theme.surface + border.width: sniffer.selectedIndex === index ? 1 : 0 + border.color: theme.line + RowLayout { + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 7 + spacing: 5 + Label { + Layout.preferredWidth: 106 + text: String(modelData.timestamp || "").slice(11,23) + color: theme.muted + font.pixelSize: 8 + } + Label { + Layout.fillWidth: true + text: (modelData.source || "—") + " → " + (modelData.destination || "—") + color: theme.textSoft + font.pixelSize: 8 + elide: Text.ElideMiddle + } + Label { + Layout.preferredWidth: 114 + text: modelData.service || "—" + color: modelData.service === "InformationReport" ? theme.green : theme.text + font.pixelSize: 8 + elide: Text.ElideRight + } + Label { + Layout.preferredWidth: 90 + text: modelData.kind || "—" + color: modelData.kind === "Decode error" ? theme.red : theme.muted + font.pixelSize: 8 + elide: Text.ElideRight + } + } + MouseArea { + anchors.fill: parent + onClicked: sniffer.select(index) + } + } + Label { + anchors.centerIn: parent + visible: eventList.count === 0 + text: "No MMS events. Start passive capture or open an Ethernet PCAP." + color: theme.muted + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + } + } + } + + Rectangle { + SplitView.preferredWidth: 315 + SplitView.minimumWidth: 250 + color: theme.surface + border.width: 1 + border.color: theme.lineSoft + ColumnLayout { + anchors.fill: parent + anchors.margins: 12 + spacing: 10 + Label { + text: "Packet details" + color: theme.text + font.pixelSize: 11 + font.weight: Font.DemiBold + } + Repeater { + model: [ + ["Time", sniffer.selectedEvent.timestamp], + ["Source", sniffer.selectedEvent.source], + ["Destination", sniffer.selectedEvent.destination], + ["PDU", sniffer.selectedEvent.kind], + ["Service", sniffer.selectedEvent.service], + ["Invoke ID", sniffer.selectedEvent.invokeId], + ["MMS bytes", sniffer.selectedEvent.bytes] + ] + delegate: ColumnLayout { + required property var modelData + Layout.fillWidth: true + spacing: 2 + Label { text: modelData[0]; color: theme.muted; font.pixelSize: 8 } + Label { + Layout.fillWidth: true + text: modelData[1] === undefined ? "—" : String(modelData[1]) + color: theme.textSoft + font.pixelSize: 9 + wrapMode: Text.WrapAnywhere + } + } + } + Label { + Layout.fillWidth: true + text: sniffer.selectedEvent.detail || "" + color: theme.textSoft + font.pixelSize: 9 + wrapMode: Text.WordWrap + } + Item { Layout.fillHeight: true } + Label { + Layout.fillWidth: true + text: "Passive observation only. No MMS client association is created. IPv4 TCP/102 and classic Ethernet PCAP are supported; unsupported or truncated packets are not guessed." + color: theme.muted + font.pixelSize: 8 + wrapMode: Text.WordWrap + } + } + } + } + } +} From c8d022797caa17c6e89096d1bfad806024b4d4fe Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:35:10 +0700 Subject: [PATCH 10/18] P6D: split passive Sniffer into GOOSE and MMS tabs --- apps/ied_simulator/qml/SnifferWorkspace.qml | 36 +++++++++++++++++++-- 1 file changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/ied_simulator/qml/SnifferWorkspace.qml b/apps/ied_simulator/qml/SnifferWorkspace.qml index a7c9b2e52..738d22106 100644 --- a/apps/ied_simulator/qml/SnifferWorkspace.qml +++ b/apps/ied_simulator/qml/SnifferWorkspace.qml @@ -1,5 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later import QtQuick +import QtQuick.Controls +import QtQuick.Layouts import ARStack.IedSimulator 1.0 Item { @@ -7,9 +9,37 @@ Item { required property var theme required property GooseMonitorController monitor - GooseWorkspace { + MmsPassiveSnifferController { + id: mmsMonitor + objectName: "passiveMmsSnifferBackend" + } + + ColumnLayout { anchors.fill: parent - theme: root.theme - monitor: root.monitor + spacing: 0 + + TabBar { + id: protocolTabs + Layout.fillWidth: true + currentIndex: 0 + TabButton { text: "GOOSE · Layer 2" } + TabButton { text: "MMS · TCP/102" } + } + + StackLayout { + Layout.fillWidth: true + Layout.fillHeight: true + currentIndex: protocolTabs.currentIndex + + GooseWorkspace { + theme: root.theme + monitor: root.monitor + } + + MmsSnifferWorkspace { + theme: root.theme + sniffer: mmsMonitor + } + } } } From 0f56e7688c283ccea602c4cc32d1898b1960b6b0 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:35:35 +0700 Subject: [PATCH 11/18] P6D: compile and bundle passive MMS controller and QML --- apps/ied_simulator/CMakeLists.txt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/ied_simulator/CMakeLists.txt b/apps/ied_simulator/CMakeLists.txt index 79fbf1365..1e75dac6f 100644 --- a/apps/ied_simulator/CMakeLists.txt +++ b/apps/ied_simulator/CMakeLists.txt @@ -79,6 +79,8 @@ qt_add_qml_module(arstack_ied_simulator src/SclWorkspaceController.hpp src/GooseMonitorController.cpp src/GooseMonitorController.hpp + src/MmsPassiveSnifferController.cpp + src/MmsPassiveSnifferController.hpp src/ProductHardeningController.cpp src/ProductHardeningController.hpp QML_FILES @@ -105,6 +107,7 @@ qt_add_qml_module(arstack_ied_simulator qml/BrowserDataSetAuthoringDialog.qml qml/BrowserReportAuthoringDialog.qml qml/SnifferWorkspace.qml + qml/MmsSnifferWorkspace.qml qml/MmsClientWorkspace.qml qml/ReportsWorkspace.qml qml/FilesWorkspace.qml From 5d5191eec8475d9b15beb9cdabe18f503de0df42 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:36:41 +0700 Subject: [PATCH 12/18] P6D: update sniffer ownership gate for passive MMS tab --- apps/ied_simulator/test_sniffer_ownership.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/ied_simulator/test_sniffer_ownership.py b/apps/ied_simulator/test_sniffer_ownership.py index 66429fc94..5f5a0ceca 100644 --- a/apps/ied_simulator/test_sniffer_ownership.py +++ b/apps/ied_simulator/test_sniffer_ownership.py @@ -48,6 +48,8 @@ def main() -> int: forbid(goose, token, "GooseWorkspace") require(sniffer, "GooseMonitorController monitor", "SnifferWorkspace") + require(sniffer, "MmsPassiveSnifferController", "SnifferWorkspace") + require(sniffer, "MmsSnifferWorkspace", "SnifferWorkspace") require(goose, "GooseMonitorController monitor", "GooseWorkspace") require(goose, "root.monitor.startCapture()", "GooseWorkspace") require(goose, "root.monitor.stopCapture()", "GooseWorkspace") @@ -68,7 +70,7 @@ def main() -> int: print( "SNIFFER_OWNERSHIP_PASS " "sniffer=passive goose_capture=true pcap=true " - "publisher=simulator_commissioning mms_sniffer=false" + "publisher=simulator_commissioning mms_sniffer=passive" ) return 0 From f2f57900478ffa32c215d97736e922e4eeb7579c Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:37:08 +0700 Subject: [PATCH 13/18] P6D: enforce passive MMS provenance and bounded capture in CI --- .../ied_simulator/test_passive_mms_sniffer.py | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 apps/ied_simulator/test_passive_mms_sniffer.py diff --git a/apps/ied_simulator/test_passive_mms_sniffer.py b/apps/ied_simulator/test_passive_mms_sniffer.py new file mode 100644 index 000000000..f77fd6525 --- /dev/null +++ b/apps/ied_simulator/test_passive_mms_sniffer.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +"""P6D passive MMS provenance/ownership regression contract.""" +from pathlib import Path +import sys + +if len(sys.argv) != 7: + raise SystemExit("usage: test_passive_mms_sniffer.py decoder receiver controller qml sniffer_qml cmake") + +decoder, receiver, controller, qml, sniffer, cmake = [ + Path(path).read_text(encoding="utf-8") for path in sys.argv[1:] +] + +required = { + "decoder": (decoder, ( + "PassiveMmsDecoder::ingest", "TpktFrameCodec::decode", "CotpFrameCodec::decode", + "SessionCodec::try_decode_data_transfer", "MmsPduCodec::decode_envelope", + "decoded_reports", "out_of_order", "retransmissions", + "maximum_pending_segments", "maximum_flows", "maximum_events", + "confirmed_request", "confirmed_response", "invoke_id" + )), + "receiver": (receiver, ( + "RawMmsPacketReceiver", "AF_PACKET", "LoadLibraryA(\"wpcap.dll\")", + "pcap_open_live", "tcp port 102" + )), + "controller": (controller, ( + "PcapReader::read_all", "kMaxPcapBytes", "kMaxPcapPackets", + "workerPool_.start", "receiver_.receive", "decoder_.ingest", + "QNetworkInterface::allInterfaces" + )), + "qml": (qml, ( + "Open PCAP", "Capture live", "sniffer.events", "sniffer.selectedEvent", + "Reports", "Control", "Association", "Malformed" + )), + "sniffer": (sniffer, ( + "GooseWorkspace {", "MmsSnifferWorkspace {", + "MmsPassiveSnifferController {" + )), + "cmake": (cmake, ( + "MmsPassiveSnifferController.cpp", "qml/MmsSnifferWorkspace.qml" + )) +} +for label,(source,tokens) in required.items(): + for token in tokens: + if token not in source: + raise SystemExit(f"P6D_PASSIVE_MMS_FAIL {label} missing {token}") + +for name,source in (("decoder",decoder),("controller",controller),("receiver",receiver),("qml",qml),("sniffer",sniffer)): + for forbidden in ("connectToIed(", "enableSelected(", "startSimulation(", "sendto(", "pcap_sendpacket"): + if forbidden in source: + raise SystemExit(f"P6D_PASSIVE_MMS_FAIL {name} active operation {forbidden}") + +if "RawEthernetSubscriber" in receiver or "goose_ethertype" in receiver: + raise SystemExit("P6D_PASSIVE_MMS_FAIL reused GOOSE capture filter") + +print("P6D_PASSIVE_MMS_PASS passive=true live_tcp102=true pcap_replay=true " + "bounded=true no_client_association=true no_packet_injection=true") From 700f8a22dd34c8d9f80ba88e79a65d2544b2e725 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:37:33 +0700 Subject: [PATCH 14/18] P6D: assert stream-decoder TPKT path --- apps/ied_simulator/test_passive_mms_sniffer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/test_passive_mms_sniffer.py b/apps/ied_simulator/test_passive_mms_sniffer.py index f77fd6525..bbf85bbf7 100644 --- a/apps/ied_simulator/test_passive_mms_sniffer.py +++ b/apps/ied_simulator/test_passive_mms_sniffer.py @@ -12,7 +12,7 @@ required = { "decoder": (decoder, ( - "PassiveMmsDecoder::ingest", "TpktFrameCodec::decode", "CotpFrameCodec::decode", + "PassiveMmsDecoder::ingest", "stream.tpkt.try_pop", "CotpFrameCodec::decode", "SessionCodec::try_decode_data_transfer", "MmsPduCodec::decode_envelope", "decoded_reports", "out_of_order", "retransmissions", "maximum_pending_segments", "maximum_flows", "maximum_events", From 0235a5ce1037929e03ff86830810a70011485c43 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:37:58 +0700 Subject: [PATCH 15/18] P6D: gate passive capture and decoder ownership --- .github/workflows/ied-simulator-qt.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/ied-simulator-qt.yml b/.github/workflows/ied-simulator-qt.yml index 82a9c60db..e0192e4eb 100644 --- a/.github/workflows/ied-simulator-qt.yml +++ b/.github/workflows/ied-simulator-qt.yml @@ -268,6 +268,16 @@ jobs: apps/ied_simulator/qml/GooseWorkspace.qml apps/ied_simulator/qml/CommissioningWorkspace.qml + - name: P6D passive MMS capture and decoder contract + run: >- + python3 apps/ied_simulator/test_passive_mms_sniffer.py + src/capture/passive_mms.cpp + include/ariec61850/capture/raw_mms_packet_receiver.hpp + apps/ied_simulator/src/MmsPassiveSnifferController.cpp + apps/ied_simulator/qml/MmsSnifferWorkspace.qml + apps/ied_simulator/qml/SnifferWorkspace.qml + apps/ied_simulator/CMakeLists.txt + - name: Upload GOOSE wire evidence uses: actions/upload-artifact@v4 with: From fe7dee131af13dbe2ebe6b3d70645a5a7a8aa9ed Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 17:43:37 +0700 Subject: [PATCH 16/18] P6D: allow Qt QML_ELEMENT wrapper inheritance --- apps/ied_simulator/src/MmsPassiveSnifferController.hpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/ied_simulator/src/MmsPassiveSnifferController.hpp b/apps/ied_simulator/src/MmsPassiveSnifferController.hpp index 9fe0008a6..1389687d0 100644 --- a/apps/ied_simulator/src/MmsPassiveSnifferController.hpp +++ b/apps/ied_simulator/src/MmsPassiveSnifferController.hpp @@ -14,7 +14,7 @@ #include -class MmsPassiveSnifferController final : public QObject { +class MmsPassiveSnifferController : public QObject { Q_OBJECT QML_ELEMENT From 6d67008f49c73ac701815c39bbac35492876a143 Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 19:06:59 +0700 Subject: [PATCH 17/18] P6D: link passive MMS test with ASan/UBSan in sanitizer builds From 7c8ff874164ae42962339e57fe6fd892cf129b1e Mon Sep 17 00:00:00 2001 From: Ari Sulistiono Date: Wed, 23 Sep 2026 19:15:54 +0700 Subject: [PATCH 18/18] P6D: apply sanitizer link flags to passive MMS tests --- CMakeLists.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/CMakeLists.txt b/CMakeLists.txt index 6cacbdfb1..d03e22d5d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -319,6 +319,7 @@ if(ARIEC61850_BUILD_TESTS) add_executable(ariec61850_passive_mms_tests tests/test_passive_mms.cpp) target_link_libraries(ariec61850_passive_mms_tests PRIVATE ARIEC61850::core) + ariec61850_apply_sanitizers(ariec61850_passive_mms_tests) add_test(NAME ariec61850_passive_mms_tests COMMAND ariec61850_passive_mms_tests) add_executable(ariec61850_osi_transport_tests tests/test_osi_transport.cpp)