Address PR review feedback

* Docs/MigrationGuide.md: drop the new 'Packet capture diagnostics hooks' section.
* Stack/Opc.Ua.Core/Stack/Tcp/IFrameCaptureSink.cs -> Stack/Opc.Ua.Core/Stack/Bindings/IFrameCaptureSink.cs.
* InProcessCaptureSource: drop sync-over-async (GetAwaiter().GetResult()) on the hot path. Frame and key-material persistence is now fully async via a bounded System.Threading.Channels queue drained by a single background writer task; the IFrameCaptureSink callbacks and OnTokenObserved enqueue non-blocking and never wait on I/O.
* Models/Models.cs: split into one file per type (FormatKind, CaptureSourceKind, CaptureSessionState, StartCaptureRequest, CaptureNowRequest, StartReplayRequest, CaptureSessionInfo, NetworkInterfaceInfo). Reformat every <summary>...</summary> block to multi-line per repo convention.
* Properties/AssemblyInfo.cs: drop duplicated InternalsVisibleTo (already declared in the csproj <ItemGroup>); keep [assembly: CLSCompliant(false)].
* Opc.Ua.Diagnostics.Pcap.csproj: trim the long marketing-style Description to a single sentence.

Validated: UA.slnx build 0 errors; 49/49 pcap library tests pass on net10.0.
This commit is contained in:
Marc Schier
2026-06-06 17:27:29 +02:00
parent 36eb77ceb8
commit 67e803c6a7
14 changed files with 722 additions and 333 deletions
-4
View File
@@ -1928,10 +1928,6 @@ TimeSpan elapsed = m_timeProvider.GetElapsedTime(startTimestamp);
### Subscriptions and Transports
#### Packet capture diagnostics hooks
**Not source-breaking.** Packet-capture diagnostics add the `Opc.Ua.Bindings.IFrameCaptureSink` interface and the opt-in `UaSCUaBinaryChannel.FrameCaptureSink` property for observing raw wire-level chunks. `TcpListenerChannel.OnTokenActivated` is a new public server-side event that mirrors the existing client-side `ISecureChannel.OnTokenActivated` event. Both additions are purely additive; existing code does not need to change.
#### Durable subscriptions and reshaped Subscription tree
**Source-breaking.** Durable subscription support reshapes the subscription tree on both the client and the server. On the client side, the new public surface in `Libraries/Opc.Ua.Client/Subscription/` includes `ISubscription`, `ISubscriptionManager`, `SubscriptionOptions`, and `MonitoredItemOptions` - these are the V2 options-based shapes; the classic `Opc.Ua.Client.Subscription` continues to ship alongside them. On the server side, the new public surface in `Libraries/Opc.Ua.Server/Subscription/...` includes `DataChangeMonitoredItemQueue`, `EventMonitoredItemQueue`, `IDataChangeMonitoredItemQueue`, `IMonitoredItemQueueFactory`, `ISubscriptionStore`, `IStoredSubscription`, `StoredSubscription`, and `StoredMonitoredItem`.
@@ -32,6 +32,7 @@ using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Logging.Abstractions;
@@ -196,21 +197,33 @@ namespace Opc.Ua.Diagnostics.Pcap.Capture.Sources
/// Shared base class for the two in-process capture sources. Manages
/// the pcap + keylog file writers and the IFrameCaptureSink observer.
/// </summary>
/// <remarks>
/// <para>
/// Captured frames and key-material snapshots are forwarded through a
/// bounded <see cref="System.Threading.Channels.Channel{T}"/> to a
/// single background writer task so the event handlers (which run on
/// the OPC UA send / receive path) never block on I/O. Overflow uses
/// <see cref="System.Threading.Channels.BoundedChannelFullMode.DropOldest"/>
/// so we degrade gracefully under sustained load.
/// </para>
/// </remarks>
public abstract class InProcessCaptureSourceBase : ICaptureSource
{
private const string kPcapFileName = "capture.pcap";
private const string kKeyLogJsonFileName = "keys.uakeys.json";
private const string kKeyLogTextFileName = "keys.uakeys.txt";
private const int kQueueCapacity = 4096;
private readonly ILoggerFactory m_loggerFactory;
private readonly ConcurrentBag<Action> m_disposeActions = [];
private readonly Lock m_lock = new();
private PcapFileWriter? m_pcapWriter;
private UaKeyLogJsonWriter? m_jsonKeyWriter;
private UaKeyLogTextWriter? m_textKeyWriter;
private string? m_sessionFolder;
private CaptureFrameSinkObserver? m_sink;
private Channel<CaptureWorkItem>? m_queue;
private Task? m_workerTask;
private long m_frameCount;
private long m_byteCount;
private long m_maxBytes;
@@ -284,6 +297,17 @@ namespace Opc.Ua.Diagnostics.Pcap.Capture.Sources
m_textKeyWriter = new UaKeyLogTextWriter(textPath);
m_sink = new CaptureFrameSinkObserver(this);
m_queue = Channel.CreateBounded<CaptureWorkItem>(
new BoundedChannelOptions(kQueueCapacity)
{
SingleReader = true,
SingleWriter = false,
FullMode = BoundedChannelFullMode.DropOldest
});
m_workerTask = Task.Run(
() => RunQueueWorkerAsync(m_queue.Reader),
CancellationToken.None);
ct.ThrowIfCancellationRequested();
return ValueTask.CompletedTask;
}
@@ -303,6 +327,21 @@ namespace Opc.Ua.Diagnostics.Pcap.Capture.Sources
}
m_disposeActions.Clear();
// Signal the worker to drain and exit.
m_queue?.Writer.TryComplete();
if (m_workerTask is not null)
{
try
{
await m_workerTask.ConfigureAwait(false);
}
catch
{
// best effort drain
}
m_workerTask = null;
}
if (m_pcapWriter is not null)
{
await m_pcapWriter.DisposeAsync().ConfigureAwait(false);
@@ -410,25 +449,19 @@ namespace Opc.Ua.Diagnostics.Pcap.Capture.Sources
/// <summary>
/// Called by the derived in-process source whenever a token is
/// activated on one of its tracked channels.
/// activated on one of its tracked channels. The snapshot is
/// enqueued for asynchronous persistence; this method never
/// blocks on I/O.
/// </summary>
protected void OnTokenObserved(ChannelKeyMaterial material)
{
UaKeyLogJsonWriter? jsonWriter = m_jsonKeyWriter;
UaKeyLogTextWriter? textWriter = m_textKeyWriter;
if (jsonWriter is null || textWriter is null)
ArgumentNullException.ThrowIfNull(material);
Channel<CaptureWorkItem>? queue = m_queue;
if (queue is null)
{
return;
}
try
{
jsonWriter.AppendAsync(material, CancellationToken.None).AsTask().GetAwaiter().GetResult();
textWriter.AppendAsync(material, CancellationToken.None).AsTask().GetAwaiter().GetResult();
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to persist key material snapshot.");
}
queue.Writer.TryWrite(CaptureWorkItem.ForKey(material));
}
/// <summary>
@@ -476,8 +509,8 @@ namespace Opc.Ua.Diagnostics.Pcap.Capture.Sources
private void OnFrameCaptured(uint channelId, ReadOnlySpan<byte> chunk, bool sent)
{
PcapFileWriter? writer = m_pcapWriter;
if (writer is null || chunk.IsEmpty)
Channel<CaptureWorkItem>? queue = m_queue;
if (queue is null || chunk.IsEmpty)
{
return;
}
@@ -491,24 +524,99 @@ namespace Opc.Ua.Diagnostics.Pcap.Capture.Sources
Interlocked.CompareExchange(ref m_state, StateStopped, StateRunning);
return;
}
// Copy the chunk bytes; the underlying buffer is pooled and
// only valid for the duration of this call.
byte[] packet = LoopbackFrameBuilder.Build(
fromClient: sent,
channelId: channelId,
chunkBytes: chunk);
DateTimeOffset now = DateTimeOffset.UtcNow;
queue.Writer.TryWrite(CaptureWorkItem.ForFrame(DateTimeOffset.UtcNow, packet));
}
private async Task RunQueueWorkerAsync(ChannelReader<CaptureWorkItem> reader)
{
try
{
lock (m_lock)
while (await reader.WaitToReadAsync().ConfigureAwait(false))
{
writer.WriteAsync(now, packet, CancellationToken.None)
.AsTask()
.GetAwaiter()
.GetResult();
while (reader.TryRead(out CaptureWorkItem item))
{
await ProcessWorkItemAsync(item).ConfigureAwait(false);
}
}
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to write captured frame to pcap.");
Logger.LogError(ex, "In-process capture queue worker terminated unexpectedly.");
}
}
private async Task ProcessWorkItemAsync(CaptureWorkItem item)
{
if (item.Packet is not null)
{
PcapFileWriter? writer = m_pcapWriter;
if (writer is null)
{
return;
}
try
{
await writer.WriteAsync(item.Timestamp, item.Packet, CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to write captured frame to pcap.");
}
return;
}
if (item.KeyMaterial is not null)
{
UaKeyLogJsonWriter? jsonWriter = m_jsonKeyWriter;
UaKeyLogTextWriter? textWriter = m_textKeyWriter;
if (jsonWriter is null || textWriter is null)
{
return;
}
try
{
await jsonWriter.AppendAsync(item.KeyMaterial, CancellationToken.None)
.ConfigureAwait(false);
await textWriter.AppendAsync(item.KeyMaterial, CancellationToken.None)
.ConfigureAwait(false);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Failed to persist key material snapshot.");
}
}
}
private readonly struct CaptureWorkItem
{
private CaptureWorkItem(
DateTimeOffset timestamp,
byte[]? packet,
ChannelKeyMaterial? keyMaterial)
{
Timestamp = timestamp;
Packet = packet;
KeyMaterial = keyMaterial;
}
public DateTimeOffset Timestamp { get; }
public byte[]? Packet { get; }
public ChannelKeyMaterial? KeyMaterial { get; }
public static CaptureWorkItem ForFrame(DateTimeOffset timestamp, byte[] packet)
{
return new CaptureWorkItem(timestamp, packet, keyMaterial: null);
}
public static CaptureWorkItem ForKey(ChannelKeyMaterial material)
{
return new CaptureWorkItem(DateTimeOffset.UtcNow, packet: null, material);
}
}
@@ -0,0 +1,52 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
namespace Opc.Ua.Diagnostics.Pcap.Models
{
/// <summary>
/// Convenience request for <c>capture_now</c>.
/// </summary>
public sealed class CaptureNowRequest
{
/// <summary>
/// The underlying capture parameters.
/// </summary>
public StartCaptureRequest Start { get; init; } = new();
/// <summary>
/// How long to capture for before returning. Capped at 60 s.
/// </summary>
public int DurationSeconds { get; init; } = 10;
/// <summary>
/// Format to return the captured data in.
/// </summary>
public FormatKind Format { get; init; } = FormatKind.ServiceTimeline;
}
}
@@ -0,0 +1,94 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
namespace Opc.Ua.Diagnostics.Pcap.Models
{
/// <summary>
/// Status / metadata describing a capture session.
/// </summary>
public sealed class CaptureSessionInfo
{
/// <summary>
/// Session id (assigned by <c>start_capture</c>).
/// </summary>
public string SessionId { get; init; } = string.Empty;
/// <summary>
/// The source kind.
/// </summary>
public CaptureSourceKind Source { get; init; }
/// <summary>
/// Current state.
/// </summary>
public CaptureSessionState State { get; init; }
/// <summary>
/// UTC timestamp the session entered Running state.
/// </summary>
public DateTimeOffset? StartedAt { get; init; }
/// <summary>
/// UTC timestamp the session stopped.
/// </summary>
public DateTimeOffset? StoppedAt { get; init; }
/// <summary>
/// Captured chunk count.
/// </summary>
public long FrameCount { get; init; }
/// <summary>
/// Captured byte count.
/// </summary>
public long ByteCount { get; init; }
/// <summary>
/// Folder on disk hosting the session's artifacts.
/// </summary>
public string SessionFolder { get; init; } = string.Empty;
/// <summary>
/// Path to the pcap file (if any).
/// </summary>
public string? PcapFilePath { get; init; }
/// <summary>
/// Path to the keylog file (if any).
/// </summary>
public string? KeyLogFilePath { get; init; }
/// <summary>
/// Failure message (if state is Failed).
/// </summary>
public string? Error { get; init; }
}
}
@@ -0,0 +1,67 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
namespace Opc.Ua.Diagnostics.Pcap.Models
{
/// <summary>
/// State a <c>CaptureSession</c> can be in.
/// </summary>
public enum CaptureSessionState
{
/// <summary>
/// The session has been created but not yet started.
/// </summary>
Starting = 0,
/// <summary>
/// The session is actively capturing.
/// </summary>
Running = 1,
/// <summary>
/// The session is in the middle of being stopped.
/// </summary>
Stopping = 2,
/// <summary>
/// The session stopped cleanly.
/// </summary>
Completed = 3,
/// <summary>
/// The session terminated because of an error.
/// </summary>
Failed = 4,
/// <summary>
/// The session has been disposed.
/// </summary>
Disposed = 5
}
}
@@ -0,0 +1,65 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
namespace Opc.Ua.Diagnostics.Pcap.Models
{
/// <summary>
/// Identifies a kind of capture source the
/// <c>CaptureSessionManager</c> can create.
/// </summary>
public enum CaptureSourceKind
{
/// <summary>
/// Live capture from a network interface via SharpPcap (requires
/// libpcap / Npcap and usually elevated privileges).
/// </summary>
Nic = 0,
/// <summary>
/// Passive in-process tap that hooks the channel
/// <see cref="Opc.Ua.Bindings.IFrameCaptureSink"/> on each new
/// <see cref="Opc.Ua.ITransportChannel"/> created by an OPC UA
/// client.
/// </summary>
InProcessClient = 1,
/// <summary>
/// Passive in-process tap that hooks every server-side
/// <see cref="Opc.Ua.Bindings.TcpServerChannel"/> created by a
/// hosted OPC UA server.
/// </summary>
InProcessServer = 2,
/// <summary>
/// Replay-only source that re-reads an existing pcap file plus
/// optional keylog from disk.
/// </summary>
Replay = 3
}
}
@@ -0,0 +1,67 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
namespace Opc.Ua.Diagnostics.Pcap.Models
{
/// <summary>
/// Output formats supported by the diagnostics pipeline.
/// </summary>
public enum FormatKind
{
/// <summary>
/// libpcap (binary) - openable in Wireshark.
/// </summary>
Pcap = 0,
/// <summary>
/// PcapNg (binary) - openable in Wireshark.
/// </summary>
PcapNg = 1,
/// <summary>
/// Per-frame JSON.
/// </summary>
Json = 2,
/// <summary>
/// Per-frame CSV.
/// </summary>
Csv = 3,
/// <summary>
/// Human-readable per-frame text.
/// </summary>
Text = 4,
/// <summary>
/// OPC UA-specific decoded service-call timeline (text).
/// </summary>
ServiceTimeline = 5
}
}
@@ -1,303 +0,0 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Collections.Generic;
using Opc.Ua.Diagnostics.Pcap.Replay;
namespace Opc.Ua.Diagnostics.Pcap.Models
{
/// <summary>
/// Output formats supported by the diagnostics pipeline.
/// </summary>
public enum FormatKind
{
/// <summary>libpcap (binary) - openable in Wireshark.</summary>
Pcap = 0,
/// <summary>PcapNg (binary) - openable in Wireshark.</summary>
PcapNg = 1,
/// <summary>Per-frame JSON.</summary>
Json = 2,
/// <summary>Per-frame CSV.</summary>
Csv = 3,
/// <summary>Human-readable per-frame text.</summary>
Text = 4,
/// <summary>
/// OPC UA-specific decoded service-call timeline (text).
/// </summary>
ServiceTimeline = 5
}
/// <summary>
/// Identifies a kind of capture source the
/// <c>CaptureSessionManager</c> can create.
/// </summary>
public enum CaptureSourceKind
{
/// <summary>
/// Live capture from a network interface via SharpPcap (requires
/// libpcap / Npcap and usually elevated privileges).
/// </summary>
Nic = 0,
/// <summary>
/// Passive in-process tap that hooks the channel
/// <see cref="Opc.Ua.Bindings.IFrameCaptureSink"/> on each new
/// <see cref="Opc.Ua.ITransportChannel"/> created by an OPC UA
/// client.
/// </summary>
InProcessClient = 1,
/// <summary>
/// Passive in-process tap that hooks every server-side
/// <see cref="Opc.Ua.Bindings.TcpServerChannel"/> created by a
/// hosted OPC UA server.
/// </summary>
InProcessServer = 2,
/// <summary>
/// Replay-only source that re-reads an existing pcap file plus
/// optional keylog from disk.
/// </summary>
Replay = 3
}
/// <summary>
/// State a <c>CaptureSession</c> can be in.
/// </summary>
public enum CaptureSessionState
{
/// <summary>The session has been created but not yet started.</summary>
Starting = 0,
/// <summary>The session is actively capturing.</summary>
Running = 1,
/// <summary>The session is in the middle of being stopped.</summary>
Stopping = 2,
/// <summary>The session stopped cleanly.</summary>
Completed = 3,
/// <summary>The session terminated because of an error.</summary>
Failed = 4,
/// <summary>The session has been disposed.</summary>
Disposed = 5
}
/// <summary>
/// Parameters for <c>start_capture</c>.
/// </summary>
public sealed class StartCaptureRequest
{
/// <summary>The kind of source to create.</summary>
public CaptureSourceKind Source { get; init; } = CaptureSourceKind.InProcessClient;
/// <summary>
/// Network interface name (required for
/// <see cref="CaptureSourceKind.Nic"/>). Use
/// <c>list_interfaces</c> to enumerate.
/// </summary>
public string? InterfaceName { get; init; }
/// <summary>
/// Optional BPF filter expression for
/// <see cref="CaptureSourceKind.Nic"/>.
/// </summary>
public string? BpfFilter { get; init; }
/// <summary>
/// Whether to open the NIC in promiscuous mode. Defaults to true
/// when capturing on a real NIC.
/// </summary>
public bool? Promiscuous { get; init; }
/// <summary>
/// Path to an existing pcap file (required for
/// <see cref="CaptureSourceKind.Replay"/>).
/// </summary>
public string? PcapFilePath { get; init; }
/// <summary>
/// Path to an existing keylog file (optional for
/// <see cref="CaptureSourceKind.Replay"/>; without it the source
/// can only replay the raw bytes, not decode them).
/// </summary>
public string? KeyLogFilePath { get; init; }
/// <summary>
/// Hard byte cap for the capture; the session is stopped when
/// reached. Default 50 MB.
/// </summary>
public long? MaxBytes { get; init; }
/// <summary>
/// Hard frame cap for the capture; the session is stopped when
/// reached. Default unlimited.
/// </summary>
public long? MaxFrames { get; init; }
/// <summary>
/// Hard duration cap for the capture (seconds). Default 30 min.
/// </summary>
public int? MaxDurationSeconds { get; init; }
/// <summary>
/// Folder under which the session's pcap and keylog files are
/// written. If <c>null</c> a per-session temp folder is created.
/// </summary>
public string? SessionFolder { get; init; }
}
/// <summary>
/// Convenience request for <c>capture_now</c>.
/// </summary>
public sealed class CaptureNowRequest
{
/// <summary>The underlying capture parameters.</summary>
public StartCaptureRequest Start { get; init; } = new();
/// <summary>How long to capture for before returning. Capped at 60 s.</summary>
public int DurationSeconds { get; init; } = 10;
/// <summary>Format to return the captured data in.</summary>
public FormatKind Format { get; init; } = FormatKind.ServiceTimeline;
}
/// <summary>
/// Parameters for replaying a pcap recording.
/// </summary>
public sealed class StartReplayRequest
{
/// <summary>
/// Path to the pcap file to replay.
/// </summary>
public string PcapFilePath { get; init; } = string.Empty;
/// <summary>
/// Optional keylog file used by mock-client replay for offline decoding.
/// </summary>
public string? KeyLogFilePath { get; init; }
/// <summary>
/// Replay mode.
/// </summary>
public ReplayMode Mode { get; init; } = ReplayMode.MockServer;
/// <summary>
/// Listen URI scheme for mock-server replay. Defaults to <c>opc.tcp</c>.
/// </summary>
public string? ListenScheme { get; init; }
/// <summary>
/// Listen port for mock-server replay. Defaults to an ephemeral port.
/// </summary>
public int? ListenPort { get; init; }
/// <summary>
/// Target endpoint URL for mock-client replay.
/// </summary>
public string? TargetEndpointUrl { get; init; }
/// <summary>
/// Replay speed multiplier.
/// </summary>
public double Speed { get; init; } = 1.0;
}
/// <summary>
/// Status / metadata describing a capture session.
/// </summary>
public sealed class CaptureSessionInfo
{
/// <summary>Session id (assigned by <c>start_capture</c>).</summary>
public string SessionId { get; init; } = string.Empty;
/// <summary>The source kind.</summary>
public CaptureSourceKind Source { get; init; }
/// <summary>Current state.</summary>
public CaptureSessionState State { get; init; }
/// <summary>UTC timestamp the session entered Running state.</summary>
public DateTimeOffset? StartedAt { get; init; }
/// <summary>UTC timestamp the session stopped.</summary>
public DateTimeOffset? StoppedAt { get; init; }
/// <summary>Captured chunk count.</summary>
public long FrameCount { get; init; }
/// <summary>Captured byte count.</summary>
public long ByteCount { get; init; }
/// <summary>Folder on disk hosting the session's artifacts.</summary>
public string SessionFolder { get; init; } = string.Empty;
/// <summary>Path to the pcap file (if any).</summary>
public string? PcapFilePath { get; init; }
/// <summary>Path to the keylog file (if any).</summary>
public string? KeyLogFilePath { get; init; }
/// <summary>Failure message (if state is Failed).</summary>
public string? Error { get; init; }
}
/// <summary>
/// Describes a local network interface returned by
/// <c>list_interfaces</c>.
/// </summary>
public sealed class NetworkInterfaceInfo
{
/// <summary>Device name as understood by libpcap (e.g. <c>\Device\NPF_{guid}</c>).</summary>
public string Name { get; init; } = string.Empty;
/// <summary>Friendly name (e.g. <c>Ethernet</c>).</summary>
public string? FriendlyName { get; init; }
/// <summary>Free-form description.</summary>
public string? Description { get; init; }
/// <summary>Bound IP addresses.</summary>
public IReadOnlyList<string> Addresses { get; init; } = [];
/// <summary>The link-layer type (e.g. <c>Ethernet</c>).</summary>
public string? LinkType { get; init; }
/// <summary>Whether this is the loopback interface.</summary>
public bool IsLoopback { get; init; }
}
}
@@ -0,0 +1,71 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System.Collections.Generic;
namespace Opc.Ua.Diagnostics.Pcap.Models
{
/// <summary>
/// Describes a local network interface returned by
/// <c>list_interfaces</c>.
/// </summary>
public sealed class NetworkInterfaceInfo
{
/// <summary>
/// Device name as understood by libpcap (e.g.
/// <c>\Device\NPF_{guid}</c>).
/// </summary>
public string Name { get; init; } = string.Empty;
/// <summary>
/// Friendly name (e.g. <c>Ethernet</c>).
/// </summary>
public string? FriendlyName { get; init; }
/// <summary>
/// Free-form description.
/// </summary>
public string? Description { get; init; }
/// <summary>
/// Bound IP addresses.
/// </summary>
public IReadOnlyList<string> Addresses { get; init; } = [];
/// <summary>
/// The link-layer type (e.g. <c>Ethernet</c>).
/// </summary>
public string? LinkType { get; init; }
/// <summary>
/// Whether this is the loopback interface.
/// </summary>
public bool IsLoopback { get; init; }
}
}
@@ -0,0 +1,97 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
namespace Opc.Ua.Diagnostics.Pcap.Models
{
/// <summary>
/// Parameters for <c>start_capture</c>.
/// </summary>
public sealed class StartCaptureRequest
{
/// <summary>
/// The kind of source to create.
/// </summary>
public CaptureSourceKind Source { get; init; } = CaptureSourceKind.InProcessClient;
/// <summary>
/// Network interface name (required for
/// <see cref="CaptureSourceKind.Nic"/>). Use
/// <c>list_interfaces</c> to enumerate.
/// </summary>
public string? InterfaceName { get; init; }
/// <summary>
/// Optional BPF filter expression for
/// <see cref="CaptureSourceKind.Nic"/>.
/// </summary>
public string? BpfFilter { get; init; }
/// <summary>
/// Whether to open the NIC in promiscuous mode. Defaults to true
/// when capturing on a real NIC.
/// </summary>
public bool? Promiscuous { get; init; }
/// <summary>
/// Path to an existing pcap file (required for
/// <see cref="CaptureSourceKind.Replay"/>).
/// </summary>
public string? PcapFilePath { get; init; }
/// <summary>
/// Path to an existing keylog file (optional for
/// <see cref="CaptureSourceKind.Replay"/>; without it the source
/// can only replay the raw bytes, not decode them).
/// </summary>
public string? KeyLogFilePath { get; init; }
/// <summary>
/// Hard byte cap for the capture; the session is stopped when
/// reached. Default 50 MB.
/// </summary>
public long? MaxBytes { get; init; }
/// <summary>
/// Hard frame cap for the capture; the session is stopped when
/// reached. Default unlimited.
/// </summary>
public long? MaxFrames { get; init; }
/// <summary>
/// Hard duration cap for the capture (seconds). Default 30 min.
/// </summary>
public int? MaxDurationSeconds { get; init; }
/// <summary>
/// Folder under which the session's pcap and keylog files are
/// written. If <c>null</c> a per-session temp folder is created.
/// </summary>
public string? SessionFolder { get; init; }
}
}
@@ -0,0 +1,77 @@
/* ========================================================================
* Copyright (c) 2005-2026 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using Opc.Ua.Diagnostics.Pcap.Replay;
namespace Opc.Ua.Diagnostics.Pcap.Models
{
/// <summary>
/// Parameters for replaying a pcap recording.
/// </summary>
public sealed class StartReplayRequest
{
/// <summary>
/// Path to the pcap file to replay.
/// </summary>
public string PcapFilePath { get; init; } = string.Empty;
/// <summary>
/// Optional keylog file used by mock-client replay for offline
/// decoding.
/// </summary>
public string? KeyLogFilePath { get; init; }
/// <summary>
/// Replay mode.
/// </summary>
public ReplayMode Mode { get; init; } = ReplayMode.MockServer;
/// <summary>
/// Listen URI scheme for mock-server replay. Defaults to
/// <c>opc.tcp</c>.
/// </summary>
public string? ListenScheme { get; init; }
/// <summary>
/// Listen port for mock-server replay. Defaults to an ephemeral
/// port.
/// </summary>
public int? ListenPort { get; init; }
/// <summary>
/// Target endpoint URL for mock-client replay.
/// </summary>
public string? TargetEndpointUrl { get; init; }
/// <summary>
/// Replay speed multiplier.
/// </summary>
public double Speed { get; init; } = 1.0;
}
}
@@ -4,7 +4,7 @@
<AssemblyName>$(AssemblyPrefix).Diagnostics.Pcap</AssemblyName>
<PackageId>$(PackagePrefix).Opc.Ua.Diagnostics.Pcap</PackageId>
<RootNamespace>Opc.Ua.Diagnostics.Pcap</RootNamespace>
<Description>OPC UA packet capture, dissection and replay engine. Reuses the stack's UaSCUaBinaryChannel decoder to support every security profile the stack supports. Includes libpcap I/O, SharpPcap NIC capture, in-process taps, key-log writers, mock-server / mock-client replay and pcap/pcapng/json/csv/text/service-timeline formatters.</Description>
<Description>OPC UA packet capture, dissection and replay engine.</Description>
<IsPackable>true</IsPackable>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<Nullable>enable</Nullable>
@@ -28,7 +28,5 @@
* ======================================================================*/
using System;
using System.Runtime.CompilerServices;
[assembly: CLSCompliant(false)]
[assembly: InternalsVisibleTo("Opc.Ua.Diagnostics.Pcap.Tests")]