Skip to content

Send UDP multicast on the network interface the transport joins - #4711

Merged
marcschier merged 3 commits into
masterfrom
fix/pubsub-multicast-send-interface
Oct 9, 2026
Merged

marcschier merged 3 commits into
masterfrom
fix/pubsub-multicast-send-interface

Conversation

@marcschier

Copy link
Copy Markdown
Collaborator

Description

UdpDatagramTransport joined multicast groups on the resolved network interface: the connection address's NetworkInterface, the PreferredNetworkInterface option, or the first operational interface. It never set IP_MULTICAST_IF / IPV6_MULTICAST_IF, though, so multicast data and discovery announcements left through the operating system's default multicast route. On a computer with several network interfaces, a publisher configured for one network sent on another, and subscribers on the configured network missed the messages (OPC 10000-14 §7.3.2.2, NetworkAddressDataType.NetworkInterface).

ConfigureSocket now sets the multicast send interface to the resolved interface for IPv4 and IPv6 sockets. That includes the separate socket that sends discovery announcements for loopback-bound connections. A failure is logged at Debug level, like the other socket options.

Behavior change: without a configured interface, multicast now leaves through the first operational non-loopback interface, which is the interface subscribers join on, instead of the default multicast route. docs/PubSub.md and the XML docs of PreferredNetworkInterface and UdpNetworkInterfaceResolver describe this and recommend naming the interface on multi-homed computers.

Tests (UdpDatagramTransportMulticastInterfaceTests):

  • The IPv4 and IPv6 egress options equal the resolved interface. Before the fix they were 0.0.0.0 and 0.
  • A publisher and subscriber pinned to the loopback interface exchange a payload. A raw control sender tells a host without loopback multicast apart from a publisher that sends elsewhere. The test fails with the bug on Linux; Windows also delivers local copies sent on other interfaces, so there it only confirms the exchange.

Validation

