/* ======================================================================== * 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.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua.Bindings.Pcap.Audit; using Opc.Ua.Bindings.Pcap.Capture.Sources; using Opc.Ua.Bindings.Pcap.KeyLog; using Opc.Ua.Bindings.Pcap.Models; namespace Opc.Ua.Bindings.Pcap.Capture { /// /// Owns the set of active s and gates new /// session creation behind safety caps (concurrent active sessions, /// LRU eviction of completed sessions). Registered as a singleton in /// DI; and are the /// primary entry points for capture tools. /// /// /// Sources are created through the supplied /// . The default factory creates /// the four built-in sources (NIC, in-proc client, in-proc server, /// replay). Custom sources can be plugged in by registering a /// different factory in DI. /// public sealed class CaptureSessionManager : IAsyncDisposable { /// /// Default cap on the number of concurrent active /// (Starting/Running) sessions when none is supplied to the /// constructor. /// public const int DefaultMaxActiveSessions = 8; /// /// Maximum number of retained sessions before LRU eviction kicks in. /// public const int MaxRetainedSessions = 32; private readonly ConcurrentDictionary m_sessions = new( StringComparer.OrdinalIgnoreCase); private readonly ICaptureSourceFactory m_sourceFactory; private readonly ILoggerFactory m_loggerFactory; private readonly ILogger m_logger; private readonly IPcapAuditSink? m_auditSink; private readonly IKeyEscrowProvider? m_escrowProvider; private readonly string m_baseFolder; private readonly Lock m_lock = new(); private int m_disposed; private readonly ConcurrentDictionary m_escrowSessions = new( StringComparer.OrdinalIgnoreCase); /// /// Constructs a manager that uses the supplied source factory and /// stores per-session artifacts under /// Path.GetTempPath()/opcua-pcap. /// /// /// Any argument is null. /// public CaptureSessionManager( ICaptureSourceFactory sourceFactory, ILoggerFactory? loggerFactory = null) : this( sourceFactory, Path.Combine(Path.GetTempPath(), "opcua-pcap"), loggerFactory, maxActiveSessions: null, auditSink: null, escrowProvider: null) { } /// /// Constructs a manager with an explicit base folder for session /// artifacts. /// /// /// Any argument is null. /// public CaptureSessionManager( ICaptureSourceFactory sourceFactory, string baseFolder, ILoggerFactory? loggerFactory = null) : this( sourceFactory, baseFolder, loggerFactory, maxActiveSessions: null, auditSink: null, escrowProvider: null) { } /// /// Constructs a manager with an explicit base folder and an /// optional cap on the number of concurrent active sessions. /// Passing null for /// uses . /// /// /// is null. /// /// /// is null or whitespace. /// /// /// is less than 1. /// public CaptureSessionManager( ICaptureSourceFactory sourceFactory, string baseFolder, ILoggerFactory? loggerFactory, int? maxActiveSessions) : this( sourceFactory, baseFolder, loggerFactory, maxActiveSessions, auditSink: null, escrowProvider: null) { } /// /// Constructs a manager with an explicit base folder, an optional /// cap on the number of concurrent active sessions, and an optional /// audit sink for security-sensitive lifecycle events. /// /// /// is null. /// /// /// is null or whitespace. /// /// /// is less than 1. /// public CaptureSessionManager( ICaptureSourceFactory sourceFactory, string baseFolder, ILoggerFactory? loggerFactory, int? maxActiveSessions, IPcapAuditSink? auditSink, IKeyEscrowProvider? escrowProvider = null) { ArgumentNullException.ThrowIfNull(sourceFactory); ArgumentException.ThrowIfNullOrWhiteSpace(baseFolder); int limit = maxActiveSessions ?? DefaultMaxActiveSessions; if (limit < 1) { throw new ArgumentOutOfRangeException( nameof(maxActiveSessions), limit, "Maximum active sessions must be at least 1."); } m_sourceFactory = sourceFactory; m_baseFolder = baseFolder; m_loggerFactory = loggerFactory ?? NullLoggerFactory.Instance; m_logger = m_loggerFactory.CreateLogger(); m_auditSink = auditSink; m_escrowProvider = escrowProvider; MaxActiveSessions = limit; Directory.CreateDirectory(m_baseFolder); if (!OperatingSystem.IsWindows()) { File.SetUnixFileMode( m_baseFolder, UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute); } } /// /// Maximum number of concurrent active (Starting/Running) /// sessions enforced by this manager instance. Defaults to /// . /// public int MaxActiveSessions { get; } /// /// Creates and starts a new capture session. Throws if the active /// session cap would be exceeded. /// /// /// is null. /// /// /// The cap on concurrent active sessions has been reached, or the /// requested source cannot be created. /// /// /// specifies a session folder outside the /// configured base folder. /// public async ValueTask StartAsync( StartCaptureRequest request, CancellationToken ct) { ArgumentNullException.ThrowIfNull(request); ThrowIfDisposed(); int active = m_sessions.Values.Count(s => s.State is CaptureSessionState.Starting or CaptureSessionState.Running); if (active >= MaxActiveSessions) { throw new PcapDiagnosticsException( $"Cannot start a new capture session: {active} sessions " + $"are already active (max {MaxActiveSessions})."); } string id = Guid.NewGuid().ToString("N"); string folder = request.SessionFolder is { Length: > 0 } ? request.SessionFolder : Path.Combine(m_baseFolder, id); folder = ValidateAndResolveSessionFolder(folder, m_baseFolder); Directory.CreateDirectory(folder); StartCaptureRequest resolvedRequest = new() { Source = request.Source, InterfaceName = request.InterfaceName, BpfFilter = request.BpfFilter, Promiscuous = request.Promiscuous, PcapFilePath = request.PcapFilePath, KeyLogFilePath = request.KeyLogFilePath, MaxBytes = request.MaxBytes, MaxFrames = request.MaxFrames, MaxDurationSeconds = request.MaxDurationSeconds, SessionFolder = folder }; ICaptureSource source = m_sourceFactory.Create( request.Source, folder, m_loggerFactory); IKeyEscrowSession? escrowSession = null; var session = new CaptureSession( id, request.Source, source, folder, resolvedRequest, m_loggerFactory.CreateLogger()); EvictIfNeeded(); try { if (m_escrowProvider is not null) { escrowSession = await m_escrowProvider.BeginSessionAsync(id, folder, ct).ConfigureAwait(false); } await session.StartAsync(ct).ConfigureAwait(false); m_sessions[id] = session; if (escrowSession is not null) { m_escrowSessions[id] = escrowSession; escrowSession = null; } await AuditAsync( PcapAuditEventKind.StartCapture, id, folder, remoteEndpoint: null, properties: CreateStartCaptureProperties(request), ct).ConfigureAwait(false); return session; } catch { // CA1508: escrowSession is non-null on the path where BeginSessionAsync // succeeded but a subsequent StartAsync or dictionary insert threw; the // analyzer does not model that path. #pragma warning disable CA1508 if (escrowSession is not null) { await escrowSession.DisposeAsync().ConfigureAwait(false); } #pragma warning restore CA1508 await session.DisposeAsync().ConfigureAwait(false); throw; } } /// /// Validates that resolves to a path under /// . Defends against path-traversal in /// user-supplied SessionFolder values that could otherwise /// write capture artifacts (including keylog) to arbitrary filesystem /// locations. /// /// /// Thrown when resolves outside /// . /// private static string ValidateAndResolveSessionFolder( string folder, string baseFolder) { string fullBase = Path.GetFullPath(baseFolder); string rootedFolder = Path.IsPathRooted(folder) ? folder : Path.Combine(fullBase, folder); string fullFolder = Path.GetFullPath(rootedFolder); if (!fullBase.EndsWith(Path.DirectorySeparatorChar)) { fullBase += Path.DirectorySeparatorChar; } if (!fullFolder.StartsWith(fullBase, StringComparison.OrdinalIgnoreCase) && !string.Equals(fullFolder + Path.DirectorySeparatorChar, fullBase, StringComparison.OrdinalIgnoreCase)) { throw new ArgumentException( $"SessionFolder '{folder}' resolves to '{fullFolder}' which is " + $"outside the configured BaseFolder '{baseFolder}'. Capture " + "artifacts must remain inside the base folder.", nameof(folder)); } return fullFolder; } /// /// Looks up a session by id. /// /// /// No session with that id exists. /// public CaptureSession Get(string sessionId) { ArgumentException.ThrowIfNullOrWhiteSpace(sessionId); if (!m_sessions.TryGetValue(sessionId, out CaptureSession? session)) { throw new PcapDiagnosticsException( $"Capture session '{sessionId}' was not found."); } return session; } /// /// Lists every session currently tracked by the manager. /// public IReadOnlyList List() { return [.. m_sessions.Values]; } /// /// Stops the named session. /// public async ValueTask StopAsync(string sessionId, CancellationToken ct) { CaptureSession session = Get(sessionId); await StopSessionAsync(session, ct).ConfigureAwait(false); await AuditAsync( PcapAuditEventKind.StopCapture, session.Id, session.SessionFolder, remoteEndpoint: null, properties: CreateStopCaptureProperties(session), ct).ConfigureAwait(false); return session; } /// /// Stops and removes a session by id. /// public async ValueTask RemoveAsync(string sessionId, CancellationToken ct) { if (!m_sessions.TryRemove(sessionId, out CaptureSession? session)) { return; } bool shouldAuditStop = session.State is CaptureSessionState.Starting or CaptureSessionState.Running; try { await StopSessionAsync(session, ct).ConfigureAwait(false); if (shouldAuditStop) { await AuditAsync( PcapAuditEventKind.StopCapture, session.Id, session.SessionFolder, remoteEndpoint: null, properties: CreateStopCaptureProperties(session), ct).ConfigureAwait(false); } } catch { // Best effort. } await session.DisposeAsync().ConfigureAwait(false); } /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref m_disposed, 1) != 0) { return; } foreach (CaptureSession session in m_sessions.Values) { try { bool shouldAuditStop = session.State is CaptureSessionState.Starting or CaptureSessionState.Running; if (shouldAuditStop) { await StopSessionAsync(session, CancellationToken.None).ConfigureAwait(false); } else { await DisposeEscrowSessionAsync(session.Id, CancellationToken.None).ConfigureAwait(false); } await session.DisposeAsync().ConfigureAwait(false); if (shouldAuditStop) { await AuditAsync( PcapAuditEventKind.StopCapture, session.Id, session.SessionFolder, remoteEndpoint: null, properties: CreateStopCaptureProperties(session), CancellationToken.None).ConfigureAwait(false); } } catch { // best effort } } m_sessions.Clear(); m_escrowSessions.Clear(); } private async ValueTask StopSessionAsync(CaptureSession session, CancellationToken ct) { await session.StopAsync(ct).ConfigureAwait(false); await EscrowCapturedKeyMaterialAsync(session, ct).ConfigureAwait(false); await DisposeEscrowSessionAsync(session.Id, ct).ConfigureAwait(false); } private async ValueTask EscrowCapturedKeyMaterialAsync(CaptureSession session, CancellationToken ct) { if (m_escrowProvider is null || m_escrowProvider is DiskKeyEscrowProvider || !m_escrowSessions.TryGetValue(session.Id, out IKeyEscrowSession? escrowSession)) { return; } await foreach (ChannelKeyMaterial material in session.Source.ReadKeyMaterialAsync(ct) .WithCancellation(ct) .ConfigureAwait(false)) { await escrowSession.EscrowAsync(material, ct).ConfigureAwait(false); } } private async ValueTask DisposeEscrowSessionAsync(string sessionId, CancellationToken ct) { if (!m_escrowSessions.TryRemove(sessionId, out IKeyEscrowSession? escrowSession)) { return; } try { await escrowSession.FlushAsync(ct).ConfigureAwait(false); } finally { await escrowSession.DisposeAsync().ConfigureAwait(false); } } private ValueTask AuditAsync( PcapAuditEventKind kind, string sessionId, string resourcePath, string? remoteEndpoint, IReadOnlyDictionary? properties, CancellationToken ct) { if (m_auditSink is null) { return ValueTask.CompletedTask; } return m_auditSink.OnEventAsync( new PcapAuditEvent( kind, DateTimeOffset.UtcNow, sessionId, resourcePath, remoteEndpoint, properties), ct); } private static Dictionary CreateStartCaptureProperties(StartCaptureRequest request) { var properties = new Dictionary { ["Source"] = request.Source.ToString() }; AddIfNotEmpty(properties, "InterfaceName", request.InterfaceName); AddIfNotEmpty(properties, "PcapFilePath", request.PcapFilePath); return properties; } private static Dictionary CreateStopCaptureProperties(CaptureSession session) { return new Dictionary { ["Source"] = session.SourceKind.ToString(), ["FrameCount"] = session.Source.FrameCount.ToString(System.Globalization.CultureInfo.InvariantCulture), ["ByteCount"] = session.Source.ByteCount.ToString(System.Globalization.CultureInfo.InvariantCulture) }; } private static void AddIfNotEmpty(Dictionary properties, string name, string? value) { if (!string.IsNullOrWhiteSpace(value)) { properties[name] = value; } } private void EvictIfNeeded() { lock (m_lock) { while (m_sessions.Count >= MaxRetainedSessions) { CaptureSession? oldest = m_sessions.Values .Where(s => s.State is CaptureSessionState.Completed or CaptureSessionState.Failed) .OrderBy(s => s.StoppedAt ?? DateTimeOffset.MaxValue) .FirstOrDefault(); if (oldest is null) { // No completed sessions to evict; let the new one in // anyway - active cap above is the hard ceiling. break; } m_sessions.TryRemove(oldest.Id, out _); _ = oldest.DisposeAsync().AsTask(); m_logger.LogInformation( "Evicted completed capture session {SessionId} (LRU).", oldest.Id); } } } private void ThrowIfDisposed() { if (m_disposed != 0) { throw new ObjectDisposedException(nameof(CaptureSessionManager)); } } } /// /// Creates instances for /// . The default DI registration /// uses DefaultCaptureSourceFactory. /// public interface ICaptureSourceFactory { /// /// Creates a source for the requested kind, writing artifacts to /// the supplied folder. /// ICaptureSource Create( CaptureSourceKind kind, string sessionFolder, ILoggerFactory loggerFactory); } }