diff --git a/CMakeLists.txt b/CMakeLists.txt index 39c5e1a8b..31ef34767 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,12 +53,38 @@ add_library(ariec61850_core add_library(ARIEC61850::core ALIAS ariec61850_core) +add_library(ariec61850_mms_server_core + src/asn1/ber_span_reader.cpp + src/osi/tpkt_span.cpp + src/osi/cotp_span.cpp + src/osi/session_span.cpp + src/osi/presentation_span.cpp + src/acse/association_span.cpp + src/mms/pdu_span.cpp + src/mms/services_span.cpp + src/mms/write_span.cpp + src/mms/data_set_span.cpp + src/mms/static_object_table.cpp + src/mms/static_write.cpp + src/mms/static_data_set_table.cpp + src/mms/static_dispatcher.cpp + src/mms/connection_runtime.cpp + src/mms/static_server_session.cpp) + +add_library(ARIEC61850::mms_server_core ALIAS ariec61850_mms_server_core) + target_compile_features(ariec61850_core PUBLIC cxx_std_20) target_include_directories(ariec61850_core PUBLIC $ $) +target_compile_features(ariec61850_mms_server_core PUBLIC cxx_std_20) +target_include_directories(ariec61850_mms_server_core + PUBLIC + $ + $) + if(WIN32) target_link_libraries(ariec61850_core PRIVATE ws2_32) endif() @@ -93,6 +119,8 @@ endfunction() ariec61850_apply_warnings(ariec61850_core) ariec61850_apply_sanitizers(ariec61850_core) +ariec61850_apply_warnings(ariec61850_mms_server_core) +ariec61850_apply_sanitizers(ariec61850_mms_server_core) if(ARIEC61850_BUILD_TOOLS) add_executable(ariec61850_pcap_interop_check tools/pcap_interop_check.cpp) @@ -136,6 +164,17 @@ if(ARIEC61850_BUILD_TOOLS) target_compile_features(ariec61850_sv_injector PRIVATE cxx_std_20) ariec61850_apply_warnings(ariec61850_sv_injector) ariec61850_apply_sanitizers(ariec61850_sv_injector) + + add_executable(ariec61850_static_ied_server tools/static_ied_server.cpp) + target_link_libraries( + ariec61850_static_ied_server + PRIVATE ARIEC61850::mms_server_core ARIEC61850::core) + if(WIN32) + target_link_libraries(ariec61850_static_ied_server PRIVATE ws2_32) + endif() + target_compile_features(ariec61850_static_ied_server PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_static_ied_server) + ariec61850_apply_sanitizers(ariec61850_static_ied_server) endif() if(ARIEC61850_BUILD_TESTS) @@ -266,6 +305,34 @@ if(ARIEC61850_BUILD_TESTS) ariec61850_apply_sanitizers(ariec61850_fuzz_smoke_tests) add_test(NAME ariec61850_fuzz_smoke_tests COMMAND ariec61850_fuzz_smoke_tests) + add_executable( + ariec61850_mms_server_session_tests + tests/test_mms_static_server_session.cpp) + target_link_libraries( + ariec61850_mms_server_session_tests + PRIVATE ARIEC61850::mms_server_core) + target_compile_features( + ariec61850_mms_server_session_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_server_session_tests) + ariec61850_apply_sanitizers(ariec61850_mms_server_session_tests) + add_test( + NAME ariec61850_mms_server_session_tests + COMMAND ariec61850_mms_server_session_tests) + + add_executable( + ariec61850_mms_connection_runtime_tests + embedded/mms_connection_runtime_hard_profile_smoke.cpp) + target_link_libraries( + ariec61850_mms_connection_runtime_tests + PRIVATE ARIEC61850::mms_server_core) + target_compile_features( + ariec61850_mms_connection_runtime_tests PRIVATE cxx_std_20) + ariec61850_apply_warnings(ariec61850_mms_connection_runtime_tests) + ariec61850_apply_sanitizers(ariec61850_mms_connection_runtime_tests) + add_test( + NAME ariec61850_mms_connection_runtime_tests + COMMAND ariec61850_mms_connection_runtime_tests) + if(ARIEC61850_BUILD_TOOLS) add_test( NAME ariec61850_pcap_interop_smoke @@ -283,6 +350,9 @@ if(ARIEC61850_BUILD_TESTS) add_test( NAME ariec61850_sv_injector_loopback_smoke COMMAND ariec61850_sv_injector --frames 160 --json) + add_test( + NAME ariec61850_static_ied_server_help + COMMAND ariec61850_static_ied_server --help) endif() endif() @@ -313,4 +383,4 @@ if(ARIEC61850_BUILD_FUZZERS) ariec61850_add_fuzzer(ariec61850_fuzz_association fuzz/fuzz_association.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_mms_services fuzz/fuzz_mms_services.cpp) ariec61850_add_fuzzer(ariec61850_fuzz_reporting fuzz/fuzz_reporting.cpp) -endif() \ No newline at end of file +endif() diff --git a/docs/STATIC_IED_SERVER.md b/docs/STATIC_IED_SERVER.md new file mode 100644 index 000000000..9b45872e4 --- /dev/null +++ b/docs/STATIC_IED_SERVER.md @@ -0,0 +1,65 @@ +# Static IEC 61850 server foundation + +`ariec61850_static_ied_server` is the first runnable desktop adapter for the +bounded MMS server core. It deliberately keeps platform networking outside the +protocol engine, matching the same boundary required by ESP-IDF/lwIP. + +```text +TCP listen/accept (WinSock or BSD sockets) + -> connected TcpByteStream callbacks + -> MmsStaticServerSession fixed-buffer pump + -> MmsStaticConnectionRuntime + -> MmsStaticApplicationDispatcher + -> static object/DataSet tables +``` + +The included reference model is read-only: + +```text +ESP32S3IOLD0 +|- LLN0 Mod.stVal +|- LPHD1 PhyHealth.stVal +`- GGIO1 Ind1.stVal ... Ind8.stVal +``` + +## Desktop smoke run + +Build with `ARIEC61850_BUILD_TOOLS=ON`, then run: + +```powershell +.\ariec61850_static_ied_server.exe --bind 0.0.0.0 --port 8102 --di-mask 0x35 +``` + +Port 8102 is the unprivileged development default. Use `--port 102` for a +normal IEC 61850 lab endpoint when the operating system permits binding it. + +Implemented server services in this slice: + +- COTP connection accept; +- ACSE association accept and MMS Initiate response; +- `GetNameList`; +- `GetVariableAccessAttributes`; +- `GetNamedVariableListAttributes` when a static DataSet table is supplied; +- `Read`; +- bounded `Write` only for object entries that explicitly provide a write + callback (the included desktop model provides none). + +## ESP32/lwIP integration boundary + +The protocol core does not include an ESP-IDF header or own a listening socket. +An ESP-IDF application accepts a TCP/102 connection through lwIP, supplies +`TcpSendFn`/`TcpReceiveFn` callbacks, gives each connection its own +`MmsStaticConnectionRuntime`, `MmsStaticServerSession`, and fixed buffers, then +calls `poll_once()` from its task or event loop. Buffer capacities and the +maximum concurrent association count remain product-profile decisions. + +## Current limits + +- The desktop adapter serves connections sequentially. The core is per-session + and supports platform-owned multi-client orchestration, but the adapter does + not yet schedule multiple clients concurrently. +- Physical IEDScout browse/read acceptance is not yet recorded. +- The reference values are startup constants; a board adapter still needs to + bind the eight GGIO values to debounced input snapshots. +- Reporting, control, authentication, TLS, file services, and runtime SCL model + loading are outside this first read-only server slice. diff --git a/embedded/hard_profile/CMakeLists.txt b/embedded/hard_profile/CMakeLists.txt index 6dfc824c2..d86c5909b 100644 --- a/embedded/hard_profile/CMakeLists.txt +++ b/embedded/hard_profile/CMakeLists.txt @@ -213,6 +213,26 @@ add_executable(ariec61850_hard_mms_report_connection_smoke ${ARIEC61850_ROOT}/src/mms/static_report_connection.cpp) ariec61850_configure_hard_target(ariec61850_hard_mms_report_connection_smoke) +add_executable(ariec61850_hard_mms_server_session_smoke + ${ARIEC61850_ROOT}/tests/test_mms_static_server_session.cpp + ${ARIEC61850_ROOT}/src/asn1/ber_span_reader.cpp + ${ARIEC61850_ROOT}/src/osi/tpkt_span.cpp + ${ARIEC61850_ROOT}/src/osi/cotp_span.cpp + ${ARIEC61850_ROOT}/src/osi/session_span.cpp + ${ARIEC61850_ROOT}/src/osi/presentation_span.cpp + ${ARIEC61850_ROOT}/src/acse/association_span.cpp + ${ARIEC61850_ROOT}/src/mms/pdu_span.cpp + ${ARIEC61850_ROOT}/src/mms/services_span.cpp + ${ARIEC61850_ROOT}/src/mms/write_span.cpp + ${ARIEC61850_ROOT}/src/mms/data_set_span.cpp + ${ARIEC61850_ROOT}/src/mms/static_object_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_write.cpp + ${ARIEC61850_ROOT}/src/mms/static_data_set_table.cpp + ${ARIEC61850_ROOT}/src/mms/static_dispatcher.cpp + ${ARIEC61850_ROOT}/src/mms/connection_runtime.cpp + ${ARIEC61850_ROOT}/src/mms/static_server_session.cpp) +ariec61850_configure_hard_target(ariec61850_hard_mms_server_session_smoke) + enable_testing() add_test(NAME ariec61850_hard_sv_smoke COMMAND ariec61850_hard_sv_smoke) add_test(NAME ariec61850_hard_goose_smoke COMMAND ariec61850_hard_goose_smoke) @@ -230,6 +250,9 @@ add_test(NAME ariec61850_hard_mms_information_report_smoke COMMAND ariec61850_ha add_test(NAME ariec61850_hard_mms_urcb_runtime_smoke COMMAND ariec61850_hard_mms_urcb_runtime_smoke) add_test(NAME ariec61850_hard_mms_urcb_objects_smoke COMMAND ariec61850_hard_mms_urcb_objects_smoke) add_test(NAME ariec61850_hard_mms_report_connection_smoke COMMAND ariec61850_hard_mms_report_connection_smoke) +add_test( + NAME ariec61850_hard_mms_server_session_smoke + COMMAND ariec61850_hard_mms_server_session_smoke) message(STATUS "arstack61850 hard embedded profile: exceptions disabled=ON") message(STATUS "arstack61850 hard embedded profile: RTTI disabled=ON") diff --git a/include/ariec61850/mms/static_object_table.hpp b/include/ariec61850/mms/static_object_table.hpp index f533cdabe..bf6561aa9 100644 --- a/include/ariec61850/mms/static_object_table.hpp +++ b/include/ariec61850/mms/static_object_table.hpp @@ -33,6 +33,7 @@ struct MmsStaticObjectEntry final { bool mms_deletable{}; MmsStaticWriteCallback write{}; void* write_context{}; + bool name_list_visible{true}; [[nodiscard]] constexpr bool writable() const noexcept { return write != nullptr; diff --git a/include/ariec61850/mms/static_server_session.hpp b/include/ariec61850/mms/static_server_session.hpp new file mode 100644 index 000000000..21ca384e8 --- /dev/null +++ b/include/ariec61850/mms/static_server_session.hpp @@ -0,0 +1,100 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include "ariec61850/embedded/io.hpp" +#include "ariec61850/mms/connection_runtime.hpp" + +#include +#include +#include + +namespace ar::iec61850::mms { + +enum class MmsStaticServerSessionStatus : std::uint8_t { + progressed, + response_pending, + would_block, + timed_out, + application_rejected, + peer_closed, + transport_error, + protocol_error, + receive_buffer_full, + invalid_configuration, +}; + +struct MmsStaticServerSessionResult final { + MmsStaticServerSessionStatus status{ + MmsStaticServerSessionStatus::invalid_configuration}; + MmsStaticConnectionStatus connection_status{ + MmsStaticConnectionStatus::need_more}; + std::size_t bytes_received{}; + std::size_t bytes_sent{}; + std::size_t buffered_input_bytes{}; + std::size_t pending_output_bytes{}; + + [[nodiscard]] constexpr bool terminal() const noexcept { + return status == MmsStaticServerSessionStatus::peer_closed || + status == MmsStaticServerSessionStatus::transport_error || + status == MmsStaticServerSessionStatus::protocol_error || + status == MmsStaticServerSessionStatus::receive_buffer_full || + status == MmsStaticServerSessionStatus::invalid_configuration; + } +}; + +struct MmsStaticServerSessionBuffers final { + std::span receive; + std::span response; + std::span workspace; + + [[nodiscard]] constexpr bool valid() const noexcept { + return !receive.empty() && !response.empty() && !workspace.empty(); + } +}; + +// Allocation-free bridge between a connected TCP byte stream and the portable +// MMS connection state machine. Listen/accept/close remain platform-owned. +// One instance is used per accepted connection, which lets desktop servers, +// FreeRTOS tasks and lwIP callbacks choose their own concurrency policy. +class MmsStaticServerSession final { +public: + constexpr MmsStaticServerSession( + MmsStaticConnectionRuntime& runtime, + const embedded::TcpByteStream stream, + const MmsStaticServerSessionBuffers buffers) noexcept + : runtime_{runtime}, stream_{stream}, buffers_{buffers} {} + + // Performs at most one transport send/receive or one protocol dispatch. + // Call repeatedly from the platform event/task loop. Partial sends and + // fragmented/coalesced TPKT input are retained across calls. + [[nodiscard]] MmsStaticServerSessionResult poll_once() noexcept; + + void reset() noexcept; + + [[nodiscard]] constexpr std::size_t buffered_input_bytes() const noexcept { + return receive_size_; + } + + [[nodiscard]] constexpr std::size_t pending_output_bytes() const noexcept { + return response_size_ - response_offset_; + } + +private: + [[nodiscard]] MmsStaticServerSessionResult make_result( + MmsStaticServerSessionStatus status, + MmsStaticConnectionStatus connection_status, + std::size_t bytes_received = 0U, + std::size_t bytes_sent = 0U) const noexcept; + + void consume_input(std::size_t consumed) noexcept; + + MmsStaticConnectionRuntime& runtime_; + embedded::TcpByteStream stream_{}; + MmsStaticServerSessionBuffers buffers_{}; + std::size_t receive_size_{}; + std::size_t response_offset_{}; + std::size_t response_size_{}; + bool terminal_{}; +}; + +} // namespace ar::iec61850::mms diff --git a/src/mms/static_dispatcher.cpp b/src/mms/static_dispatcher.cpp index ebc8ace4d..ba983de05 100644 --- a/src/mms/static_dispatcher.cpp +++ b/src/mms/static_dispatcher.cpp @@ -121,7 +121,8 @@ namespace { if (request.object_class == MmsNameListObjectClass::named_variable && request.scope == MmsNameScopeKind::domain_specific) { for (const auto& object : objects.objects()) { - if (span_equals(request.domain_id, object.domain)) { + if (object.name_list_visible && + span_equals(request.domain_id, object.domain)) { if (count >= names.size()) { return names.size() + 1U; } diff --git a/src/mms/static_server_session.cpp b/src/mms/static_server_session.cpp new file mode 100644 index 000000000..b65d079a9 --- /dev/null +++ b/src/mms/static_server_session.cpp @@ -0,0 +1,184 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_server_session.hpp" + +#include + +namespace ar::iec61850::mms { + +MmsStaticServerSessionResult MmsStaticServerSession::make_result( + const MmsStaticServerSessionStatus status, + const MmsStaticConnectionStatus connection_status, + const std::size_t bytes_received, + const std::size_t bytes_sent) const noexcept { + return { + status, + connection_status, + bytes_received, + bytes_sent, + receive_size_, + response_size_ - response_offset_}; +} + +void MmsStaticServerSession::consume_input(const std::size_t consumed) noexcept { + if (consumed == 0U || consumed > receive_size_) { + return; + } + const auto remaining = receive_size_ - consumed; + if (remaining != 0U) { + std::memmove( + buffers_.receive.data(), + buffers_.receive.data() + consumed, + remaining); + } + receive_size_ = remaining; +} + +void MmsStaticServerSession::reset() noexcept { + receive_size_ = 0U; + response_offset_ = 0U; + response_size_ = 0U; + terminal_ = false; + runtime_.reset(); +} + +MmsStaticServerSessionResult MmsStaticServerSession::poll_once() noexcept { + if (!buffers_.valid() || stream_.send_callback == nullptr || + stream_.receive_callback == nullptr) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::invalid_configuration, + MmsStaticConnectionStatus::backend_failure); + } + if (terminal_) { + return make_result( + MmsStaticServerSessionStatus::protocol_error, + MmsStaticConnectionStatus::closed); + } + + if (response_offset_ < response_size_) { + const auto remaining = std::span{buffers_.response} + .subspan(response_offset_, response_size_ - response_offset_); + const auto sent = stream_.send(remaining); + if (sent.status == embedded::IoStatus::would_block) { + return make_result( + MmsStaticServerSessionStatus::would_block, + MmsStaticConnectionStatus::response_ready); + } + if (sent.status == embedded::IoStatus::timeout) { + return make_result( + MmsStaticServerSessionStatus::timed_out, + MmsStaticConnectionStatus::response_ready); + } + if (sent.status == embedded::IoStatus::closed) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::peer_closed, + MmsStaticConnectionStatus::closed); + } + if (!sent.success() || sent.transferred == 0U || + sent.transferred > remaining.size()) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::transport_error, + MmsStaticConnectionStatus::backend_failure); + } + + response_offset_ += sent.transferred; + if (response_offset_ == response_size_) { + response_offset_ = 0U; + response_size_ = 0U; + } + return make_result( + MmsStaticServerSessionStatus::progressed, + MmsStaticConnectionStatus::response_ready, + 0U, + sent.transferred); + } + + if (receive_size_ != 0U) { + const auto connection = runtime_.process_tcp_window( + std::span{buffers_.receive}.first(receive_size_), + buffers_.response, + buffers_.workspace); + + switch (connection.status) { + case MmsStaticConnectionStatus::response_ready: + consume_input(connection.consumed_bytes); + response_offset_ = 0U; + response_size_ = connection.bytes_written; + return make_result( + MmsStaticServerSessionStatus::response_pending, + connection.status); + case MmsStaticConnectionStatus::consumed_no_response: + consume_input(connection.consumed_bytes); + return make_result( + MmsStaticServerSessionStatus::progressed, + connection.status); + case MmsStaticConnectionStatus::application_rejected: + consume_input(connection.consumed_bytes); + return make_result( + MmsStaticServerSessionStatus::application_rejected, + connection.status); + case MmsStaticConnectionStatus::closed: + consume_input(connection.consumed_bytes); + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::peer_closed, + connection.status); + case MmsStaticConnectionStatus::need_more: + break; + case MmsStaticConnectionStatus::malformed_transport: + case MmsStaticConnectionStatus::protocol_violation: + case MmsStaticConnectionStatus::response_buffer_too_small: + case MmsStaticConnectionStatus::workspace_too_small: + case MmsStaticConnectionStatus::backend_failure: + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::protocol_error, + connection.status); + } + } + + if (receive_size_ == buffers_.receive.size()) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::receive_buffer_full, + MmsStaticConnectionStatus::need_more); + } + + const auto destination = buffers_.receive.subspan(receive_size_); + const auto received = stream_.receive(destination); + if (received.status == embedded::IoStatus::would_block) { + return make_result( + MmsStaticServerSessionStatus::would_block, + MmsStaticConnectionStatus::need_more); + } + if (received.status == embedded::IoStatus::timeout) { + return make_result( + MmsStaticServerSessionStatus::timed_out, + MmsStaticConnectionStatus::need_more); + } + if (received.status == embedded::IoStatus::closed) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::peer_closed, + MmsStaticConnectionStatus::closed); + } + if (!received.success() || received.transferred == 0U || + received.transferred > destination.size()) { + terminal_ = true; + return make_result( + MmsStaticServerSessionStatus::transport_error, + MmsStaticConnectionStatus::backend_failure); + } + + receive_size_ += received.transferred; + return make_result( + MmsStaticServerSessionStatus::progressed, + MmsStaticConnectionStatus::need_more, + received.transferred, + 0U); +} + +} // namespace ar::iec61850::mms diff --git a/src/osi/cotp_span.cpp b/src/osi/cotp_span.cpp index 54a4cdb65..56cdd5375 100644 --- a/src/osi/cotp_span.cpp +++ b/src/osi/cotp_span.cpp @@ -185,13 +185,13 @@ bool CotpTpduView::try_parameter( if (parameter_bytes.size() - offset < 2U) { return false; } - const auto code = parameter_bytes[offset]; + const auto current_code = parameter_bytes[offset]; const auto length = static_cast(parameter_bytes[offset + 1U]); offset += 2U; if (length > parameter_bytes.size() - offset) { return false; } - if (code == parameter_code) { + if (current_code == parameter_code) { value = parameter_bytes.subspan(offset, length); return true; } diff --git a/src/osi/session_span.cpp b/src/osi/session_span.cpp index be0dcc10e..43353322d 100644 --- a/src/osi/session_span.cpp +++ b/src/osi/session_span.cpp @@ -90,13 +90,13 @@ bool SessionSpduView::try_parameter( if (parameter_bytes.size() - offset < 2U) { return false; } - const auto code = parameter_bytes[offset]; + const auto current_code = parameter_bytes[offset]; const auto length = static_cast(parameter_bytes[offset + 1U]); offset += 2U; if (length > parameter_bytes.size() - offset) { return false; } - if (code == parameter_code) { + if (current_code == parameter_code) { value = parameter_bytes.subspan(offset, length); return true; } diff --git a/tests/test_mms_static_server_session.cpp b/tests/test_mms_static_server_session.cpp new file mode 100644 index 000000000..f30185f14 --- /dev/null +++ b/tests/test_mms_static_server_session.cpp @@ -0,0 +1,232 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_server_session.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/osi/cotp_span.hpp" +#include "ariec61850/osi/tpkt_span.hpp" + +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +struct FakeTcp final { + std::span inbound; + std::span outbound; + std::size_t inbound_offset{}; + std::size_t outbound_size{}; + std::size_t maximum_receive_chunk{2U}; + std::size_t maximum_send_chunk{3U}; + bool close_when_empty{true}; +}; + +[[nodiscard]] embedded::IoResult fake_receive( + void* context, + const std::span destination) noexcept { + auto& tcp = *static_cast(context); + if (tcp.inbound_offset == tcp.inbound.size()) { + return { + tcp.close_when_empty ? embedded::IoStatus::closed + : embedded::IoStatus::would_block, + 0U}; + } + const auto count = std::min({ + destination.size(), + tcp.maximum_receive_chunk, + tcp.inbound.size() - tcp.inbound_offset}); + std::copy_n( + tcp.inbound.begin() + static_cast(tcp.inbound_offset), + count, + destination.begin()); + tcp.inbound_offset += count; + return {embedded::IoStatus::ok, count}; +} + +[[nodiscard]] embedded::IoResult fake_send( + void* context, + const std::span bytes) noexcept { + auto& tcp = *static_cast(context); + if (tcp.outbound_size == tcp.outbound.size()) { + return {embedded::IoStatus::would_block, 0U}; + } + const auto count = std::min({ + bytes.size(), + tcp.maximum_send_chunk, + tcp.outbound.size() - tcp.outbound_size}); + std::copy_n( + bytes.begin(), + count, + tcp.outbound.begin() + static_cast(tcp.outbound_size)); + tcp.outbound_size += count; + return {embedded::IoStatus::ok, count}; +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) ? 0xFFU : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +[[nodiscard]] bool fragmented_connection_request_is_pumped() noexcept { + constexpr std::array boolean_type{0x83U, 0x00U}; + const bool value = true; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", + "GGIO1$ST$Ind1$stVal", + boolean_type, + read_boolean, + &value}}; + const mms::MmsStaticObjectTable table{objects}; + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + + constexpr std::array tpdu_size{0x0AU}; + constexpr std::array source_tsap{0x00U, 0x01U}; + constexpr std::array destination_tsap{0x00U, 0x01U}; + const std::array parameters{ + osi::CotpParameterView{osi::CotpSpanCodec::tpdu_size_parameter, tpdu_size}, + osi::CotpParameterView{osi::CotpSpanCodec::source_tsap_parameter, source_tsap}, + osi::CotpParameterView{ + osi::CotpSpanCodec::destination_tsap_parameter, + destination_tsap}}; + + std::array cotp_bytes{}; + std::array inbound{}; + const auto cotp = osi::CotpSpanCodec::encode_connection_request_into( + 0x0001U, parameters, cotp_bytes); + const auto tpkt = cotp.success() + ? osi::TpktSpanCodec::encode_into( + std::span{cotp_bytes}.first(cotp.bytes_written), + inbound) + : wire::EncodeResult{}; + if (!cotp.success() || !tpkt.success()) { + return false; + } + + std::array outbound{}; + FakeTcp fake{ + std::span{inbound}.first(tpkt.bytes_written), + outbound}; + const embedded::TcpByteStream stream{&fake, fake_send, fake_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + mms::MmsStaticServerSession session{ + runtime, + stream, + {receive, response, workspace}}; + + for (std::size_t iteration = 0U; iteration < 256U; ++iteration) { + const auto result = session.poll_once(); + if (result.terminal()) { + return false; + } + const auto peek = osi::TpktSpanCodec::peek_frame( + std::span{outbound}.first(fake.outbound_size)); + if (peek.status == osi::TpktPeekStatus::ready) { + osi::TpktFrameView response_tpkt; + osi::CotpTpduView response_cotp; + return fake.inbound_offset == tpkt.bytes_written && + session.pending_output_bytes() == 0U && + runtime.state() == + mms::MmsStaticConnectionState::awaiting_association && + osi::TpktSpanCodec::try_decode_view( + std::span{outbound}.first(peek.frame_bytes), + response_tpkt) && + osi::CotpSpanCodec::try_decode_view( + response_tpkt.payload, + response_cotp) && + response_cotp.kind == osi::CotpWireKind::connection_confirm && + response_cotp.destination_reference == 0x0001U; + } + } + return false; +} + +[[nodiscard]] bool oversized_frame_is_bounded() noexcept { + const bool value = false; + constexpr std::array boolean_type{0x83U, 0x00U}; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "X", boolean_type, read_boolean, &value}}; + const mms::MmsStaticObjectTable table{objects}; + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + + constexpr std::array declared_large_tpkt{ + 0x03U, 0x00U, 0x04U, 0x00U, 0x02U, 0xF0U, 0x80U, 0x00U}; + std::array discarded_output{}; + FakeTcp fake{declared_large_tpkt, discarded_output}; + fake.maximum_receive_chunk = declared_large_tpkt.size(); + const embedded::TcpByteStream stream{&fake, fake_send, fake_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + mms::MmsStaticServerSession session{ + runtime, + stream, + {receive, response, workspace}}; + + const auto first = session.poll_once(); + const auto second = session.poll_once(); + return first.status == mms::MmsStaticServerSessionStatus::progressed && + second.status == + mms::MmsStaticServerSessionStatus::receive_buffer_full && + second.terminal(); +} + +[[nodiscard]] bool peer_close_is_reported() noexcept { + const bool value = false; + constexpr std::array boolean_type{0x83U, 0x00U}; + const std::array objects{ + mms::MmsStaticObjectEntry{ + "LD0", "X", boolean_type, read_boolean, &value}}; + const mms::MmsStaticObjectTable table{objects}; + const mms::MmsStaticApplicationDispatcher dispatcher{table}; + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + std::array empty_inbound{}; + std::array outbound{}; + FakeTcp fake{std::span{empty_inbound}.first(0U), outbound}; + const embedded::TcpByteStream stream{&fake, fake_send, fake_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + mms::MmsStaticServerSession session{ + runtime, + stream, + {receive, response, workspace}}; + const auto result = session.poll_once(); + return result.status == mms::MmsStaticServerSessionStatus::peer_closed && + result.terminal(); +} + +} // namespace + +int main() { + if (!fragmented_connection_request_is_pumped()) { + return 1; + } + if (!oversized_frame_is_bounded()) { + return 2; + } + if (!peer_close_is_reported()) { + return 3; + } + return 0; +} diff --git a/tools/static_ied_server.cpp b/tools/static_ied_server.cpp new file mode 100644 index 000000000..764f9fea0 --- /dev/null +++ b/tools/static_ied_server.cpp @@ -0,0 +1,625 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "ariec61850/mms/static_server_session.hpp" +#include "ariec61850/mms/static_object_table.hpp" +#include "ariec61850/mms/services.hpp" + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#define NOMINMAX +#include +#include +#else +#include +#include +#include +#include +#include +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace { + +using namespace ar::iec61850; + +#ifdef _WIN32 +using SocketHandle = SOCKET; +constexpr SocketHandle kInvalidSocket = INVALID_SOCKET; +#else +using SocketHandle = int; +constexpr SocketHandle kInvalidSocket = -1; +#endif + +struct Options final { + std::string bind_address{"0.0.0.0"}; + std::uint16_t port{8102U}; + std::uint8_t digital_input_mask{}; + bool serve_once{}; +}; + +class SocketSystem final { +public: + SocketSystem() noexcept { +#ifdef _WIN32 + WSADATA data{}; + ready_ = WSAStartup(MAKEWORD(2, 2), &data) == 0; +#else + ready_ = true; +#endif + } + + ~SocketSystem() { +#ifdef _WIN32 + if (ready_) { + WSACleanup(); + } +#endif + } + + SocketSystem(const SocketSystem&) = delete; + SocketSystem& operator=(const SocketSystem&) = delete; + + [[nodiscard]] bool ready() const noexcept { + return ready_; + } + +private: + bool ready_{}; +}; + +class OwnedSocket final { +public: + OwnedSocket() noexcept = default; + explicit OwnedSocket(const SocketHandle handle) noexcept : handle_{handle} {} + + ~OwnedSocket() { + reset(); + } + + OwnedSocket(const OwnedSocket&) = delete; + OwnedSocket& operator=(const OwnedSocket&) = delete; + + OwnedSocket(OwnedSocket&& other) noexcept : handle_{other.release()} {} + + OwnedSocket& operator=(OwnedSocket&& other) noexcept { + if (this != &other) { + reset(other.release()); + } + return *this; + } + + [[nodiscard]] SocketHandle get() const noexcept { + return handle_; + } + + [[nodiscard]] bool valid() const noexcept { + return handle_ != kInvalidSocket; + } + + [[nodiscard]] SocketHandle release() noexcept { + const auto handle = handle_; + handle_ = kInvalidSocket; + return handle; + } + + void reset(const SocketHandle replacement = kInvalidSocket) noexcept { + if (valid()) { +#ifdef _WIN32 + closesocket(handle_); +#else + close(handle_); +#endif + } + handle_ = replacement; + } + +private: + SocketHandle handle_{kInvalidSocket}; +}; + +[[nodiscard]] int last_socket_error() noexcept { +#ifdef _WIN32 + return WSAGetLastError(); +#else + return errno; +#endif +} + +[[nodiscard]] bool is_would_block(const int error) noexcept { +#ifdef _WIN32 + return error == WSAEWOULDBLOCK; +#else + return error == EAGAIN || error == EWOULDBLOCK; +#endif +} + +[[nodiscard]] bool is_timeout(const int error) noexcept { +#ifdef _WIN32 + return error == WSAETIMEDOUT; +#else + return error == ETIMEDOUT; +#endif +} + +[[nodiscard]] bool is_interrupted(const int error) noexcept { +#ifdef _WIN32 + return error == WSAEINTR; +#else + return error == EINTR; +#endif +} + +[[nodiscard]] embedded::IoResult socket_receive( + void* context, + const std::span destination) noexcept { + const auto socket = *static_cast(context); + const auto requested = static_cast(std::min( + destination.size(), + static_cast(std::numeric_limits::max()))); + const auto received = recv( + socket, + reinterpret_cast(destination.data()), + requested, + 0); + if (received > 0) { + return { + embedded::IoStatus::ok, + static_cast(received)}; + } + if (received == 0) { + return {embedded::IoStatus::closed, 0U}; + } + const auto error = last_socket_error(); + if (is_would_block(error)) { + return {embedded::IoStatus::would_block, 0U}; + } + if (is_timeout(error) || is_interrupted(error)) { + return {embedded::IoStatus::timeout, 0U}; + } + return {embedded::IoStatus::io_error, 0U}; +} + +[[nodiscard]] embedded::IoResult socket_send( + void* context, + const std::span bytes) noexcept { + const auto socket = *static_cast(context); + const auto requested = static_cast(std::min( + bytes.size(), + static_cast(std::numeric_limits::max()))); +#ifdef MSG_NOSIGNAL + constexpr int flags = MSG_NOSIGNAL; +#else + constexpr int flags = 0; +#endif + const auto sent = send( + socket, + reinterpret_cast(bytes.data()), + requested, + flags); + if (sent > 0) { + return {embedded::IoStatus::ok, static_cast(sent)}; + } + if (sent == 0) { + return {embedded::IoStatus::closed, 0U}; + } + const auto error = last_socket_error(); + if (is_would_block(error)) { + return {embedded::IoStatus::would_block, 0U}; + } + if (is_timeout(error) || is_interrupted(error)) { + return {embedded::IoStatus::timeout, 0U}; + } + return {embedded::IoStatus::io_error, 0U}; +} + +void print_help() { + std::cout + << "arstack61850 static IEC 61850 IED server\n\n" + << "Usage:\n" + << " ariec61850_static_ied_server [options]\n\n" + << "Options:\n" + << " --bind ADDRESS Bind address (default 0.0.0.0)\n" + << " --port PORT TCP port (default 8102; use 102 for IEC 61850)\n" + << " --di-mask VALUE Initial GGIO1 Ind1..Ind8 bit mask, decimal or 0xHEX\n" + << " --once Serve one TCP connection, then exit\n" + << " --help Show this help\n\n" + << "Read-only MMS services: association, Initiate, GetNameList, " + "GetVariableAccessAttributes, Read.\n"; +} + +template +[[nodiscard]] bool parse_integer( + const std::string_view text, + Integer& output) noexcept { + auto digits = text; + int base = 10; + if (digits.size() > 2U && digits[0] == '0' && + (digits[1] == 'x' || digits[1] == 'X')) { + digits.remove_prefix(2U); + base = 16; + } + unsigned int parsed{}; + const auto result = std::from_chars( + digits.data(), digits.data() + digits.size(), parsed, base); + if (result.ec != std::errc{} || result.ptr != digits.data() + digits.size() || + parsed > static_cast(std::numeric_limits::max())) { + return false; + } + output = static_cast(parsed); + return true; +} + +[[nodiscard]] bool parse_options( + const int argc, + char** argv, + Options& options, + bool& help) { + help = false; + for (int index = 1; index < argc; ++index) { + const std::string_view argument{argv[index]}; + if (argument == "--help" || argument == "-h") { + help = true; + return true; + } + if (argument == "--once") { + options.serve_once = true; + continue; + } + if (index + 1 >= argc) { + std::cerr << "Missing value after " << argument << "\n"; + return false; + } + const std::string_view value{argv[++index]}; + if (argument == "--bind") { + options.bind_address.assign(value); + } else if (argument == "--port") { + if (!parse_integer(value, options.port) || options.port == 0U) { + std::cerr << "Invalid TCP port: " << value << "\n"; + return false; + } + } else if (argument == "--di-mask") { + if (!parse_integer(value, options.digital_input_mask)) { + std::cerr << "Invalid DI mask: " << value << "\n"; + return false; + } + } else { + std::cerr << "Unknown option: " << argument << "\n"; + return false; + } + } + return true; +} + +[[nodiscard]] OwnedSocket create_listener(const Options& options) { + addrinfo hints{}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_protocol = IPPROTO_TCP; + hints.ai_flags = AI_PASSIVE; + + const auto service = std::to_string(options.port); + addrinfo* addresses{}; + const auto address_status = getaddrinfo( + options.bind_address.empty() ? nullptr : options.bind_address.c_str(), + service.c_str(), + &hints, + &addresses); + if (address_status != 0) { + std::cerr << "Unable to resolve bind address: " + << options.bind_address << "\n"; + return {}; + } + + OwnedSocket listener; + for (auto* address = addresses; address != nullptr; address = address->ai_next) { + OwnedSocket candidate{socket( + address->ai_family, + address->ai_socktype, + address->ai_protocol)}; + if (!candidate.valid()) { + continue; + } + constexpr int enabled = 1; + static_cast(setsockopt( + candidate.get(), + SOL_SOCKET, + SO_REUSEADDR, + reinterpret_cast(&enabled), + static_cast(sizeof(enabled)))); + if (bind( + candidate.get(), + address->ai_addr, + static_cast(address->ai_addrlen)) == 0 && + listen(candidate.get(), 8) == 0) { + listener = std::move(candidate); + break; + } + } + freeaddrinfo(addresses); + return listener; +} + +void set_client_timeouts(const SocketHandle socket) noexcept { +#ifdef _WIN32 + constexpr DWORD timeout_ms = 1000U; + static_cast(setsockopt( + socket, + SOL_SOCKET, + SO_RCVTIMEO, + reinterpret_cast(&timeout_ms), + static_cast(sizeof(timeout_ms)))); + static_cast(setsockopt( + socket, + SOL_SOCKET, + SO_SNDTIMEO, + reinterpret_cast(&timeout_ms), + static_cast(sizeof(timeout_ms)))); +#else + constexpr timeval timeout{1, 0}; + static_cast(setsockopt( + socket, SOL_SOCKET, SO_RCVTIMEO, &timeout, sizeof(timeout))); + static_cast(setsockopt( + socket, SOL_SOCKET, SO_SNDTIMEO, &timeout, sizeof(timeout))); +#endif +} + +[[nodiscard]] wire::EncodeResult read_boolean( + const void* context, + const std::span destination) noexcept { + constexpr std::size_t required = 3U; + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, required}; + } + if (destination.size() < required) { + return {wire::EncodeStatus::buffer_too_small, 0U, required}; + } + destination[0] = 0x83U; + destination[1] = 0x01U; + destination[2] = *static_cast(context) != 0U + ? 0xFFU + : 0x00U; + return {wire::EncodeStatus::ok, required, required}; +} + +struct EncodedValue final { + std::span bytes; +}; + +[[nodiscard]] wire::EncodeResult read_encoded( + const void* context, + const std::span destination) noexcept { + if (context == nullptr) { + return {wire::EncodeStatus::value_out_of_range, 0U, 0U}; + } + const auto bytes = static_cast(context)->bytes; + if (destination.size() < bytes.size()) { + return {wire::EncodeStatus::buffer_too_small, 0U, bytes.size()}; + } + std::copy(bytes.begin(), bytes.end(), destination.begin()); + return {wire::EncodeStatus::ok, bytes.size(), bytes.size()}; +} + +[[nodiscard]] mms::MmsTypeSpecification boolean_component( + const std::string& name) { + mms::MmsTypeSpecification component; + component.kind = mms::MmsTypeKind::boolean; + component.name = name; + return component; +} + +[[nodiscard]] mms::MmsTypeSpecification structure_component( + const std::string& name, + std::vector children) { + mms::MmsTypeSpecification component; + component.kind = mms::MmsTypeKind::structure; + component.name = name; + component.children = std::move(children); + return component; +} + +[[nodiscard]] std::vector build_single_status_ln_type( + const std::string& data_object) { + mms::MmsTypeSpecification root; + root.kind = mms::MmsTypeKind::structure; + root.children.push_back(structure_component( + "ST", + {structure_component( + data_object, + {boolean_component("stVal")})})); + return mms::MmsServiceCodec::encode_type_specification(root); +} + +[[nodiscard]] std::vector build_ggio_type() { + std::vector indications; + indications.reserve(8U); + for (std::size_t index = 0U; index < 8U; ++index) { + indications.push_back(structure_component( + "Ind" + std::to_string(index + 1U), + {boolean_component("stVal")})); + } + mms::MmsTypeSpecification root; + root.kind = mms::MmsTypeKind::structure; + root.children.push_back(structure_component("ST", std::move(indications))); + return mms::MmsServiceCodec::encode_type_specification(root); +} + +[[nodiscard]] bool serve_client( + SocketHandle client, + const mms::MmsStaticApplicationDispatcher& dispatcher) { + mms::MmsStaticConnectionRuntime runtime{dispatcher}; + embedded::TcpByteStream stream{&client, socket_send, socket_receive}; + std::array receive{}; + std::array response{}; + std::array workspace{}; + mms::MmsStaticServerSession session{ + runtime, + stream, + {receive, response, workspace}}; + + for (;;) { + const auto result = session.poll_once(); + switch (result.status) { + case mms::MmsStaticServerSessionStatus::progressed: + case mms::MmsStaticServerSessionStatus::response_pending: + case mms::MmsStaticServerSessionStatus::would_block: + case mms::MmsStaticServerSessionStatus::timed_out: + continue; + case mms::MmsStaticServerSessionStatus::application_rejected: + std::cerr << "MMS request rejected; connection remains open.\n"; + continue; + case mms::MmsStaticServerSessionStatus::peer_closed: + return true; + case mms::MmsStaticServerSessionStatus::transport_error: + std::cerr << "Client transport failed.\n"; + return false; + case mms::MmsStaticServerSessionStatus::protocol_error: + std::cerr << "Malformed or unsupported connection sequence.\n"; + return false; + case mms::MmsStaticServerSessionStatus::receive_buffer_full: + std::cerr << "Client TPKT exceeds the bounded receive capacity.\n"; + return false; + case mms::MmsStaticServerSessionStatus::invalid_configuration: + std::cerr << "Server session configuration is invalid.\n"; + return false; + } + } +} + +} // namespace + +int main(int argc, char** argv) { + Options options; + bool help{}; + if (!parse_options(argc, argv, options, help)) { + return 2; + } + if (help) { + print_help(); + return 0; + } + + SocketSystem socket_system; + if (!socket_system.ready()) { + std::cerr << "Unable to initialize the platform socket API.\n"; + return 3; + } + + constexpr std::array boolean_type{0x83U, 0x00U}; + constexpr std::array leaf_items{ + "GGIO1$ST$Ind1$stVal", + "GGIO1$ST$Ind2$stVal", + "GGIO1$ST$Ind3$stVal", + "GGIO1$ST$Ind4$stVal", + "GGIO1$ST$Ind5$stVal", + "GGIO1$ST$Ind6$stVal", + "GGIO1$ST$Ind7$stVal", + "GGIO1$ST$Ind8$stVal"}; + std::array values{}; + values[0] = 1U; + values[1] = 1U; + for (std::size_t index = 0U; index < 8U; ++index) { + values[index + 2U] = static_cast( + (options.digital_input_mask >> index) & 0x01U); + } + + const auto lln0_type = build_single_status_ln_type("Mod"); + const auto lphd1_type = build_single_status_ln_type("PhyHealth"); + const auto ggio1_type = build_ggio_type(); + constexpr std::array healthy_ln_data{ + 0xA2U, 0x07U, 0xA2U, 0x05U, 0xA2U, 0x03U, 0x83U, 0x01U, 0xFFU}; + std::array ggio_data{}; + ggio_data[0] = 0xA2U; + ggio_data[1] = 0x2AU; + ggio_data[2] = 0xA2U; + ggio_data[3] = 0x28U; + for (std::size_t index = 0U; index < 8U; ++index) { + const auto offset = 4U + index * 5U; + ggio_data[offset] = 0xA2U; + ggio_data[offset + 1U] = 0x03U; + ggio_data[offset + 2U] = 0x83U; + ggio_data[offset + 3U] = 0x01U; + ggio_data[offset + 4U] = values[index + 2U] != 0U ? 0xFFU : 0x00U; + } + const std::array root_values{ + EncodedValue{healthy_ln_data}, + EncodedValue{healthy_ln_data}, + EncodedValue{ggio_data}}; + + std::array objects{}; + objects[0] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", "LLN0", lln0_type, + read_encoded, &root_values[0], false, nullptr, nullptr, false}; + objects[1] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", "LPHD1", lphd1_type, + read_encoded, &root_values[1], false, nullptr, nullptr, false}; + objects[2] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", "GGIO1", ggio1_type, + read_encoded, &root_values[2], false, nullptr, nullptr, false}; + objects[3] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", "LLN0$ST$Mod$stVal", boolean_type, + read_boolean, &values[0]}; + objects[4] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", "LPHD1$ST$PhyHealth$stVal", boolean_type, + read_boolean, &values[1]}; + for (std::size_t index = 0U; index < leaf_items.size(); ++index) { + objects[index + 5U] = mms::MmsStaticObjectEntry{ + "ESP32S3IOLD0", + leaf_items[index], + boolean_type, + read_boolean, + &values[index + 2U]}; + } + + const mms::MmsStaticObjectTable object_table{objects}; + if (!object_table.valid()) { + std::cerr << "Static MMS model is invalid.\n"; + return 4; + } + const mms::MmsStaticApplicationDispatcher dispatcher{object_table}; + auto listener = create_listener(options); + if (!listener.valid()) { + std::cerr << "Unable to bind/listen on " << options.bind_address + << ':' << options.port << ".\n"; + return 5; + } + + std::cout << "STATIC_IED_SERVER_READY bind=" << options.bind_address + << " port=" << options.port + << " domain=ESP32S3IOLD0 listed_variables=10 root_types=3" + " readable_leaves=10\n"; + do { + sockaddr_storage peer{}; + socklen_t peer_size = sizeof(peer); + OwnedSocket client{accept( + listener.get(), + reinterpret_cast(&peer), + &peer_size)}; + if (!client.valid()) { + const auto error = last_socket_error(); + if (is_interrupted(error)) { + continue; + } + std::cerr << "TCP accept failed with platform error " << error << ".\n"; + return 6; + } + set_client_timeouts(client.get()); + std::cout << "STATIC_IED_CLIENT_ACCEPTED\n"; + const auto clean_close = serve_client(client.get(), dispatcher); + std::cout << (clean_close ? "STATIC_IED_CLIENT_CLOSED" + : "STATIC_IED_CLIENT_ABORTED") + << "\n"; + } while (!options.serve_once); + + return 0; +}