Command Result
dotnet test tests/Opc.Ua.PubSub.Udp.Tests -f net10.0 -p:CustomTestTarget=net10.0 --filter "FullyQualifiedName~UdpDatagramTransportMulticastInterfaceTests", with the fix temporarily disabled IPv4 and IPv6 option tests failed (Expected: <192.168.178.101> But was: <0.0.0.0>, Expected: 4 But was: 0)
The same tests from the Windows-built binaries in a Linux container (mcr.microsoft.com/dotnet/sdk:10.0, dotnet test Opc.Ua.PubSub.Udp.Tests.dll), fix disabled, 5 runs 3 of 3 failed in every run, including the loopback exchange (the control sender arrived, the publisher's multicast did not)
Same, with the fix, 5 runs 3 of 3 passed in every run
dotnet test tests/Opc.Ua.PubSub.Udp.Tests -f net10.0 -p:CustomTestTarget=net10.0 237 passed (Windows, and the same binaries in the Linux container)
dotnet test tests/Opc.Ua.PubSub.Udp.Tests -f net48 -p:CustomTestTarget=net48 232 passed
dotnet test tests/Opc.Ua.PubSub.Tests -f net10.0 -p:CustomTestTarget=net10.0 1490 passed, 1 skipped
dotnet build src/Opc.Ua.PubSub.Udp -c Release (net10.0, net9.0, net8.0, net48) 0 warnings
The docs/PubSub.md first exchange (unicast loopback), run verbatim, with #4710 applied 11 DataSets; the unsecured-frame warning appears once, as documented

Related Issues

Checklist

  • I have signed the CLA and read the CONTRIBUTING doc.
  • I have added tests that prove my fix is effective or that my feature works and increased code coverage.
  • I have added all necessary documentation.
  • I have verified that my changes do not introduce (new) build or analyzer warnings.
  • I ran all tests locally using the UA.slnx solution against at least .net framework and .net 10, and all passed.
  • I fixed all failing and flaky tests in the CI pipelines and all CodeQL warnings.
  • I have addressed all PR feedback received.

UdpDatagramTransport joined multicast groups on the resolved network
interface (the connection address's NetworkInterface, the
PreferredNetworkInterface option, or the first operational interface), but
it never set IP_MULTICAST_IF / IPV6_MULTICAST_IF. Multicast data and
discovery announcements therefore left through the operating system's
default multicast route. On a computer with several network interfaces, a
publisher configured for one network sent on another, and subscribers on
the configured network missed the messages (OPC 10000-14 7.3.2.2,
NetworkAddressDataType.NetworkInterface).

ConfigureSocket now sets the multicast send interface to the resolved
interface for IPv4 and IPv6 sockets, including the separate socket that
sends discovery announcements for loopback-bound connections. A failure is
logged at Debug level, like the other socket options.

Behavior change: without a configured interface, multicast now leaves
through the first operational non-loopback interface, which is the
interface that subscribers join on, instead of the default multicast route.

Tests (UdpDatagramTransportMulticastInterfaceTests):
- The IPv4 and IPv6 egress options equal the resolved interface. Before the
  fix they were 0.0.0.0 and 0.
- A publisher and subscriber pinned to the loopback interface exchange a
  payload; a raw control sender distinguishes a host without loopback
  multicast from a publisher that sends elsewhere. Red on Linux before the
  fix. Windows also delivers local copies sent on other interfaces, so there
  the test only confirms the exchange.

Validation: Opc.Ua.PubSub.Udp.Tests net10.0 237/237 (Windows and a Linux
container), net48 232/232; Opc.Ua.PubSub.Tests net10.0 1490 passed,
1 skipped; Release build of Opc.Ua.PubSub.Udp for all target frameworks
with 0 warnings. The one-computer exchange in docs/PubSub.md delivers
DataSets with the documented warning once, verified together with the
separate sample KeyNonce fix that master currently needs.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@marcschier
marcschier marked this pull request as ready for review October 9, 2026 11:03
Copilot AI balanced review requested due to automatic review settings October 9, 2026 11:03
Comment thread src/Opc.Ua.PubSub.Udp/UdpDatagramTransport.cs Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

The implementation is focused and well tested; only minor IPv6 logging wording remains.

1 open finding
What changed in this PR

Pins UDP multicast egress to the resolved network interface, aligning publishers with subscriber group membership.

Changes:

  • Configures IPv4/IPv6 multicast send interfaces.
  • Adds multicast interface regression tests.
  • Updates API and PubSub documentation.
File Description
UdpDatagramTransportMulticastInterfaceTests.cs Tests egress options and loopback delivery.
UdpTransportOptions.cs Documents interface behavior.
UdpNetworkInterfaceResolver.cs Clarifies resolver semantics.
UdpDatagramTransport.cs Configures multicast egress and logging.
docs/​PubSub.md Updates multicast guidance.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Opc.Ua.PubSub.Udp/UdpDatagramTransport.cs Outdated
@github-actions

github-actions Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

Code coverage

✅ Coverage gate passed.

Check Result Threshold
✅ Project line rate 88.60% (306390/345826 lines) >= 70.00%
✅ Project branch rate 79.03% >= 60.00%
✅ Patch coverage 86.96% (20/23 changed lines) >= 60.00% (<= 100 changed lines, advisory)
ℹ️ Baseline delta (advisory) +15.00 pp 73.60% recorded
Uncovered changed lines
  • src/Opc.Ua.PubSub.Udp/UdpDatagramTransport.cs: 928, 930, 931

Coverage is above the recorded baseline - consider ratcheting coverage-thresholds.json.

Thresholds live in coverage-thresholds.json. Whole report before exclusions: line 87.73%, branch 78.24%.

Review feedback:
- UdpDatagramTransport.SocketForTest is now InnerSocket.
- The message logged when setting the multicast send interface fails no
  longer names IP_MULTICAST_IF, because the same handler reports IPv6
  (IPV6_MULTICAST_IF) failures.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@codecov

codecov Bot commented Oct 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 86.95652% with 3 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.83%. Comparing base (28f1401) to head (adbe02a).
⚠️ Report is 2 commits behind head on master.

Files with missing lines Patch % Lines
src/Opc.Ua.PubSub.Udp/UdpDatagramTransport.cs 86.95% 3 Missing ⚠️
Additional details and impacted files

Impacted file tree graph

@@           Coverage Diff           @@
##           master    #4711   +/-   ##
=======================================
  Coverage   82.83%   82.83%           
=======================================
  Files        2267     2267           
  Lines      345803   345826   +23     
  Branches    60271    60275    +4     
=======================================
+ Hits       286437   286460   +23     
+ Misses      39272    39267    -5     
- Partials    20094    20099    +5     
Flag Coverage Δ
actions 82.83% <86.95%> (+<0.01%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...c/Opc.Ua.PubSub.Udp/UdpNetworkInterfaceResolver.cs 90.19% <ø> (ø)
src/Opc.Ua.PubSub.Udp/UdpTransportOptions.cs 100.00% <ø> (ø)
src/Opc.Ua.PubSub.Udp/UdpDatagramTransport.cs 77.40% <86.95%> (+2.95%) ⬆️

... and 31 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@marcschier
marcschier merged commit aeeef5b into master Oct 9, 2026
111 checks passed
marcschier added a commit that referenced this pull request Oct 9, 2026
…4712)

# Description

`MetaDataPublisher` encoded the DataSetMetaData announcement itself and
called `IPubSubTransport.SendAsync` directly. That bypassed the
connection's security wrapper and its fail-closed check. A WriterGroup
configured for `SignAndEncrypt` therefore announced its DataSet layout
(field names, types, version) unsigned and unencrypted. A secured
subscriber dropped the announcement (`Dropping unsecured inbound frame
... requiring SignAndEncrypt`, documented as an expected warning in
docs/PubSub.md), and any listener could read it. OPC 10000-14 §6.2.5.2
applies the WriterGroup's SecurityMode to the NetworkMessages it
publishes, and §7.2.4.6.3 carries the security of a discovery
announcement in its SecurityHeader.

Confirmed on the wire: the documented `udp-uadp` exchange sent secured
DataSetMessages plus one DiscoveryResponse (announcement type 2,
DataSetMetaData) with ExtendedFlags1 `0x81`, meaning the SecurityHeader
bit was clear.

Review feedback widened the scope to every UADP discovery message of a
secured connection, and to the diagnostics of secured messages.

Fix:

- `MetaDataPublisher` builds the announcement and hands it to the
connection (`PubSubConnection.SendMetaDataAnnouncementAsync`).
- One encode-and-secure step (`PubSubConnection.EncodeOutboundAsync`)
now serves data messages, discovery requests and responses, and the
announcements that UDP and Ethernet send to their transport-specific
discovery destination (ApplicationInformation, PublisherEndpoints,
PubSubConnection). On a connection configured for message security, it
applies the SecurityMode (`Sign`, or `SignAndEncrypt`) to UADP messages
and refuses to send any message it cannot secure.
- Before, a secured connection dropped its own discovery requests, so a
secured subscriber could not ask for metadata, writer configuration, or
endpoints. It also refused metadata and writer-configuration probe
responses, and sent the transport-specific announcements in plaintext.
- A discovery response or announcement that cannot be secured or sent is
logged (`DiscoveryMessageNotSent`, Warning) and dropped. Securing
announcements makes them depend on a current key; without this, a key
provider with no key yet, for example before a Security Key Service
delivers the first key, made `StartAsync` fail, and a probe response
that could not be secured ended the receive loop. Requests sent through
`RequestDiscoveryAsync` still throw to the caller.
- Secured messages are counted in `SentNetworkMessages` and
`SentDataSetMessages` like unsecured ones. Before, they bypassed the
encoder that counts them, so secured data messages were never counted.
- `UadpDiscoveryCoder` can encode a discovery message with the
SecurityHeader bit set, and reports the security boundary after the
PublisherId (and DataSetClassId), which is where
`UadpDecoder.TryReadOuterPrefix` ends the prefix.
`UadpEncoder.EncodeWithSecurityBoundary` accepts discovery messages.
- The receive path is unchanged: it already unwraps secured discovery
frames and registers authenticated metadata.
- Not changed: the MQTT last-will status message is registered with the
broker when the connection opens and published later, so it cannot carry
a current SecurityHeader; it stays unsecured.
- docs/PubSub.md no longer lists the unsecured-frame warning. It
describes how the SecurityMode applies to announcements and discovery
(`Sign` authenticates; `SignAndEncrypt` also encrypts) and the last-will
exception.

Tests:

-
`MetaDataPublisherTests.UadpPathSecuresTheAnnouncementOfASecuredWriterGroupAsync`:
the announcement of a SignAndEncrypt WriterGroup carries a
SecurityHeader, does not contain the DataSet name in cleartext, and
unwraps and decodes with the subscriber's keys. Before the fix, the
announcement had no SecurityHeader.
- `MetaDataPublisherTests.SecuredNetworkMessagesAreCountedAsSentAsync`:
before the fix, `SentNetworkMessages` read 0 on a secured connection.
-
`PubSubDiscoveryTests.SecuredSubscriberDiscoversSecuredPublisherWithoutPlaintextFramesAsync`:
a secured subscriber and a secured publisher, connected through an
in-memory transport that also sends announcements to a separate
destination, discover metadata, writer configuration, and endpoints, and
no frame lacks a SecurityHeader. Before the fix, the metadata request
was dropped. With only the announcement change reverted, the
PublisherEndpoints response was dropped.
-
`PubSubDiscoveryTests.StartupAnnouncementsWithoutACurrentKeyDoNotFailTheStartAsync`
and `ReceiveLoopSurvivesADiscoveryResponseThatCannotBeSecuredAsync`:
both failed before the fail-soft change.
-
`UadpDiscoveryTests.SecuredDiscoveryMessagesSplitWhereTheReceiverExpects`:
for requests and responses, with and without a DataSetClassId, the
secured encoding differs from the unsecured one only by the
SecurityHeader flag, and its boundary equals the receiver's prefix
length.
-
`UadpDiscoveryTests.EncodeWithSecurityBoundaryRejectsAMessageItCannotSecure`.

### Validation

| Command | Result |
| --- | --- |
| `dotnet test tests/Opc.Ua.PubSub.Tests -f net10.0
-p:CustomTestTarget=net10.0` | 1498 passed, 1 skipped (the UDP multicast
loopback discovery test, which this host skips) |
| `dotnet test tests/Opc.Ua.PubSub.Tests -f net48
-p:CustomTestTarget=net48` | 1498 passed, 1 skipped |
| `dotnet test` on net10.0 for `Opc.Ua.PubSub.Udp.Tests`, `.Eth.Tests`,
`.Mqtt.Tests`, `.Server.Tests`, `fuzzing/Opc.Ua.PubSub.Fuzz.Tests` |
234, 55, 157, 177, 149 passed |
| `dotnet test` on net10.0 for `Opc.Ua.PubSub.Kafka.Tests`,
`.Adapter.Tests`, `.Diagnostics.Tests`, `Opc.Ua.Redundancy.PubSub.Tests`
(on 9417ccb) | 92 (1 skipped), 152, 53 (1 skipped), 45 passed |
| `tests\Opc.Ua.Aot.Tests\bin\Debug\net10.0\Opc.Ua.Aot.Tests.exe
--treenode-filter "/*/*/PubSubAotTests/*"` (JIT; the AOT job failed on
94d4391) | 7 of 7 passed, including `StartsAndStopsPublisher_UdpUadp`
|
| `dotnet build src/Opc.Ua.PubSub -c Release` (all target frameworks) |
0 warnings |
| The docs/PubSub.md first exchange, run verbatim, with #4710 applied
(before the review changes) | The announcement is secured on the wire
(ExtendedFlags1 `0x91`, 268 bytes); 14 DataSets; no unsecured-frame
warning; the subscriber logs `Registered inbound metadata for writer 1`
|
| After merging master (#4710, #4711, #4716), on 83567ce: `dotnet
build src/Opc.Ua.PubSub -c Release` | 0 warnings |
| Same merge: `dotnet test tests/Opc.Ua.PubSub.Tests -f net10.0`; the
changed fixtures on net48; `dotnet test tests/Opc.Ua.PubSub.Udp.Tests -f
net10.0` | 1498 passed, 1 skipped; 132 passed, 1 skipped; 237 passed |
| Same merge: `Opc.Ua.Aot.Tests.exe --treenode-filter
"/*/*/PubSubAotTests/*"` (JIT) | 8 of 8 passed, including #4710's
`SamplePublisherDeliversSecuredDataSetToSampleSubscriberAsync` |
| Same merge: the docs/PubSub.md first exchange, run verbatim on
`opc.udp://127.0.0.1:4840`, then the publisher alone against a UDP
listener | 9 DataSets in 10 seconds and no warning on either side; the
listener saw 9 DataSetMessages and 1 DiscoveryResponse (the startup
announcement), all with the SecurityHeader bit set |

## Related Issues

- No tracking issue. Found while verifying the PubSub first-exchange
documentation (follow-up to #4555).

## Checklist

- [ ] I have signed the
[CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf)
and read the
[CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md)
doc.
- [x] I have added tests that prove my fix is effective or that my
feature works and increased code coverage.
- [x] I have added all necessary documentation.
- [x] I have verified that my changes do not introduce (new) build or
analyzer warnings.
- [ ] I ran **all** tests locally using the **UA.slnx** solution against
at least .net **framework** and .net **10**, and all passed.
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [x] I have addressed **all** PR feedback received.

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants