From f6617fa3d9ae9c3eb967bc63ff519827435e608f Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Wed, 5 Nov 2025 10:33:58 -0800 Subject: [PATCH 01/11] change the master address for each connection --- dnp3/src/outstation/session.rs | 4 ++++ dnp3/src/outstation/task.rs | 5 +++++ dnp3/src/tcp/client.rs | 5 +++++ dnp3/src/tcp/connector.rs | 13 +++++++++++++ dnp3/src/transport/mock/reader.rs | 6 +++--- dnp3/src/util/session.rs | 12 ++++++++++++ 6 files changed, 42 insertions(+), 3 deletions(-) diff --git a/dnp3/src/outstation/session.rs b/dnp3/src/outstation/session.rs index 6c34f111..672c23ae 100644 --- a/dnp3/src/outstation/session.rs +++ b/dnp3/src/outstation/session.rs @@ -352,6 +352,10 @@ impl OutstationSession { } } + pub(crate) fn change_master_address(&mut self, address: EndpointAddress) { + self.destination.link = address; + } + pub(crate) fn enabled(&self) -> Enabled { self.enabled } diff --git a/dnp3/src/outstation/task.rs b/dnp3/src/outstation/task.rs index 1bc7e1ae..04f26479 100644 --- a/dnp3/src/outstation/task.rs +++ b/dnp3/src/outstation/task.rs @@ -1,6 +1,7 @@ use crate::app::parse::options::ParseOptions; use crate::decode::DecodeLevel; use crate::link::reader::LinkModes; +use crate::link::EndpointAddress; use crate::outstation::config::*; use crate::outstation::database::DatabaseHandle; use crate::outstation::session::OutstationSession; @@ -92,6 +93,10 @@ impl OutstationTask { self.session.enabled() } + pub(crate) fn change_master_address(&mut self, address: EndpointAddress) { + self.session.change_master_address(address); + } + /// run the outstation task asynchronously until a `SessionError` occurs pub(crate) async fn run(&mut self, io: &mut PhysLayer) -> RunError { let res = self diff --git a/dnp3/src/tcp/client.rs b/dnp3/src/tcp/client.rs index 05564943..522358e2 100644 --- a/dnp3/src/tcp/client.rs +++ b/dnp3/src/tcp/client.rs @@ -74,7 +74,12 @@ impl ClientTask { async fn connect(&mut self) -> Result<(PhysLayer, SocketAddr, Option>), Duration> { loop { let conn_info = self.connect_handler.next()?; + let settings = conn_info.settings; if let Some((phys, addr, hostname)) = self.connect_to_endpoint(conn_info).await { + if let Some(master_address) = settings.master_address { + self.session.change_master_address(master_address); + } + self.connect_handler .connected(addr, hostname.as_ref().map(|x| x.as_str())); return Ok((phys, addr, hostname)); diff --git a/dnp3/src/tcp/connector.rs b/dnp3/src/tcp/connector.rs index 0c28acc7..2f898712 100644 --- a/dnp3/src/tcp/connector.rs +++ b/dnp3/src/tcp/connector.rs @@ -1,4 +1,5 @@ use crate::app::{ConnectStrategy, ExponentialBackOff, RetryStrategy}; +use crate::link::EndpointAddress; use crate::tcp::{ConnectOptions, EndpointList}; use std::net::SocketAddr; use std::sync::Arc; @@ -45,12 +46,18 @@ impl From for Endpoint { } } +#[derive(Default, Copy, Clone, Debug)] +pub(crate) struct SessionSettings { + pub(crate) master_address: Option, +} + /// Information about the next connection attempt #[derive(Clone, Debug)] pub struct ConnectionInfo { pub(crate) endpoint: Endpoint, pub(crate) timeout: Option, pub(crate) local_endpoint: Option, + pub(crate) settings: SessionSettings, } impl ConnectionInfo { @@ -61,6 +68,7 @@ impl ConnectionInfo { endpoint, timeout: None, local_endpoint: None, + settings: Default::default(), } } @@ -75,6 +83,11 @@ impl ConnectionInfo { pub fn set_local_endpoint(&mut self, local: SocketAddr) { self.local_endpoint = Some(local); } + + /// Use a different master address for this endpoint + pub fn set_master_address(&mut self, address: EndpointAddress) { + self.settings.master_address = Some(address); + } } /// Provides fine-grained control over how TCP and TLS clients connect to endpoints diff --git a/dnp3/src/transport/mock/reader.rs b/dnp3/src/transport/mock/reader.rs index f2c4c8ca..08e01267 100644 --- a/dnp3/src/transport/mock/reader.rs +++ b/dnp3/src/transport/mock/reader.rs @@ -54,17 +54,17 @@ impl MockReader { unimplemented!() } - pub(crate) fn peek(&self) -> Option { + pub(crate) fn peek(&self) -> Option> { Some(TransportData::Fragment(self.get(self.count)?)) } - pub(crate) fn pop(&mut self) -> Option { + pub(crate) fn pop(&mut self) -> Option> { let count = self.count; self.count = 0; Some(TransportData::Fragment(self.get(count)?)) } - fn get(&self, count: usize) -> Option { + fn get(&self, count: usize) -> Option> { if count == 0 { return None; } diff --git a/dnp3/src/util/session.rs b/dnp3/src/util/session.rs index d805cd08..8563ff9e 100644 --- a/dnp3/src/util/session.rs +++ b/dnp3/src/util/session.rs @@ -1,5 +1,6 @@ use crate::app::Shutdown; use crate::link::error::LinkError; +use crate::link::EndpointAddress; use crate::util::phys::PhysLayer; use std::time::Duration; @@ -98,6 +99,17 @@ impl Session { } } + pub(crate) fn change_master_address(&mut self, address: EndpointAddress) { + match &mut self.inner { + SessionType::Master(_) => { + // not supported, no code path to call it + } + SessionType::Outstation(x) => { + x.change_master_address(address); + } + } + } + pub(crate) fn enabled(&self) -> Enabled { match &self.inner { SessionType::Master(x) => x.enabled(), From 4faa133e713bcf39916d6d762b0d40180a845a3e Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 13 Nov 2025 08:23:19 -0800 Subject: [PATCH 02/11] fix enum default lints --- dnp3/src/app/attr.rs | 9 ++------- dnp3/src/decode.rs | 32 ++++++++------------------------ 2 files changed, 10 insertions(+), 31 deletions(-) diff --git a/dnp3/src/app/attr.rs b/dnp3/src/app/attr.rs index fac9ce09..e0801384 100644 --- a/dnp3/src/app/attr.rs +++ b/dnp3/src/app/attr.rs @@ -127,9 +127,10 @@ pub mod var { } /// Set to which a device attribute belongs -#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Default)] pub enum AttrSet { /// The default attribute set defined by DNP3.org + #[default] Default, /// Non-zero privately defined attribute set Private(u8), @@ -168,12 +169,6 @@ impl AttrSet { } } -impl Default for AttrSet { - fn default() -> Self { - Self::Default - } -} - /// Enum that represent default or private set attributes #[derive(Clone, Debug, PartialEq)] pub enum AnyAttribute<'a> { diff --git a/dnp3/src/decode.rs b/dnp3/src/decode.rs index cb441b68..102fe29e 100644 --- a/dnp3/src/decode.rs +++ b/dnp3/src/decode.rs @@ -25,8 +25,10 @@ pub struct DecodeLevel { feature = "serialization", derive(serde::Serialize, serde::Deserialize) )] +#[derive(Default)] pub enum AppDecodeLevel { /// Decode nothing + #[default] Nothing, /// Decode the header-only Header, @@ -36,20 +38,16 @@ pub enum AppDecodeLevel { ObjectValues, } -impl Default for AppDecodeLevel { - fn default() -> Self { - Self::Nothing - } -} - /// Controls how transmitted and received transport segments are decoded at the INFO log level #[derive(Copy, Clone, Debug, PartialEq, Eq)] #[cfg_attr( feature = "serialization", derive(serde::Serialize, serde::Deserialize) )] +#[derive(Default)] pub enum TransportDecodeLevel { /// Decode nothing + #[default] Nothing, /// Decode the header Header, @@ -57,20 +55,16 @@ pub enum TransportDecodeLevel { Payload, } -impl Default for TransportDecodeLevel { - fn default() -> Self { - Self::Nothing - } -} - /// Controls how transmitted and received link frames are decoded at the INFO log level #[derive(Copy, Clone, Debug, PartialEq, Eq)] #[cfg_attr( feature = "serialization", derive(serde::Serialize, serde::Deserialize) )] +#[derive(Default)] pub enum LinkDecodeLevel { /// Decode nothing + #[default] Nothing, /// Decode the header Header, @@ -78,20 +72,16 @@ pub enum LinkDecodeLevel { Payload, } -impl Default for LinkDecodeLevel { - fn default() -> Self { - Self::Nothing - } -} - /// Controls how data transmitted at the physical layer (TCP, serial, etc) is logged #[derive(Copy, Clone, Debug, PartialEq, Eq)] #[cfg_attr( feature = "serialization", derive(serde::Serialize, serde::Deserialize) )] +#[derive(Default)] pub enum PhysDecodeLevel { /// Log nothing + #[default] Nothing, /// Log only the length of data that is sent and received Length, @@ -99,12 +89,6 @@ pub enum PhysDecodeLevel { Data, } -impl Default for PhysDecodeLevel { - fn default() -> Self { - Self::Nothing - } -} - impl DecodeLevel { /// construct a `DecodeLevel` with nothing enabled pub fn nothing() -> Self { From c65792621968b294c29adf66dda328157cba91e4 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 13 Nov 2025 08:37:31 -0800 Subject: [PATCH 03/11] make the contract clear --- dnp3/src/tcp/connector.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/dnp3/src/tcp/connector.rs b/dnp3/src/tcp/connector.rs index 2f898712..2fe86eee 100644 --- a/dnp3/src/tcp/connector.rs +++ b/dnp3/src/tcp/connector.rs @@ -84,7 +84,14 @@ impl ConnectionInfo { self.local_endpoint = Some(local); } - /// Use a different master address for this endpoint + /// Change the master address for this and all subsequent connections. + /// + /// The master address change is persistent and remains active across reconnections + /// until explicitly changed again. This is useful when an outstation needs to communicate + /// with different masters at different endpoints, such as during failover scenarios or + /// when connecting to a backup master. + /// + /// By default, the outstation uses the master address specified in its configuration. pub fn set_master_address(&mut self, address: EndpointAddress) { self.settings.master_address = Some(address); } From 162d0b5c208fed0335a3fdd9e050b781747dbac9 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 13 Nov 2025 09:43:57 -0800 Subject: [PATCH 04/11] initial impl --- ffi/dnp3-ffi/src/client_connection_handler.rs | 246 +++++++++++++----- .../src/client_connection_handler.rs | 93 ++++++- 2 files changed, 267 insertions(+), 72 deletions(-) diff --git a/ffi/dnp3-ffi/src/client_connection_handler.rs b/ffi/dnp3-ffi/src/client_connection_handler.rs index 0743f26a..301ba008 100644 --- a/ffi/dnp3-ffi/src/client_connection_handler.rs +++ b/ffi/dnp3-ffi/src/client_connection_handler.rs @@ -1,19 +1,183 @@ use crate::ffi::ParamError; +use dnp3::link::EndpointAddress; use std::ffi::{CStr, CString}; use std::net::SocketAddr; use std::str::FromStr; use std::time::Duration; +pub struct ConnectionInfo { + endpoint: Option, + timeout: Option, + local_endpoint: Option, + master_address: Option, +} + +impl ConnectionInfo { + pub(crate) fn new() -> Self { + Self { + endpoint: None, + timeout: None, + local_endpoint: None, + master_address: None, + } + } + + pub(crate) fn set_endpoint(&mut self, endpoint: String) -> ParamError { + if endpoint.is_empty() { + tracing::warn!("set_endpoint called with empty endpoint string"); + return ParamError::InvalidSocketAddress; + } + self.endpoint = Some(dnp3::tcp::Endpoint::from(endpoint)); + ParamError::Ok + } + + pub(crate) fn set_timeout(&mut self, timeout: Duration) { + if !timeout.is_zero() { + self.timeout = Some(timeout); + } + } + + pub(crate) fn set_local_endpoint(&mut self, local: SocketAddr) { + self.local_endpoint = Some(local); + } + + pub(crate) fn set_master_address(&mut self, address: u16) -> ParamError { + match EndpointAddress::try_new(address) { + Ok(addr) => { + self.master_address = Some(addr); + ParamError::Ok + } + Err(_) => { + tracing::warn!( + "set_master_address called with invalid address: {}", + address + ); + ParamError::InvalidDnp3Address + } + } + } + + pub(crate) fn build(&self) -> Result { + let endpoint = self.endpoint.clone().ok_or_else(|| { + tracing::warn!("ConnectionInfo used without calling set_endpoint()"); + ParamError::InvalidSocketAddress + })?; + + let mut info = dnp3::tcp::ConnectionInfo::new(endpoint); + + if let Some(timeout) = self.timeout { + info.set_timeout(timeout); + } + if let Some(local) = self.local_endpoint { + info.set_local_endpoint(local); + } + if let Some(addr) = self.master_address { + info.set_master_address(addr); + } + + Ok(info) + } +} + +pub(crate) unsafe fn connection_info_create() -> *mut ConnectionInfo { + Box::into_raw(Box::new(ConnectionInfo::new())) +} + +pub(crate) unsafe fn connection_info_set_endpoint( + instance: *mut ConnectionInfo, + endpoint: &CStr, +) -> ParamError { + let info = match instance.as_mut() { + Some(x) => x, + None => { + tracing::warn!("connection_info_set_endpoint called with null ConnectionInfo instance"); + return ParamError::NullParameter; + } + }; + + let endpoint_str = match endpoint.to_str() { + Ok(s) => s.to_string(), + Err(_) => { + tracing::warn!( + "connection_info_set_endpoint called with invalid UTF-8 endpoint string" + ); + return ParamError::InvalidSocketAddress; + } + }; + + info.set_endpoint(endpoint_str) +} + +pub(crate) unsafe fn connection_info_set_timeout(instance: *mut ConnectionInfo, timeout: Duration) { + if let Some(info) = instance.as_mut() { + info.set_timeout(timeout); + } else { + tracing::warn!("connection_info_set_timeout called with null ConnectionInfo instance"); + } +} + +pub(crate) unsafe fn connection_info_set_local_endpoint( + instance: *mut ConnectionInfo, + local_endpoint: &CStr, +) -> ParamError { + let info = match instance.as_mut() { + Some(x) => x, + None => { + tracing::warn!( + "connection_info_set_local_endpoint called with null ConnectionInfo instance" + ); + return ParamError::NullParameter; + } + }; + + if local_endpoint.to_bytes().is_empty() { + return ParamError::Ok; // Empty string means don't set it + } + + let local_addr = match local_endpoint.to_str() { + Ok(s) => match SocketAddr::from_str(s) { + Ok(addr) => addr, + Err(_) => { + tracing::warn!( + "connection_info_set_local_endpoint called with invalid local endpoint address: {}", + s + ); + return ParamError::InvalidSocketAddress; + } + }, + Err(_) => { + tracing::warn!( + "connection_info_set_local_endpoint called with invalid UTF-8 local endpoint string" + ); + return ParamError::InvalidSocketAddress; + } + }; + + info.set_local_endpoint(local_addr); + ParamError::Ok +} + +pub(crate) unsafe fn connection_info_set_master_address( + instance: *mut ConnectionInfo, + address: u16, +) -> ParamError { + match instance.as_mut() { + Some(info) => info.set_master_address(address), + None => { + tracing::warn!( + "connection_info_set_master_address called with null ConnectionInfo instance" + ); + ParamError::NullParameter + } + } +} + pub struct NextEndpointAction { pub(crate) action: Option, } pub(crate) enum NextEndpointActionType { - ConnectTo { - endpoint: String, - timeout: Option, - local_endpoint: Option, - }, + ConnectTo(dnp3::tcp::ConnectionInfo), SleepFor(Duration), } @@ -25,9 +189,7 @@ impl NextEndpointAction { pub(crate) unsafe fn next_endpoint_action_connect_to( instance: *mut NextEndpointAction, - endpoint: &CStr, - timeout_ms: Duration, - local_endpoint: &CStr, + info: *const ConnectionInfo, ) -> ParamError { let action = match instance.as_mut() { Some(x) => x, @@ -39,53 +201,23 @@ pub(crate) unsafe fn next_endpoint_action_connect_to( } }; - let endpoint_str = match endpoint.to_str() { - Ok(s) => { - if s.is_empty() { - tracing::warn!("next_endpoint_action_connect_to called with empty endpoint string"); - return ParamError::InvalidSocketAddress; - } - s.to_string() - } - Err(_) => { + let connection_info = match info.as_ref() { + Some(x) => x, + None => { tracing::warn!( - "next_endpoint_action_connect_to called with invalid UTF-8 endpoint string" + "next_endpoint_action_connect_to called with null ConnectionInfo instance" ); - return ParamError::InvalidSocketAddress; + return ParamError::NullParameter; } }; - let timeout = if timeout_ms.is_zero() { - None - } else { - Some(timeout_ms) - }; - - let local_addr = if local_endpoint.to_bytes().is_empty() { - None - } else { - match local_endpoint.to_str() { - Ok(s) => match SocketAddr::from_str(s) { - Ok(addr) => Some(addr), - Err(_) => { - tracing::warn!("next_endpoint_action_connect_to called with invalid local endpoint address: {}", s); - return ParamError::InvalidSocketAddress; - } - }, - Err(_) => { - tracing::warn!("next_endpoint_action_connect_to called with invalid UTF-8 local endpoint string"); - return ParamError::InvalidSocketAddress; - } + match connection_info.build() { + Ok(info) => { + action.action = Some(NextEndpointActionType::ConnectTo(info)); + ParamError::Ok } - }; - - action.action = Some(NextEndpointActionType::ConnectTo { - endpoint: endpoint_str, - timeout, - local_endpoint: local_addr, - }); - - ParamError::Ok + Err(err) => err, + } } pub(crate) unsafe fn next_endpoint_action_sleep_for( @@ -145,21 +277,7 @@ impl dnp3::tcp::ClientConnectionHandler for ClientConnectionHandlerAdapter { self.callback.next(&mut action); match action.action { - Some(NextEndpointActionType::ConnectTo { - endpoint, - timeout, - local_endpoint, - }) => { - let endpoint = dnp3::tcp::Endpoint::from(endpoint); - let mut info = dnp3::tcp::ConnectionInfo::new(endpoint); - if let Some(timeout) = timeout { - info.set_timeout(timeout); - } - if let Some(local) = local_endpoint { - info.set_local_endpoint(local); - } - Ok(info) - } + Some(NextEndpointActionType::ConnectTo(info)) => Ok(info), Some(NextEndpointActionType::SleepFor(duration)) => Err(duration), None => Err(Duration::from_secs(5)), // fallback if no action was set } diff --git a/ffi/dnp3-schema/src/client_connection_handler.rs b/ffi/dnp3-schema/src/client_connection_handler.rs index 74a97519..06dbb6a6 100644 --- a/ffi/dnp3-schema/src/client_connection_handler.rs +++ b/ffi/dnp3-schema/src/client_connection_handler.rs @@ -1,13 +1,15 @@ use oo_bindgen::model::{ - AsynchronousInterface, BackTraced, DurationType, LibraryBuilder, StringType, + doc, AsynchronousInterface, BackTraced, DurationType, LibraryBuilder, Primitive, StringType, }; pub(crate) fn define( lib: &mut LibraryBuilder, shared: &crate::shared::SharedDefinitions, ) -> BackTraced { - // Define the NextEndpointAction abstract class first - let next_endpoint_action = define_next_endpoint_action(lib, shared)?; + // Define the ConnectionInfo class first + let connection_info = define_connection_info(lib, shared)?; + // Define the NextEndpointAction abstract class + let next_endpoint_action = define_next_endpoint_action(lib, shared, &connection_info)?; let handler = lib .define_interface( @@ -60,28 +62,103 @@ pub(crate) fn define( Ok(handler) } -fn define_next_endpoint_action( +fn define_connection_info( lib: &mut LibraryBuilder, shared: &crate::shared::SharedDefinitions, ) -> BackTraced { - let next_endpoint_action = lib.declare_class("next_endpoint_action")?; + let connection_info = lib.declare_class("connection_info")?; - let connect_to = lib - .define_method("connect_to", next_endpoint_action.clone())? + let constructor = lib + .define_constructor(connection_info.clone())? + .doc("Create a new ConnectionInfo. You must call set_endpoint() before using it.")? + .build()?; + + let set_endpoint = lib + .define_method("set_endpoint", connection_info.clone())? .param( "endpoint", StringType, "Endpoint to connect to (hostname or IP:port)", )? + .returns( + shared.error_type.clone_enum(), + "Error status - Ok if successful", + )? + .doc(doc("Set the endpoint to connect to").details( + "This method must be called before passing the ConnectionInfo to connect_to()", + ))? + .build()?; + + let set_timeout = lib + .define_method("set_timeout", connection_info.clone())? .param( "timeout_ms", DurationType::Milliseconds, "Connection timeout (0 means use OS default)", )? + .doc(doc("Set the connection timeout").details( + "Optional: If not called, the operating system's default timeout will be used", + ))? + .build()?; + + let set_local_endpoint = lib + .define_method("set_local_endpoint", connection_info.clone())? .param( "local_endpoint", StringType, - "Local address to bind to (empty string means any)", + "Local address to bind to", + )? + .returns( + shared.error_type.clone_enum(), + "Error status - Ok if successful", + )? + .doc( + doc("Set the local endpoint to bind to") + .details("Optional: If not called, any available network adapter will be used with an OS-assigned port. Pass an empty string to explicitly use the default behavior.") + )? + .build()?; + + let set_master_address = lib + .define_method("set_master_address", connection_info.clone())? + .param("address", Primitive::U16, "Master link-layer address")? + .returns( + shared.error_type.clone_enum(), + "Error status - Ok if successful", + )? + .doc( + doc("Change the master address for this and all subsequent connections") + .details("Optional: The master address change is persistent and remains active across reconnections until explicitly changed again.") + .details("This is useful when an outstation needs to communicate with different masters at different endpoints, such as during failover scenarios or when connecting to a backup master.") + .details("If not called, the outstation uses the master address specified in its configuration.") + )? + .build()?; + + let connection_info = lib + .define_class(&connection_info)? + .constructor(constructor)? + .method(set_endpoint)? + .method(set_timeout)? + .method(set_local_endpoint)? + .method(set_master_address)? + .doc("Builder for configuring connection parameters")? + .build()?; + + Ok(connection_info) +} + +fn define_next_endpoint_action( + lib: &mut LibraryBuilder, + shared: &crate::shared::SharedDefinitions, + connection_info: &oo_bindgen::model::ClassHandle, +) -> BackTraced { + let next_endpoint_action = lib.declare_class("next_endpoint_action")?; + + let connect_to = lib + .define_method("connect_to", next_endpoint_action.clone())? + .param( + "info", + connection_info.declaration(), + "Connection information including endpoint and optional parameters", )? .returns( shared.error_type.clone_enum(), From de9d96104fe9b39d5ba66b9f662e111ee76b2bd1 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 13 Nov 2025 09:56:45 -0800 Subject: [PATCH 05/11] improve docs --- dnp3/src/tcp/connector.rs | 19 +++++++++++++++++-- .../src/client_connection_handler.rs | 5 ++++- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/dnp3/src/tcp/connector.rs b/dnp3/src/tcp/connector.rs index 2fe86eee..9465f22f 100644 --- a/dnp3/src/tcp/connector.rs +++ b/dnp3/src/tcp/connector.rs @@ -78,8 +78,23 @@ impl ConnectionInfo { self.timeout = Some(timeout); } - /// Set the local address to which the socket is bound. If not specified, then any available - /// adapter may be used with an OS-assigned port. + /// Set the local address to which the socket is bound. + /// + /// If not specified, the OS will select the network adapter based on the routing table + /// for the destination address, and assign an ephemeral port. + /// + /// # Use Cases + /// + /// This is primarily useful for enforcing network segmentation in multi-homed systems, + /// such as OT gateways that bridge device and enterprise networks. By explicitly binding + /// to a specific adapter, you ensure traffic goes out the correct interface regardless + /// of routing table configuration, providing defense against misconfiguration. + /// + /// # Port Selection + /// + /// Typically you should specify port 0 to let the OS assign an ephemeral port while + /// forcing a specific network adapter. Using a specific non-zero port is rarely needed + /// for client connections and may cause bind failures if the port is already in use. pub fn set_local_endpoint(&mut self, local: SocketAddr) { self.local_endpoint = Some(local); } diff --git a/ffi/dnp3-schema/src/client_connection_handler.rs b/ffi/dnp3-schema/src/client_connection_handler.rs index 06dbb6a6..0e1e25d3 100644 --- a/ffi/dnp3-schema/src/client_connection_handler.rs +++ b/ffi/dnp3-schema/src/client_connection_handler.rs @@ -114,7 +114,10 @@ fn define_connection_info( )? .doc( doc("Set the local endpoint to bind to") - .details("Optional: If not called, any available network adapter will be used with an OS-assigned port. Pass an empty string to explicitly use the default behavior.") + .details("Optional: If not called, the OS will select the network adapter based on the routing table for the destination address, and assign an ephemeral port.") + .details("This is primarily useful for enforcing network segmentation in multi-homed systems, such as OT gateways that bridge device and enterprise networks. By explicitly binding to a specific adapter, you ensure traffic goes out the correct interface regardless of routing table configuration.") + .details("Typically you should specify port 0 to let the OS assign an ephemeral port while forcing a specific network adapter. Using a specific non-zero port is rarely needed for client connections and may cause bind failures if the port is already in use.") + .details("Pass an empty string to explicitly use the default OS behavior.") )? .build()?; From 19e5479c49a7b168b9ab702a1132c8927bc33c5f Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 13 Nov 2025 10:11:12 -0800 Subject: [PATCH 06/11] Add destructor and remove sentinel values from docs --- ffi/dnp3-ffi/src/client_connection_handler.rs | 14 +++++++------- ffi/dnp3-schema/src/client_connection_handler.rs | 7 +++++-- 2 files changed, 12 insertions(+), 9 deletions(-) diff --git a/ffi/dnp3-ffi/src/client_connection_handler.rs b/ffi/dnp3-ffi/src/client_connection_handler.rs index 301ba008..de83aa8e 100644 --- a/ffi/dnp3-ffi/src/client_connection_handler.rs +++ b/ffi/dnp3-ffi/src/client_connection_handler.rs @@ -32,9 +32,7 @@ impl ConnectionInfo { } pub(crate) fn set_timeout(&mut self, timeout: Duration) { - if !timeout.is_zero() { - self.timeout = Some(timeout); - } + self.timeout = Some(timeout); } pub(crate) fn set_local_endpoint(&mut self, local: SocketAddr) { @@ -83,6 +81,12 @@ pub(crate) unsafe fn connection_info_create() -> *mut ConnectionInfo { Box::into_raw(Box::new(ConnectionInfo::new())) } +pub(crate) unsafe fn connection_info_destroy(instance: *mut ConnectionInfo) { + if !instance.is_null() { + drop(Box::from_raw(instance)); + } +} + pub(crate) unsafe fn connection_info_set_endpoint( instance: *mut ConnectionInfo, endpoint: &CStr, @@ -130,10 +134,6 @@ pub(crate) unsafe fn connection_info_set_local_endpoint( } }; - if local_endpoint.to_bytes().is_empty() { - return ParamError::Ok; // Empty string means don't set it - } - let local_addr = match local_endpoint.to_str() { Ok(s) => match SocketAddr::from_str(s) { Ok(addr) => addr, diff --git a/ffi/dnp3-schema/src/client_connection_handler.rs b/ffi/dnp3-schema/src/client_connection_handler.rs index 0e1e25d3..8657218a 100644 --- a/ffi/dnp3-schema/src/client_connection_handler.rs +++ b/ffi/dnp3-schema/src/client_connection_handler.rs @@ -94,7 +94,7 @@ fn define_connection_info( .param( "timeout_ms", DurationType::Milliseconds, - "Connection timeout (0 means use OS default)", + "Connection timeout", )? .doc(doc("Set the connection timeout").details( "Optional: If not called, the operating system's default timeout will be used", @@ -117,7 +117,6 @@ fn define_connection_info( .details("Optional: If not called, the OS will select the network adapter based on the routing table for the destination address, and assign an ephemeral port.") .details("This is primarily useful for enforcing network segmentation in multi-homed systems, such as OT gateways that bridge device and enterprise networks. By explicitly binding to a specific adapter, you ensure traffic goes out the correct interface regardless of routing table configuration.") .details("Typically you should specify port 0 to let the OS assign an ephemeral port while forcing a specific network adapter. Using a specific non-zero port is rarely needed for client connections and may cause bind failures if the port is already in use.") - .details("Pass an empty string to explicitly use the default OS behavior.") )? .build()?; @@ -136,9 +135,13 @@ fn define_connection_info( )? .build()?; + let destructor = + lib.define_destructor(connection_info.clone(), "Destroy a ConnectionInfo instance")?; + let connection_info = lib .define_class(&connection_info)? .constructor(constructor)? + .destructor(destructor)? .method(set_endpoint)? .method(set_timeout)? .method(set_local_endpoint)? From 4962236c5723d624c251b48c5a0d3dffe30f22f6 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 13 Nov 2025 11:06:49 -0800 Subject: [PATCH 07/11] add ability to clear optional values for reuse. update C++ example --- ffi/bindings/c/outstation_example.cpp | 11 ++++++-- ffi/dnp3-ffi/src/client_connection_handler.rs | 26 +++++++++++++++++++ .../src/client_connection_handler.rs | 19 +++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/ffi/bindings/c/outstation_example.cpp b/ffi/bindings/c/outstation_example.cpp index 2d6609b4..56d78c9c 100644 --- a/ffi/bindings/c/outstation_example.cpp +++ b/ffi/bindings/c/outstation_example.cpp @@ -384,8 +384,15 @@ class MyConnectionHandler : public ClientConnectionHandler { std::string endpoint = endpoints[current_endpoint]; std::cout << "ConnectionManager: Attempting to connect to " << endpoint << " (endpoint " << (current_endpoint + 1) << " of " << endpoints.size() << ")" << std::endl; - // Connect with 10 second timeout - action.connect_to(endpoint, std::chrono::milliseconds(10000), ""); + // Connect with 10 second timeout using the ConnectionInfo builder + ConnectionInfo info; + if (info.set_endpoint(endpoint) != ParamError::ok) { + std::cout << "ConnectionManager: Failed to set endpoint: " << endpoint << std::endl; + action.sleep_for(std::chrono::seconds(5)); + return; + } + info.set_timeout(std::chrono::milliseconds(10000)); + action.connect_to(info); // Cycle through endpoints for round-robin behavior current_endpoint = (current_endpoint + 1) % endpoints.size(); diff --git a/ffi/dnp3-ffi/src/client_connection_handler.rs b/ffi/dnp3-ffi/src/client_connection_handler.rs index de83aa8e..8eb6eaec 100644 --- a/ffi/dnp3-ffi/src/client_connection_handler.rs +++ b/ffi/dnp3-ffi/src/client_connection_handler.rs @@ -35,10 +35,18 @@ impl ConnectionInfo { self.timeout = Some(timeout); } + pub(crate) fn clear_timeout(&mut self) { + self.timeout = None; + } + pub(crate) fn set_local_endpoint(&mut self, local: SocketAddr) { self.local_endpoint = Some(local); } + pub(crate) fn clear_local_endpoint(&mut self) { + self.local_endpoint = None; + } + pub(crate) fn set_master_address(&mut self, address: u16) -> ParamError { match EndpointAddress::try_new(address) { Ok(addr) => { @@ -120,6 +128,14 @@ pub(crate) unsafe fn connection_info_set_timeout(instance: *mut ConnectionInfo, } } +pub(crate) unsafe fn connection_info_clear_timeout(instance: *mut ConnectionInfo) { + if let Some(info) = instance.as_mut() { + info.clear_timeout(); + } else { + tracing::warn!("connection_info_clear_timeout called with null ConnectionInfo instance"); + } +} + pub(crate) unsafe fn connection_info_set_local_endpoint( instance: *mut ConnectionInfo, local_endpoint: &CStr, @@ -157,6 +173,16 @@ pub(crate) unsafe fn connection_info_set_local_endpoint( ParamError::Ok } +pub(crate) unsafe fn connection_info_clear_local_endpoint(instance: *mut ConnectionInfo) { + if let Some(info) = instance.as_mut() { + info.clear_local_endpoint(); + } else { + tracing::warn!( + "connection_info_clear_local_endpoint called with null ConnectionInfo instance" + ); + } +} + pub(crate) unsafe fn connection_info_set_master_address( instance: *mut ConnectionInfo, address: u16, diff --git a/ffi/dnp3-schema/src/client_connection_handler.rs b/ffi/dnp3-schema/src/client_connection_handler.rs index 8657218a..e15074de 100644 --- a/ffi/dnp3-schema/src/client_connection_handler.rs +++ b/ffi/dnp3-schema/src/client_connection_handler.rs @@ -101,6 +101,11 @@ fn define_connection_info( ))? .build()?; + let clear_timeout = lib + .define_method("clear_timeout", connection_info.clone())? + .doc("Clear any previously configured timeout so the OS default is used")? + .build()?; + let set_local_endpoint = lib .define_method("set_local_endpoint", connection_info.clone())? .param( @@ -120,6 +125,11 @@ fn define_connection_info( )? .build()?; + let clear_local_endpoint = lib + .define_method("clear_local_endpoint", connection_info.clone())? + .doc("Clear any previously configured local endpoint so the OS chooses the interface and port")? + .build()?; + let set_master_address = lib .define_method("set_master_address", connection_info.clone())? .param("address", Primitive::U16, "Master link-layer address")? @@ -144,9 +154,16 @@ fn define_connection_info( .destructor(destructor)? .method(set_endpoint)? .method(set_timeout)? + .method(clear_timeout)? .method(set_local_endpoint)? + .method(clear_local_endpoint)? .method(set_master_address)? - .doc("Builder for configuring connection parameters")? + .doc( + doc("Builder for configuring connection parameters") + .details("Instances are passed to {class:next_endpoint_action.connect_to()} inside {interface:client_connection_handler.next()}.") + .details("You may construct a new instance for each callback to {interface:client_connection_handler.next()} or reuse a single instance to reduce allocations.") + .details("When reusing, call {class:connection_info.clear_timeout()} or {class:connection_info.clear_local_endpoint()} before setting new values if you need to revert to the default timeout or adapter selection."), + )? .build()?; Ok(connection_info) From 1c8cc4c2cff6c58535871b8ce4a9f1297b749719 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 13 Nov 2025 11:15:51 -0800 Subject: [PATCH 08/11] demonstrate how you can change master addresses --- ffi/bindings/c/outstation_example.cpp | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/ffi/bindings/c/outstation_example.cpp b/ffi/bindings/c/outstation_example.cpp index 56d78c9c..e9c4eea4 100644 --- a/ffi/bindings/c/outstation_example.cpp +++ b/ffi/bindings/c/outstation_example.cpp @@ -360,16 +360,20 @@ void run_tcp_client(dnp3::Runtime &runtime) class MyConnectionHandler : public ClientConnectionHandler { private: - std::vector endpoints; + struct Target { + std::string endpoint; + uint16_t master_address; + }; + std::vector endpoints; size_t current_endpoint; int connection_attempts; bool cycle_completed; public: MyConnectionHandler() : current_endpoint(0), connection_attempts(0), cycle_completed(false) { - endpoints.push_back("127.0.0.1:20000"); - endpoints.push_back("127.0.0.1:20001"); - endpoints.push_back("127.0.0.1:20002"); + endpoints.push_back({"127.0.0.1:20000", 1}); + endpoints.push_back({"127.0.0.1:20001", 2}); + endpoints.push_back({"127.0.0.1:20002", 3}); } void next(NextEndpointAction& action) override { @@ -381,13 +385,19 @@ class MyConnectionHandler : public ClientConnectionHandler { return; } - std::string endpoint = endpoints[current_endpoint]; - std::cout << "ConnectionManager: Attempting to connect to " << endpoint << " (endpoint " + const auto target = endpoints[current_endpoint]; + std::cout << "ConnectionManager: Attempting to connect to " << target.endpoint << " (endpoint " << (current_endpoint + 1) << " of " << endpoints.size() << ")" << std::endl; // Connect with 10 second timeout using the ConnectionInfo builder ConnectionInfo info; - if (info.set_endpoint(endpoint) != ParamError::ok) { - std::cout << "ConnectionManager: Failed to set endpoint: " << endpoint << std::endl; + if (info.set_endpoint(target.endpoint) != ParamError::ok) { + std::cout << "ConnectionManager: Failed to set endpoint: " << target.endpoint << std::endl; + action.sleep_for(std::chrono::seconds(5)); + return; + } + // give each endpoint its own master address to demonstrate per-connection overrides + if (info.set_master_address(target.master_address) != ParamError::ok) { + std::cout << "ConnectionManager: Failed to set master address: " << target.master_address << std::endl; action.sleep_for(std::chrono::seconds(5)); return; } From 060b1406d676aa89a041f360e950ee7a045745b7 Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 13 Nov 2025 11:23:31 -0800 Subject: [PATCH 09/11] target .net 8.0 in the examples --- ffi/bindings/dotnet/examples/master/Master.csproj | 2 +- ffi/bindings/dotnet/examples/outstation/Outstation.csproj | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ffi/bindings/dotnet/examples/master/Master.csproj b/ffi/bindings/dotnet/examples/master/Master.csproj index 87213072..63a0314b 100644 --- a/ffi/bindings/dotnet/examples/master/Master.csproj +++ b/ffi/bindings/dotnet/examples/master/Master.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 diff --git a/ffi/bindings/dotnet/examples/outstation/Outstation.csproj b/ffi/bindings/dotnet/examples/outstation/Outstation.csproj index 57d24bf7..7013cb59 100644 --- a/ffi/bindings/dotnet/examples/outstation/Outstation.csproj +++ b/ffi/bindings/dotnet/examples/outstation/Outstation.csproj @@ -2,7 +2,7 @@ Exe - net6.0 + net8.0 From 24267187ef0e15424fc38dc635a6d8c7882e5c5d Mon Sep 17 00:00:00 2001 From: jadamcrain Date: Thu, 13 Nov 2025 11:37:01 -0800 Subject: [PATCH 10/11] check arguments before initializing anything --- ffi/bindings/c/master_example.c | 66 +++++++++++++-------------- ffi/bindings/c/master_example.cpp | 12 ++--- ffi/bindings/c/outstation_example.c | 20 ++++---- ffi/bindings/c/outstation_example.cpp | 16 +++---- 4 files changed, 56 insertions(+), 58 deletions(-) diff --git a/ffi/bindings/c/master_example.c b/ffi/bindings/c/master_example.c index ff046123..caa9baf9 100644 --- a/ffi/bindings/c/master_example.c +++ b/ffi/bindings/c/master_example.c @@ -651,37 +651,37 @@ dnp3_tls_client_config_t get_self_signed_tls_config() } // create a channel based on the command line arguments -dnp3_param_error_t create_and_run_channel(int argc, char *argv[], dnp3_runtime_t *runtime) -{ - if(argc != 2) { - printf("you must specify a transport type\n"); - printf("usage: master-example (tcp, serial, tls-ca, tls-self-signed)\n"); - return -1; - } - - if (strcmp(argv[1], "tcp") == 0) { - return run_tcp_channel(runtime); - } - else if (strcmp(argv[1], "serial") == 0) { - return run_serial_channel(runtime); - } - else if (strcmp(argv[1], "tls-ca") == 0) { - return run_tls_channel(runtime, get_ca_tls_config()); - } - else if (strcmp(argv[1], "tls-self-signed") == 0) { - return run_tls_channel(runtime, get_self_signed_tls_config()); - } - else { - printf("unknown channel type: %s\n", argv[1]); - return -1; - } -} - -int main(int argc, char *argv[]) -{ - // ANCHOR: logging_init - // initialize logging with the default configuration - dnp3_configure_logging(dnp3_logging_config_init(), get_logger()); +dnp3_param_error_t create_and_run_channel(const char* type, dnp3_runtime_t *runtime) +{ + if (strcmp(type, "tcp") == 0) { + return run_tcp_channel(runtime); + } + else if (strcmp(type, "serial") == 0) { + return run_serial_channel(runtime); + } + else if (strcmp(type, "tls-ca") == 0) { + return run_tls_channel(runtime, get_ca_tls_config()); + } + else if (strcmp(type, "tls-self-signed") == 0) { + return run_tls_channel(runtime, get_self_signed_tls_config()); + } + else { + printf("unknown channel type: %s\n", type); + return -1; + } +} + +int main(int argc, char *argv[]) +{ + if(argc != 2) { + printf("you must specify a transport type\n"); + printf("usage: master-example (tcp, serial, tls-ca, tls-self-signed)\n"); + return -1; + } + + // ANCHOR: logging_init + // initialize logging with the default configuration + dnp3_configure_logging(dnp3_logging_config_init(), get_logger()); // ANCHOR_END: logging_init // ANCHOR: runtime_create @@ -696,8 +696,8 @@ int main(int argc, char *argv[]) return -1; } - // create a channel based on the cmd line arguments and run it - int res = create_and_run_channel(argc, argv, runtime); + // create a channel based on the cmd line arguments and run it + int res = create_and_run_channel(argv[1], runtime); // ANCHOR: runtime_destroy dnp3_runtime_destroy(runtime); diff --git a/ffi/bindings/c/master_example.cpp b/ffi/bindings/c/master_example.cpp index 2b76200e..a6ecb026 100644 --- a/ffi/bindings/c/master_example.cpp +++ b/ffi/bindings/c/master_example.cpp @@ -573,6 +573,12 @@ dnp3::TlsClientConfig get_self_signed_tls_config() int main(int argc, char *argv[]) { + if (argc != 2) { + std::cout << "you must specify a transport type" << std::endl; + std::cout << "usage: cpp-master-example (tcp, serial, tls-ca, tls-self-signed)" << std::endl; + return -1; + } + // ANCHOR: logging_init dnp3::Logging::configure(dnp3::LoggingConfig(), std::make_unique()); // ANCHOR_END: logging_init @@ -581,12 +587,6 @@ int main(int argc, char *argv[]) auto runtime = dnp3::Runtime(dnp3::RuntimeConfig()); // ANCHOR_END: runtime_create - if (argc != 2) { - std::cout << "you must specify a transport type" << std::endl; - std::cout << "usage: cpp-outstation-example (tcp, serial, tls-ca, tls-self-signed)" << std::endl; - return -1; - } - const auto type = argv[1]; if (strcmp(type, "tcp") == 0) { diff --git a/ffi/bindings/c/outstation_example.c b/ffi/bindings/c/outstation_example.c index b203179d..3791ed33 100644 --- a/ffi/bindings/c/outstation_example.c +++ b/ffi/bindings/c/outstation_example.c @@ -669,16 +669,8 @@ int run_tls_server(dnp3_runtime_t *runtime, dnp3_tls_server_config_t config) return ret; } -int run_transport(int argc, char *argv[], dnp3_runtime_t* runtime) +int run_transport(const char* type, dnp3_runtime_t* runtime) { - if (argc != 2) { - printf("you must specify a transport type\n"); - printf("usage: outstation-example (tcp, serial, tls-ca, tls-self-signed)\n"); - return -1; - } - - const char* type = argv[1]; - if (strcmp(type, "tcp") == 0) { return run_tcp_server(runtime); } @@ -692,13 +684,19 @@ int run_transport(int argc, char *argv[], dnp3_runtime_t* runtime) return run_tls_server(runtime, get_tls_self_signed_config()); } else { - printf("unknown channel type: %s\n", argv[1]); + printf("unknown channel type: %s\n", type); return -1; } } int main(int argc, char *argv[]) { + if (argc != 2) { + printf("you must specify a transport type\n"); + printf("usage: outstation-example (tcp, serial, tls-ca, tls-self-signed)\n"); + return -1; + } + // initialize logging with the default configuration dnp3_configure_logging(dnp3_logging_config_init(), get_logger()); @@ -716,7 +714,7 @@ int main(int argc, char *argv[]) } // use the command line arguments to run a specific transport type - int ret = run_transport(argc, argv, runtime); + int ret = run_transport(argv[1], runtime); // cleanup the runtime before exit dnp3_runtime_destroy(runtime); diff --git a/ffi/bindings/c/outstation_example.cpp b/ffi/bindings/c/outstation_example.cpp index e9c4eea4..4a850132 100644 --- a/ffi/bindings/c/outstation_example.cpp +++ b/ffi/bindings/c/outstation_example.cpp @@ -547,20 +547,20 @@ dnp3::TlsServerConfig get_tls_self_signed_config() int main(int argc, char *argv[]) { - Logging::configure(LoggingConfig(), logger( - [](LogLevel level, std::string message) { - std::cout << message; - } - )); - - auto runtime = Runtime(RuntimeConfig()); - if (argc != 2) { std::cout << "you must specify a transport type" << std::endl; std::cout << "usage: cpp-outstation-example (tcp, tcp-client, tcp-client-manager, serial, udp, tls-ca, tls-self-signed)" << std::endl; return -1; } + Logging::configure(LoggingConfig(), logger( + [](LogLevel level, std::string message) { + std::cout << message; + } + )); + + auto runtime = Runtime(RuntimeConfig()); + const auto type = argv[1]; if (strcmp(type, "tcp") == 0) { From 6f0e9545a97ca797d66196e00e6bdd3efc4c3c5f Mon Sep 17 00:00:00 2001 From: Adam Crain Date: Thu, 13 Nov 2025 11:48:33 -0800 Subject: [PATCH 11/11] Update dnp3/src/util/session.rs Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- dnp3/src/util/session.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dnp3/src/util/session.rs b/dnp3/src/util/session.rs index 8563ff9e..f024e0e6 100644 --- a/dnp3/src/util/session.rs +++ b/dnp3/src/util/session.rs @@ -102,7 +102,7 @@ impl Session { pub(crate) fn change_master_address(&mut self, address: EndpointAddress) { match &mut self.inner { SessionType::Master(_) => { - // not supported, no code path to call it + tracing::warn!("Attempted to change master address on a master session. This feature is only supported for outstation clients."); } SessionType::Outstation(x) => { x.change_master_address(address);