/* ========================================================================
* 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);
}
}