mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-19 07:02:16 +08:00
c781514ad5
Three changes addressing the design feedback on PR #3855: 1. Renamed Stack/Opc.Ua.Diagnostics.Pcap -> Stack/Opc.Ua.Bindings.Pcap (and the corresponding test project). PackageId, AssemblyName and RootNamespace updated to Opc.Ua.Bindings.Pcap; references in McpServer, signlists, slnx files, docs, and the interop-tester agent updated; Opc.Ua.Core's InternalsVisibleTo entries updated. 2. Replaced UaSCUaBinaryChannel.FrameCaptureSink + per-call NotifyFrameCaptureSink null check with a transport-binding decorator. New files in Opc.Ua.Bindings.Pcap/Bindings: - IChannelCaptureRegistry / ChannelCaptureRegistry: singleton with a volatile IFrameCaptureSink slot (1 atomic write to switch recording on/off). - CapturingMessageSocket / CapturingMessageSocketFactory: IMessageSocket decorator that taps Send/Receive bytes via a single volatile read of CurrentObserver. - PcapTransportChannelBinding: ITransportChannelFactory decorator that injects the capturing socket factory into every TcpTransportChannel and wires ISecureChannel.OnTokenActivated through to the active observer. - PcapBindings.Install(): non-DI installer for direct TransportBindings.Channels.SetBinding wiring. IFrameCaptureSink moved to Opc.Ua.Core/Stack/Bindings and gained an OnTokenActivated method so the binding can publish key material alongside chunks. UaSCUaBinaryChannel.FrameCaptureSink property and all three NotifyFrameCaptureSink call sites removed - hot path returns to original shape with zero indirection when the binding is not installed. IMessageSocketAsyncEventArgs gains Offset + Count getters (additive). AddOpcUaDiagnosticsPcap/Formatters/Replay extension methods renamed to AddOpcUaBindingsPcap[Formatters|Replay]; the new AddOpcUaBindingsPcap also installs the binding into TransportBindings.Channels. InProcessClientCaptureSource / InProcessServerCaptureSource: dropped reflection-based AttachClientChannel/AttachServerChannel. The source itself implements IFrameCaptureSink and on Start publishes itself as the registry observer; on Stop clears it. McpServer's PacketCaptureTools simplified accordingly (no more per-channel attach loop). 3. Discarded the source-generator suggestion. Confirmed that the dissection path already 100% reuses the source-generated decoders: UaSCUaBinaryChannel.ReadSymmetricMessage for chunk decryption and BinaryDecoder.ReadEncodeable + EncodeableFactory.GlobalFactory (populated by Tools/Opc.Ua.SourceGeneration's DataTypeGenerator) for service-type decoding. Emitting additional dissection code would duplicate what's already there. Validation: - Full UA.slnx builds clean: 0 errors, 2 pre-existing unrelated warnings. - All 53 tests pass on net10.0 (49 existing + 4 new CapturingMessageSocketTests covering hot-path: not-installed/installed-off/installed-on costs, byte equality, exception isolation). - CI command line (dotnet build/test --framework net10.0 --configuration Release /p:CustomTestTarget=net10.0) reproduces clean locally.
639 lines
24 KiB
C#
639 lines
24 KiB
C#
/* ========================================================================
|
|
* 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.Buffers.Binary;
|
|
using System.Collections;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Diagnostics.Metrics;
|
|
using System.Globalization;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using Opc.Ua.Bindings;
|
|
using Opc.Ua.Bindings.Pcap.Capture;
|
|
using Opc.Ua.Bindings.Pcap.Frame;
|
|
using Opc.Ua.Bindings.Pcap.KeyLog;
|
|
|
|
namespace Opc.Ua.Bindings.Pcap.Dissection
|
|
{
|
|
/// <summary>
|
|
/// Reassembles decrypted OPC UA service calls from captured UA-SC
|
|
/// chunks.
|
|
/// </summary>
|
|
public sealed class ServiceCallReassembler
|
|
{
|
|
/// <summary>
|
|
/// Constructs a service-call reassembler with no-op logging.
|
|
/// </summary>
|
|
public ServiceCallReassembler()
|
|
: this(NullLoggerFactory.Instance)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// Constructs a service-call reassembler with explicit logging.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException">
|
|
/// <paramref name="loggerFactory"/> is <c>null</c>.
|
|
/// </exception>
|
|
public ServiceCallReassembler(ILoggerFactory loggerFactory)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(loggerFactory);
|
|
|
|
m_loggerFactory = loggerFactory;
|
|
m_logger = loggerFactory.CreateLogger<ServiceCallReassembler>();
|
|
m_messageContext = new ServiceMessageContext(
|
|
NoopTelemetryContext.Instance,
|
|
EncodeableFactory.Create());
|
|
}
|
|
|
|
/// <summary>
|
|
/// Add (or update) the key material for a token observed in the
|
|
/// capture. Tokens are matched against incoming chunks by their
|
|
/// (channelId, tokenId). Must be called at least once for each
|
|
/// channel before <see cref="Push(CaptureFrame)"/> chunks for that
|
|
/// channel can be decoded.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException">
|
|
/// <paramref name="material"/> is <c>null</c>.
|
|
/// </exception>
|
|
public void LoadKeyMaterial(ChannelKeyMaterial material)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(material);
|
|
|
|
if (m_channels.TryGetValue(material.ChannelId, out OfflineSecureChannel? channel))
|
|
{
|
|
channel.LoadKeyMaterial(material);
|
|
return;
|
|
}
|
|
|
|
m_channels[material.ChannelId] = new OfflineSecureChannel(material, m_loggerFactory);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Feeds one captured OPC UA chunk through the reassembler. If a
|
|
/// request/response pair completes as a result of this chunk, the
|
|
/// completed <see cref="DecodedServiceCall"/> is appended to the
|
|
/// internal result queue and returned by the next call to
|
|
/// <see cref="DrainCompleted"/>.
|
|
/// </summary>
|
|
/// <param name="frame">
|
|
/// The chunk to feed. The first 4 bytes of <see cref="CaptureFrame.Data"/>
|
|
/// must be a valid OPC UA message-type marker; otherwise the chunk is
|
|
/// silently skipped.
|
|
/// </param>
|
|
public void Push(CaptureFrame frame)
|
|
{
|
|
try
|
|
{
|
|
if (frame.Data.Length < sizeof(uint))
|
|
{
|
|
return;
|
|
}
|
|
|
|
ReadOnlySpan<byte> data = frame.Data.Span;
|
|
uint messageType = BinaryPrimitives.ReadUInt32LittleEndian(data);
|
|
if (!TcpMessageType.IsValid(messageType))
|
|
{
|
|
return;
|
|
}
|
|
|
|
if (frame.Direction == CaptureFrameDirection.Unknown)
|
|
{
|
|
m_logger.LogWarning(
|
|
"Skipping OPC UA chunk with unknown direction at {Timestamp}.",
|
|
frame.Timestamp);
|
|
return;
|
|
}
|
|
|
|
bool fromClient = frame.Direction == CaptureFrameDirection.ClientToServer;
|
|
uint baseType = messageType & TcpMessageType.MessageTypeMask;
|
|
switch (baseType)
|
|
{
|
|
case TcpMessageType.Hello:
|
|
AddControlMessage(frame, "Hello");
|
|
return;
|
|
case TcpMessageType.Acknowledge:
|
|
AddControlMessage(frame, "Acknowledge");
|
|
return;
|
|
case TcpMessageType.Error:
|
|
AddControlMessage(frame, "Error");
|
|
return;
|
|
case TcpMessageType.ReverseHello:
|
|
AddControlMessage(frame, "ReverseHello");
|
|
return;
|
|
case TcpMessageType.Open:
|
|
AddOpenSecureChannelMessage(frame, fromClient);
|
|
return;
|
|
case TcpMessageType.Message:
|
|
case TcpMessageType.Close:
|
|
PushSecureChannelChunk(frame, fromClient, baseType);
|
|
return;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
m_logger.LogWarning(ex, "Skipping OPC UA chunk because service-call reassembly failed.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns and clears the set of service calls that completed since
|
|
/// the last call. The reassembler keeps internal state for in-flight
|
|
/// requests until they complete or the channel closes.
|
|
/// </summary>
|
|
public IReadOnlyList<DecodedServiceCall> DrainCompleted()
|
|
{
|
|
DecodedServiceCall[] completed = m_completed.ToArray();
|
|
m_completed.Clear();
|
|
return completed;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Convenience helper: feeds every frame from an enumerable, then
|
|
/// returns all completed service calls.
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException">
|
|
/// <paramref name="frames"/> is <c>null</c>.
|
|
/// </exception>
|
|
public IReadOnlyList<DecodedServiceCall> ProcessAll(IEnumerable<CaptureFrame> frames)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(frames);
|
|
|
|
foreach (CaptureFrame frame in frames)
|
|
{
|
|
Push(frame);
|
|
}
|
|
|
|
return DrainCompleted();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Same as <see cref="ProcessAll(IEnumerable{CaptureFrame})"/> but
|
|
/// for async streams (e.g. directly from
|
|
/// <see cref="ICaptureSource.ReadCapturedFramesAsync"/>).
|
|
/// </summary>
|
|
/// <exception cref="ArgumentNullException">
|
|
/// <paramref name="frames"/> is <c>null</c>.
|
|
/// </exception>
|
|
public async ValueTask<IReadOnlyList<DecodedServiceCall>> ProcessAllAsync(
|
|
IAsyncEnumerable<CaptureFrame> frames,
|
|
CancellationToken ct)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(frames);
|
|
|
|
await foreach (CaptureFrame frame in frames.WithCancellation(ct).ConfigureAwait(false))
|
|
{
|
|
Push(frame);
|
|
}
|
|
|
|
return DrainCompleted();
|
|
}
|
|
|
|
private void AddControlMessage(CaptureFrame frame, string name)
|
|
{
|
|
m_completed.Add(new DecodedServiceCall
|
|
{
|
|
RequestTimestamp = frame.Timestamp,
|
|
RequestName = name,
|
|
RequestBodySize = frame.Data.Length
|
|
});
|
|
}
|
|
|
|
private void AddOpenSecureChannelMessage(CaptureFrame frame, bool fromClient)
|
|
{
|
|
uint channelId = 0;
|
|
uint tokenId = 0;
|
|
if (frame.Data.Length >= 16)
|
|
{
|
|
ReadOnlySpan<byte> data = frame.Data.Span;
|
|
channelId = BinaryPrimitives.ReadUInt32LittleEndian(data[8..]);
|
|
tokenId = BinaryPrimitives.ReadUInt32LittleEndian(data[12..]);
|
|
}
|
|
|
|
m_completed.Add(new DecodedServiceCall
|
|
{
|
|
ChannelId = channelId,
|
|
TokenId = tokenId,
|
|
RequestTimestamp = frame.Timestamp,
|
|
RequestName = fromClient ? "OpenSecureChannelRequest" : "OpenSecureChannelResponse",
|
|
RequestBodySize = frame.Data.Length,
|
|
RequestSummary = "(asymmetric OpenSecureChannel chunk)"
|
|
});
|
|
}
|
|
|
|
private void PushSecureChannelChunk(CaptureFrame frame, bool fromClient, uint baseType)
|
|
{
|
|
try
|
|
{
|
|
if (frame.Data.Length < TcpMessageLimits.BaseHeaderSize)
|
|
{
|
|
return;
|
|
}
|
|
|
|
uint channelId = BinaryPrimitives.ReadUInt32LittleEndian(frame.Data.Span[8..]);
|
|
if (!m_channels.TryGetValue(channelId, out OfflineSecureChannel? channel))
|
|
{
|
|
m_logger.LogWarning(
|
|
"Skipping OPC UA chunk for channel 0x{ChannelId:X8}; no key material is loaded.",
|
|
channelId);
|
|
return;
|
|
}
|
|
|
|
OfflineDecodedChunk decoded = channel.ReadChunk(frame.Data.Span, fromClient);
|
|
PendingServiceCall pending = GetPendingCall(decoded, frame.Timestamp, fromClient);
|
|
if (fromClient)
|
|
{
|
|
pending.RequestChunks.Add(decoded.Body.ToArray());
|
|
pending.TokenId = decoded.TokenId;
|
|
if (decoded.IsFinal)
|
|
{
|
|
pending.RequestIsComplete = true;
|
|
pending.Request = DecodeMessage(pending.RequestChunks);
|
|
}
|
|
}
|
|
else
|
|
{
|
|
pending.ResponseChunks.Add(decoded.Body.ToArray());
|
|
pending.TokenId = decoded.TokenId;
|
|
if (decoded.IsFinal)
|
|
{
|
|
pending.ResponseTimestamp = frame.Timestamp;
|
|
pending.ResponseIsComplete = true;
|
|
pending.Response = DecodeMessage(pending.ResponseChunks);
|
|
}
|
|
}
|
|
|
|
if (pending.RequestIsComplete && pending.ResponseIsComplete)
|
|
{
|
|
m_completed.Add(CreateDecodedServiceCall(decoded, pending));
|
|
m_pending.Remove((decoded.ChannelId, decoded.RequestId));
|
|
}
|
|
|
|
if (baseType == TcpMessageType.Close && decoded.IsFinal)
|
|
{
|
|
RemoveChannelState(decoded.ChannelId);
|
|
}
|
|
}
|
|
catch (PcapDiagnosticsException ex)
|
|
{
|
|
m_logger.LogWarning(ex, "Skipping OPC UA secure-channel chunk because offline decoding failed.");
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
m_logger.LogWarning(ex, "Skipping OPC UA secure-channel chunk because reassembly failed.");
|
|
}
|
|
}
|
|
|
|
private PendingServiceCall GetPendingCall(
|
|
OfflineDecodedChunk decoded,
|
|
DateTimeOffset timestamp,
|
|
bool fromClient)
|
|
{
|
|
var key = (decoded.ChannelId, decoded.RequestId);
|
|
if (m_pending.TryGetValue(key, out PendingServiceCall? pending))
|
|
{
|
|
if (!fromClient || !pending.RequestIsComplete)
|
|
{
|
|
return pending;
|
|
}
|
|
}
|
|
|
|
pending = new PendingServiceCall(timestamp);
|
|
m_pending[key] = pending;
|
|
return pending;
|
|
}
|
|
|
|
private DecodedServiceCall CreateDecodedServiceCall(
|
|
OfflineDecodedChunk decoded,
|
|
PendingServiceCall pending)
|
|
{
|
|
Dictionary<string, string?> annotations = [];
|
|
AddRequestAnnotations(annotations, pending.Request?.Message);
|
|
|
|
return new DecodedServiceCall
|
|
{
|
|
ChannelId = decoded.ChannelId,
|
|
TokenId = pending.TokenId,
|
|
RequestId = decoded.RequestId,
|
|
RequestTimestamp = pending.RequestTimestamp,
|
|
ResponseTimestamp = pending.ResponseTimestamp,
|
|
RequestName = pending.Request?.Name,
|
|
ResponseName = pending.Response?.Name,
|
|
ResponseStatus = pending.Response?.ResponseStatus,
|
|
RequestBodySize = GetTotalLength(pending.RequestChunks),
|
|
ResponseBodySize = GetTotalLength(pending.ResponseChunks),
|
|
RequestSummary = pending.Request?.Summary,
|
|
ResponseSummary = pending.Response?.Summary,
|
|
Annotations = annotations
|
|
};
|
|
}
|
|
|
|
private DecodedMessage DecodeMessage(List<byte[]> chunks)
|
|
{
|
|
byte[] body = Concatenate(chunks);
|
|
string undecodedName = $"{body.Length}B";
|
|
try
|
|
{
|
|
using var decoder = new BinaryDecoder(body, m_messageContext);
|
|
ExpandedNodeId typeId = decoder.ReadExpandedNodeId(null);
|
|
undecodedName = typeId.ToString();
|
|
if (!m_messageContext.Factory.TryGetEncodeableType(
|
|
typeId,
|
|
out IEncodeableType? encodeableType))
|
|
{
|
|
return DecodedMessage.Undecoded(undecodedName, body.Length);
|
|
}
|
|
|
|
IEncodeable message = encodeableType.CreateInstance();
|
|
message.Decode(decoder);
|
|
|
|
return CreateDecodedMessage(message, body.Length);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
m_logger.LogWarning(ex, "Unable to decode OPC UA service message body.");
|
|
return DecodedMessage.Undecoded(undecodedName, body.Length);
|
|
}
|
|
}
|
|
|
|
private DecodedMessage CreateDecodedMessage(IEncodeable message, int byteCount)
|
|
{
|
|
string name = message.GetType().Name;
|
|
if (message is IServiceRequest request)
|
|
{
|
|
return new DecodedMessage(name, CreateRequestSummary(request, name, byteCount), null, request);
|
|
}
|
|
|
|
if (message is IServiceResponse response)
|
|
{
|
|
StatusCode status = response.ResponseHeader.ServiceResult;
|
|
string summary = $"status={status} ts={response.ResponseHeader.Timestamp:O}";
|
|
return new DecodedMessage(name, summary, status, response);
|
|
}
|
|
|
|
return new DecodedMessage(name, $"{name} body={byteCount}B", null, message);
|
|
}
|
|
|
|
private static string CreateRequestSummary(IServiceRequest request, string typeName, int byteCount)
|
|
{
|
|
string detail = CreateKnownRequestDetail(request);
|
|
string header = request.RequestHeader == null
|
|
? string.Empty
|
|
: $"handle={request.RequestHeader.RequestHandle} audit={request.RequestHeader.AuditEntryId ?? string.Empty}";
|
|
|
|
if (detail.Length == 0)
|
|
{
|
|
detail = $"{typeName} body={byteCount}B";
|
|
}
|
|
|
|
return header.Length == 0 ? detail : $"{header} {detail}";
|
|
}
|
|
|
|
private static string CreateKnownRequestDetail(IServiceRequest request)
|
|
{
|
|
string typeName = request.GetType().Name;
|
|
return typeName switch
|
|
{
|
|
"ReadRequest" => FormatCountAndValue(
|
|
request,
|
|
"NodesToRead",
|
|
"nodes",
|
|
"MaxAge",
|
|
"maxAge"),
|
|
"BrowseRequest" => FormatCountAndValue(
|
|
request,
|
|
"NodesToBrowse",
|
|
"nodes",
|
|
"View",
|
|
"view"),
|
|
"WriteRequest" => FormatCount(request, "NodesToWrite", "nodes"),
|
|
"CallRequest" => FormatCount(request, "MethodsToCall", "methods"),
|
|
"HistoryReadRequest" => FormatCount(request, "NodesToRead", "nodes"),
|
|
"CreateMonitoredItemsRequest" => FormatCount(request, "ItemsToCreate", "items"),
|
|
"DeleteMonitoredItemsRequest" => FormatCount(request, "MonitoredItemIds", "items"),
|
|
"PublishRequest" => FormatCount(request, "SubscriptionAcknowledgements", "acks"),
|
|
_ => string.Empty
|
|
};
|
|
}
|
|
|
|
private static string FormatCount(
|
|
IEncodeable message,
|
|
string propertyName,
|
|
string itemName)
|
|
{
|
|
int? count = GetCollectionCount(message, propertyName);
|
|
return count.HasValue ? $"{count.Value} {itemName}" : string.Empty;
|
|
}
|
|
|
|
private static string FormatCountAndValue(
|
|
IEncodeable message,
|
|
string countPropertyName,
|
|
string itemName,
|
|
string valuePropertyName,
|
|
string valueName)
|
|
{
|
|
int? count = GetCollectionCount(message, countPropertyName);
|
|
string? value = GetPropertyString(message, valuePropertyName);
|
|
if (!count.HasValue)
|
|
{
|
|
return string.Empty;
|
|
}
|
|
|
|
return value == null
|
|
? $"{count.Value} {itemName}, {valueName}=null"
|
|
: $"{count.Value} {itemName}, {valueName}={value}";
|
|
}
|
|
|
|
private static int? GetCollectionCount(IEncodeable message, string propertyName)
|
|
{
|
|
object? value = message.GetType().GetProperty(propertyName)?.GetValue(message);
|
|
return value switch
|
|
{
|
|
ICollection collection => collection.Count,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private static string? GetPropertyString(IEncodeable message, string propertyName)
|
|
{
|
|
object? value = message.GetType().GetProperty(propertyName)?.GetValue(message);
|
|
return value?.ToString();
|
|
}
|
|
|
|
private static void AddRequestAnnotations(
|
|
Dictionary<string, string?> annotations,
|
|
IEncodeable? message)
|
|
{
|
|
if (message is not IServiceRequest request || request.RequestHeader == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
annotations["RequestHandle"] = request.RequestHeader.RequestHandle.ToString(CultureInfo.InvariantCulture);
|
|
annotations["AuditEntryId"] = request.RequestHeader.AuditEntryId;
|
|
}
|
|
|
|
private static byte[] Concatenate(List<byte[]> chunks)
|
|
{
|
|
byte[] result = new byte[GetTotalLength(chunks)];
|
|
int offset = 0;
|
|
foreach (byte[] chunk in chunks)
|
|
{
|
|
chunk.AsSpan().CopyTo(result.AsSpan(offset));
|
|
offset += chunk.Length;
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
private static int GetTotalLength(List<byte[]> chunks)
|
|
{
|
|
int length = 0;
|
|
foreach (byte[] chunk in chunks)
|
|
{
|
|
length += chunk.Length;
|
|
}
|
|
|
|
return length;
|
|
}
|
|
|
|
private void RemoveChannelState(uint channelId)
|
|
{
|
|
List<(uint channelId, uint requestId)> removeKeys = [];
|
|
foreach ((uint pendingChannelId, uint pendingRequestId) in m_pending.Keys)
|
|
{
|
|
if (pendingChannelId == channelId)
|
|
{
|
|
removeKeys.Add((pendingChannelId, pendingRequestId));
|
|
}
|
|
}
|
|
|
|
foreach ((uint pendingChannelId, uint pendingRequestId) in removeKeys)
|
|
{
|
|
m_pending.Remove((pendingChannelId, pendingRequestId));
|
|
}
|
|
|
|
if (m_channels.Remove(channelId, out OfflineSecureChannel? channel))
|
|
{
|
|
channel.Dispose();
|
|
}
|
|
}
|
|
|
|
private sealed class PendingServiceCall
|
|
{
|
|
public PendingServiceCall(DateTimeOffset requestTimestamp)
|
|
{
|
|
RequestTimestamp = requestTimestamp;
|
|
}
|
|
|
|
public DateTimeOffset RequestTimestamp { get; }
|
|
|
|
public DateTimeOffset? ResponseTimestamp { get; set; }
|
|
|
|
public List<byte[]> RequestChunks { get; } = [];
|
|
|
|
public List<byte[]> ResponseChunks { get; } = [];
|
|
|
|
public bool RequestIsComplete { get; set; }
|
|
|
|
public bool ResponseIsComplete { get; set; }
|
|
|
|
public uint TokenId { get; set; }
|
|
|
|
public DecodedMessage? Request { get; set; }
|
|
|
|
public DecodedMessage? Response { get; set; }
|
|
}
|
|
|
|
private readonly struct DecodedMessage
|
|
{
|
|
public DecodedMessage(
|
|
string? name,
|
|
string? summary,
|
|
StatusCode? responseStatus,
|
|
IEncodeable? message)
|
|
{
|
|
Name = name;
|
|
Summary = summary;
|
|
ResponseStatus = responseStatus;
|
|
Message = message;
|
|
}
|
|
|
|
public string? Name { get; }
|
|
|
|
public string? Summary { get; }
|
|
|
|
public StatusCode? ResponseStatus { get; }
|
|
|
|
public IEncodeable? Message { get; }
|
|
|
|
public static DecodedMessage Undecoded(string name, int byteCount)
|
|
{
|
|
return new DecodedMessage(name, $"(undecoded: {byteCount} bytes)", null, null);
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Minimal <see cref="ITelemetryContext"/> implementation that
|
|
/// hands out <see cref="NullLogger.Instance"/> for everything.
|
|
/// </summary>
|
|
private sealed class NoopTelemetryContext : ITelemetryContext
|
|
{
|
|
public static NoopTelemetryContext Instance { get; } = new();
|
|
|
|
public ILoggerFactory LoggerFactory => NullLoggerFactory.Instance;
|
|
|
|
public ActivitySource ActivitySource => s_activitySource;
|
|
|
|
public Meter CreateMeter()
|
|
{
|
|
return new Meter("Opc.Ua.Bindings.Pcap.ServiceCallReassembler");
|
|
}
|
|
|
|
private NoopTelemetryContext()
|
|
{
|
|
}
|
|
|
|
private static readonly ActivitySource s_activitySource
|
|
= new("Opc.Ua.Bindings.Pcap.ServiceCallReassembler");
|
|
}
|
|
|
|
private readonly Dictionary<uint, OfflineSecureChannel> m_channels = [];
|
|
private readonly Dictionary<(uint channelId, uint requestId), PendingServiceCall> m_pending = [];
|
|
private readonly List<DecodedServiceCall> m_completed = [];
|
|
private readonly ServiceMessageContext m_messageContext;
|
|
private readonly ILoggerFactory m_loggerFactory;
|
|
private readonly ILogger m_logger;
|
|
}
|
|
}
|