diff --git a/src/ChatServer/ChatClient.cs b/src/ChatServer/ChatClient.cs
index 91ca51e885..8b09107c16 100644
--- a/src/ChatServer/ChatClient.cs
+++ b/src/ChatServer/ChatClient.cs
@@ -79,6 +79,11 @@ public byte Index
///
public DateTime LastActivity { get; private set; }
+ ///
+ /// Gets the connection of this client, so that its traffic can be captured.
+ ///
+ internal IConnection? Connection => this._connection;
+
///
public async ValueTask SendMessageAsync(byte senderId, string message)
{
diff --git a/src/ChatServer/ChatClientConnectionInfo.cs b/src/ChatServer/ChatClientConnectionInfo.cs
new file mode 100644
index 0000000000..f1620c1ae3
--- /dev/null
+++ b/src/ChatServer/ChatClientConnectionInfo.cs
@@ -0,0 +1,81 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.ChatServer;
+
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Network;
+using MUnique.OpenMU.Network.Analyzer;
+using MUnique.OpenMU.Network.PlugIns;
+
+///
+/// The of a client of the chat server.
+///
+internal sealed class ChatClientConnectionInfo : ICapturedConnectionInfo
+{
+ private readonly ChatClient _client;
+
+ private readonly IConnection _connection;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The client.
+ /// The connection of the client.
+ /// The identifier of the chat server.
+ public ChatClientConnectionInfo(ChatClient client, IConnection connection, int serverId)
+ {
+ this._client = client;
+ this._connection = connection;
+ this.ServerId = serverId;
+ }
+
+ ///
+ public Guid Id => this._connection.Id;
+
+ ///
+ public ServerType ServerType => ServerType.ChatServer;
+
+ ///
+ public int ServerId { get; }
+
+ ///
+ /// Gets the name of the account. The chat server doesn't know the account of its clients,
+ /// so it's always .
+ ///
+ public string? AccountName => null;
+
+ ///
+ /// Gets the name of the character. The chat clients authenticate with the name of the
+ /// character which joined the chat room.
+ ///
+ public string? CharacterName => this._client.Nickname;
+
+ ///
+ public string? RemoteEndPoint => this._connection.EndPoint?.ToString();
+
+ ///
+ /// Gets the client version. The chat protocol doesn't depend on it, so the default
+ /// version is used.
+ ///
+ public ClientVersion ClientVersion => default;
+
+ ///
+ public PacketDefinitionSet DefinitionSet => PacketDefinitionSet.ChatServer;
+
+ ///
+ public bool IsConnected => this._connection.Connected;
+
+ ///
+ public string DisplayName => this.CharacterName ?? this.RemoteEndPoint ?? this.Id.ToString();
+
+ ///
+ public void AddCaptureSink(IPacketCaptureSink sink) => this._connection.AddCaptureSink(sink);
+
+ ///
+ public void RemoveCaptureSink(IPacketCaptureSink sink) => this._connection.RemoveCaptureSink(sink);
+
+ ///
+ public ValueTask DisconnectAsync() => this._client.LogOffAsync();
+}
diff --git a/src/ChatServer/ChatServer.cs b/src/ChatServer/ChatServer.cs
index bcc9399539..ccbd84f235 100644
--- a/src/ChatServer/ChatServer.cs
+++ b/src/ChatServer/ChatServer.cs
@@ -13,13 +13,14 @@ namespace MUnique.OpenMU.ChatServer;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.Network;
+using MUnique.OpenMU.Network.Analyzer;
using MUnique.OpenMU.PlugIns;
using Timer = System.Timers.Timer;
///
/// Chat Server Listener that accepts incoming connections.
///
-public sealed class ChatServer : IChatServer, IDisposable
+public sealed class ChatServer : IChatServer, IDisposable, IConnectionSource
{
private readonly ChatRoomManager _manager;
private readonly ILogger _logger;
@@ -95,6 +96,20 @@ private set
private ChatServerSettings Settings => this._settings ?? throw new InvalidOperationException("The server was not initialized before");
+ ///
+ public ValueTask> GetConnectionsAsync()
+ {
+ IReadOnlyList result = this._connectedClients
+ .OfType()
+ .Select(client => client.Connection is { } connection
+ ? new ChatClientConnectionInfo(client, connection, this.Id)
+ : null)
+ .Where(info => info is not null)
+ .Select(info => (ICapturedConnectionInfo)info!)
+ .ToList();
+ return ValueTask.FromResult(result);
+ }
+
///
public async ValueTask RegisterClientAsync(ushort roomId, string clientName)
{
diff --git a/src/ChatServer/MUnique.OpenMU.ChatServer.csproj b/src/ChatServer/MUnique.OpenMU.ChatServer.csproj
index 04c356c204..3e8c8c11f8 100644
--- a/src/ChatServer/MUnique.OpenMU.ChatServer.csproj
+++ b/src/ChatServer/MUnique.OpenMU.ChatServer.csproj
@@ -25,6 +25,7 @@
+
diff --git a/src/ConnectServer/ClientConnectionInfo.cs b/src/ConnectServer/ClientConnectionInfo.cs
new file mode 100644
index 0000000000..6a1855e9e1
--- /dev/null
+++ b/src/ConnectServer/ClientConnectionInfo.cs
@@ -0,0 +1,76 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.ConnectServer;
+
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Network;
+using MUnique.OpenMU.Network.Analyzer;
+using MUnique.OpenMU.Network.PlugIns;
+
+///
+/// The of a client of the connect server.
+///
+internal sealed class ClientConnectionInfo : ICapturedConnectionInfo
+{
+ private readonly Client _client;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The client.
+ /// The identifier of the connect server.
+ /// The client version of the connect server.
+ public ClientConnectionInfo(Client client, int serverId, ClientVersion clientVersion)
+ {
+ this._client = client;
+ this.ServerId = serverId;
+ this.ClientVersion = clientVersion;
+ }
+
+ ///
+ public Guid Id => this._client.Connection.Id;
+
+ ///
+ public ServerType ServerType => ServerType.ConnectServer;
+
+ ///
+ public int ServerId { get; }
+
+ ///
+ /// Gets the name of the account. The clients of a connect server are never logged in, so
+ /// it's always .
+ ///
+ public string? AccountName => null;
+
+ ///
+ /// Gets the name of the character. The clients of a connect server never selected one, so
+ /// it's always .
+ ///
+ public string? CharacterName => null;
+
+ ///
+ public string? RemoteEndPoint => this._client.Connection.EndPoint?.ToString();
+
+ ///
+ public ClientVersion ClientVersion { get; }
+
+ ///
+ public PacketDefinitionSet DefinitionSet => PacketDefinitionSet.ConnectServer;
+
+ ///
+ public bool IsConnected => this._client.Connection.Connected;
+
+ ///
+ public string DisplayName => this.RemoteEndPoint ?? this.Id.ToString();
+
+ ///
+ public void AddCaptureSink(IPacketCaptureSink sink) => this._client.Connection.AddCaptureSink(sink);
+
+ ///
+ public void RemoveCaptureSink(IPacketCaptureSink sink) => this._client.Connection.RemoveCaptureSink(sink);
+
+ ///
+ public ValueTask DisconnectAsync() => this._client.Connection.DisconnectAsync();
+}
diff --git a/src/ConnectServer/ConnectServer.cs b/src/ConnectServer/ConnectServer.cs
index ad249dcb82..c5e0d4a753 100644
--- a/src/ConnectServer/ConnectServer.cs
+++ b/src/ConnectServer/ConnectServer.cs
@@ -11,12 +11,13 @@ namespace MUnique.OpenMU.ConnectServer;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Network.Analyzer;
using MUnique.OpenMU.Network.PlugIns;
///
/// The connect server.
///
-public class ConnectServer : IConnectServer, OpenMU.Interfaces.IConnectServer
+public class ConnectServer : IConnectServer, OpenMU.Interfaces.IConnectServer, IConnectionSource
{
private readonly ILoggerFactory _loggerFactory;
private readonly ILogger _logger;
@@ -114,6 +115,15 @@ private set
///
internal ClientListener ClientListener { get; }
+ ///
+ public ValueTask> GetConnectionsAsync()
+ {
+ IReadOnlyList result = this.ClientListener.Clients
+ .Select(client => new ClientConnectionInfo(client, this.Id, this.ClientVersion))
+ .ToList();
+ return ValueTask.FromResult(result);
+ }
+
///
public async Task StartAsync(CancellationToken cancellationToken)
{
diff --git a/src/ConnectServer/MUnique.OpenMU.ConnectServer.csproj b/src/ConnectServer/MUnique.OpenMU.ConnectServer.csproj
index 407e5ae990..9d5ae8179a 100644
--- a/src/ConnectServer/MUnique.OpenMU.ConnectServer.csproj
+++ b/src/ConnectServer/MUnique.OpenMU.ConnectServer.csproj
@@ -20,6 +20,7 @@
+
diff --git a/src/GameServer/GameServer.cs b/src/GameServer/GameServer.cs
index f7cbc62df0..723f6cfbc1 100644
--- a/src/GameServer/GameServer.cs
+++ b/src/GameServer/GameServer.cs
@@ -17,7 +17,9 @@ namespace MUnique.OpenMU.GameServer;
using MUnique.OpenMU.GameLogic.Views.Guild;
using MUnique.OpenMU.GameLogic.Views.Login;
using MUnique.OpenMU.GameLogic.Views.Messenger;
+using MUnique.OpenMU.GameServer.RemoteView;
using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Network.Analyzer;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.PlugIns;
using Nito.AsyncEx;
@@ -25,7 +27,7 @@ namespace MUnique.OpenMU.GameServer;
///
/// The game server to which game clients can connect.
///
-public sealed class GameServer : IGameServer, IDisposable, IGameServerContextProvider
+public sealed class GameServer : IGameServer, IDisposable, IGameServerContextProvider, IConnectionSource
{
private readonly ILogger _logger;
@@ -421,6 +423,20 @@ public async ValueTask GuildHostilityChangedAsync(uint guildIdA, IReadOnlyList
+ public async ValueTask> GetConnectionsAsync()
+ {
+ var players = await this._gameContext.GetPlayersAsync().ConfigureAwait(false);
+ return players
+ .OfType()
+ .Select(player => player.Connection is { } connection
+ ? new RemotePlayerConnectionInfo(player, connection, this.Id)
+ : null)
+ .Where(info => info is not null)
+ .Select(info => (ICapturedConnectionInfo)info!)
+ .ToList();
+ }
+
///
/// Creates an instance of with the data of this instance.
///
diff --git a/src/GameServer/MUnique.OpenMU.GameServer.csproj b/src/GameServer/MUnique.OpenMU.GameServer.csproj
index 669abfa986..321de7844e 100644
--- a/src/GameServer/MUnique.OpenMU.GameServer.csproj
+++ b/src/GameServer/MUnique.OpenMU.GameServer.csproj
@@ -23,6 +23,7 @@
+
diff --git a/src/GameServer/RemotePlayerConnectionInfo.cs b/src/GameServer/RemotePlayerConnectionInfo.cs
new file mode 100644
index 0000000000..d3c3e22688
--- /dev/null
+++ b/src/GameServer/RemotePlayerConnectionInfo.cs
@@ -0,0 +1,73 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.GameServer;
+
+using MUnique.OpenMU.GameServer.RemoteView;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Network;
+using MUnique.OpenMU.Network.Analyzer;
+using MUnique.OpenMU.Network.PlugIns;
+
+///
+/// The of a .
+///
+internal sealed class RemotePlayerConnectionInfo : ICapturedConnectionInfo
+{
+ private readonly RemotePlayer _player;
+
+ private readonly IConnection _connection;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The player.
+ /// The connection of the player.
+ /// The identifier of the game server.
+ public RemotePlayerConnectionInfo(RemotePlayer player, IConnection connection, int serverId)
+ {
+ this._player = player;
+ this._connection = connection;
+ this.ServerId = serverId;
+ }
+
+ ///
+ public Guid Id => this._connection.Id;
+
+ ///
+ public ServerType ServerType => ServerType.GameServer;
+
+ ///
+ public int ServerId { get; }
+
+ ///
+ public string? AccountName => this._player.Account?.LoginName;
+
+ ///
+ public string? CharacterName => this._player.SelectedCharacter?.Name;
+
+ ///
+ public string? RemoteEndPoint => this._connection.EndPoint?.ToString();
+
+ ///
+ public ClientVersion ClientVersion => this._player.ClientVersion;
+
+ ///
+ public PacketDefinitionSet DefinitionSet => PacketDefinitionSet.GameServer;
+
+ ///
+ public bool IsConnected => this._connection.Connected;
+
+ ///
+ public string DisplayName => this.CharacterName ?? this.AccountName ?? this.RemoteEndPoint ?? this.Id.ToString();
+
+ ///
+ public void AddCaptureSink(IPacketCaptureSink sink) => this._connection.AddCaptureSink(sink);
+
+ ///
+ public void RemoveCaptureSink(IPacketCaptureSink sink) => this._connection.RemoveCaptureSink(sink);
+
+ ///
+ public ValueTask DisconnectAsync() => this._player.DisconnectAsync();
+}
diff --git a/src/Network/Analyzer/ICapturedConnectionInfo.cs b/src/Network/Analyzer/ICapturedConnectionInfo.cs
new file mode 100644
index 0000000000..ad32a49619
--- /dev/null
+++ b/src/Network/Analyzer/ICapturedConnectionInfo.cs
@@ -0,0 +1,87 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network.Analyzer;
+
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Network.PlugIns;
+
+///
+/// Information about a connection of a server, whose traffic can be captured.
+///
+///
+/// The implementations are provided by the servers themselves, so that the network connection
+/// stays inside the server which owns it.
+///
+public interface ICapturedConnectionInfo
+{
+ ///
+ /// Gets the identifier of the connection.
+ ///
+ Guid Id { get; }
+
+ ///
+ /// Gets the type of the server which handles this connection.
+ ///
+ ServerType ServerType { get; }
+
+ ///
+ /// Gets the identifier of the server which handles this connection.
+ ///
+ int ServerId { get; }
+
+ ///
+ /// Gets the name of the account, if the client is logged in.
+ ///
+ string? AccountName { get; }
+
+ ///
+ /// Gets the name of the selected character, if one is selected.
+ ///
+ string? CharacterName { get; }
+
+ ///
+ /// Gets the remote endpoint of the connection.
+ ///
+ string? RemoteEndPoint { get; }
+
+ ///
+ /// Gets the client version which currently applies to this connection.
+ ///
+ ClientVersion ClientVersion { get; }
+
+ ///
+ /// Gets the set of packet definitions which applies to this connection.
+ ///
+ PacketDefinitionSet DefinitionSet { get; }
+
+ ///
+ /// Gets a value indicating whether the connection is still connected.
+ ///
+ bool IsConnected { get; }
+
+ ///
+ /// Gets the name which should be shown for this connection. It's the character name, the
+ /// account name or the remote endpoint - whatever is known.
+ ///
+ string DisplayName { get; }
+
+ ///
+ /// Adds a sink which gets the data packets of this connection.
+ ///
+ /// The sink.
+ void AddCaptureSink(IPacketCaptureSink sink);
+
+ ///
+ /// Removes a previously added sink.
+ ///
+ /// The sink.
+ void RemoveCaptureSink(IPacketCaptureSink sink);
+
+ ///
+ /// Disconnects the client of this connection.
+ ///
+ /// The async task.
+ ValueTask DisconnectAsync();
+}
diff --git a/src/Network/Analyzer/IConnectionSource.cs b/src/Network/Analyzer/IConnectionSource.cs
new file mode 100644
index 0000000000..d5ffcc206d
--- /dev/null
+++ b/src/Network/Analyzer/IConnectionSource.cs
@@ -0,0 +1,22 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network.Analyzer;
+
+///
+/// Interface for a server which can provide the connections of its clients, so that their
+/// traffic can be captured.
+///
+///
+/// It's implemented by the servers themselves. A server which doesn't implement it, e.g.
+/// because it's just a proxy to a server in another process, is simply not listed.
+///
+public interface IConnectionSource
+{
+ ///
+ /// Gets the currently connected clients of this server.
+ ///
+ /// The currently connected clients of this server.
+ ValueTask> GetConnectionsAsync();
+}
diff --git a/src/Network/Analyzer/ILiveCapturedConnection.cs b/src/Network/Analyzer/ILiveCapturedConnection.cs
new file mode 100644
index 0000000000..959e25af8a
--- /dev/null
+++ b/src/Network/Analyzer/ILiveCapturedConnection.cs
@@ -0,0 +1,37 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network.Analyzer;
+
+///
+/// A which captures the traffic of a connection which is
+/// currently handled by one of our servers.
+///
+public interface ILiveCapturedConnection : ICapturedConnection
+{
+ ///
+ /// Occurs when packets have been added or removed.
+ ///
+ event EventHandler? PacketsChanged;
+
+ ///
+ /// Gets the information about the captured connection.
+ ///
+ ICapturedConnectionInfo ConnectionInfo { get; }
+
+ ///
+ /// Gets a snapshot of the currently captured packets.
+ ///
+ /// A snapshot of the currently captured packets.
+ ///
+ /// The packets are captured on the network threads of the connection, so the
+ /// must not be enumerated directly.
+ ///
+ IReadOnlyList GetPackets();
+
+ ///
+ /// Removes all captured packets.
+ ///
+ void Clear();
+}
diff --git a/src/Network/Analyzer/IPacketCaptureService.cs b/src/Network/Analyzer/IPacketCaptureService.cs
new file mode 100644
index 0000000000..a02291ee1e
--- /dev/null
+++ b/src/Network/Analyzer/IPacketCaptureService.cs
@@ -0,0 +1,59 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network.Analyzer;
+
+///
+/// Service which provides the connections of all servers which run in this process, and which
+/// captures their traffic on request.
+///
+public interface IPacketCaptureService
+{
+ ///
+ /// Gets the connections of all servers which can provide them.
+ ///
+ /// The connections of all servers which can provide them.
+ ValueTask> GetConnectionsAsync();
+
+ ///
+ /// Gets the connection with the specified identifier.
+ ///
+ /// The identifier of the connection.
+ /// The connection, if it's still connected; Otherwise, .
+ ValueTask FindConnectionAsync(Guid connectionId);
+
+ ///
+ /// Gets the connection of the specified account or character name.
+ ///
+ /// The identifier of the server.
+ /// The name of the account or character.
+ /// The connection, if one was found; Otherwise, .
+ ValueTask FindConnectionAsync(int serverId, string accountOrCharacterName);
+
+ ///
+ /// Starts to capture the traffic of the specified connection, or returns the already
+ /// running capture of it.
+ ///
+ /// The identifier of the connection.
+ /// The capture of the connection, if it's still connected; Otherwise, .
+ ///
+ /// Each call has to be followed by a when the caller isn't
+ /// interested anymore. The capture stops when the last interested caller is gone.
+ ///
+ ValueTask StartCaptureAsync(Guid connectionId);
+
+ ///
+ /// Stops the capture of the specified connection, when no other caller is interested in
+ /// it anymore.
+ ///
+ /// The identifier of the connection.
+ void StopCapture(Guid connectionId);
+
+ ///
+ /// Gets the currently running capture of the specified connection.
+ ///
+ /// The identifier of the connection.
+ /// The running capture, if there is one; Otherwise, .
+ ILiveCapturedConnection? GetRunningCapture(Guid connectionId);
+}
diff --git a/src/Network/Analyzer/LiveCapturedConnection.cs b/src/Network/Analyzer/LiveCapturedConnection.cs
new file mode 100644
index 0000000000..613b3670b5
--- /dev/null
+++ b/src/Network/Analyzer/LiveCapturedConnection.cs
@@ -0,0 +1,90 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network.Analyzer;
+
+using System.ComponentModel;
+
+///
+/// Captures the traffic of a connection which is currently handled by one of our servers.
+///
+public sealed class LiveCapturedConnection : ILiveCapturedConnection, IPacketCaptureSink
+{
+ ///
+ /// The default maximum number of packets which are kept in memory.
+ ///
+ public const int DefaultMaximumPacketCount = 5000;
+
+ private readonly object _syncRoot = new();
+
+ private readonly int _maximumPacketCount;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The information about the captured connection.
+ /// The maximum number of packets which are kept in
+ /// memory. When more packets arrive, the oldest ones are dropped.
+ public LiveCapturedConnection(ICapturedConnectionInfo connectionInfo, int maximumPacketCount = DefaultMaximumPacketCount)
+ {
+ this.ConnectionInfo = connectionInfo;
+ this._maximumPacketCount = Math.Max(1, maximumPacketCount);
+ this.Name = connectionInfo.DisplayName;
+ }
+
+ ///
+ public event EventHandler? PacketsChanged;
+
+ ///
+ public ICapturedConnectionInfo ConnectionInfo { get; }
+
+ ///
+ public string Name { get; }
+
+ ///
+ public BindingList PacketList { get; } = new();
+
+ ///
+ public DateTime StartTimestamp { get; } = DateTime.UtcNow;
+
+ ///
+ public IReadOnlyList GetPackets()
+ {
+ lock (this._syncRoot)
+ {
+ return this.PacketList.ToList();
+ }
+ }
+
+ ///
+ public void Clear()
+ {
+ lock (this._syncRoot)
+ {
+ this.PacketList.Clear();
+ }
+
+ this.PacketsChanged?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ public void PacketCaptured(ReadOnlySpan packet, bool sent)
+ {
+ // A packet which was sent to the remote endpoint of a server connection is a packet
+ // which goes to the client; a received one goes to the server.
+ var capturedPacket = new Packet(DateTime.UtcNow - this.StartTimestamp, packet.ToArray(), !sent);
+
+ lock (this._syncRoot)
+ {
+ while (this.PacketList.Count >= this._maximumPacketCount)
+ {
+ this.PacketList.RemoveAt(0);
+ }
+
+ this.PacketList.Add(capturedPacket);
+ }
+
+ this.PacketsChanged?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/src/Network/Analyzer/MUnique.OpenMU.Network.Analyzer.csproj b/src/Network/Analyzer/MUnique.OpenMU.Network.Analyzer.csproj
index e2ad75c00d..221d613f2a 100644
--- a/src/Network/Analyzer/MUnique.OpenMU.Network.Analyzer.csproj
+++ b/src/Network/Analyzer/MUnique.OpenMU.Network.Analyzer.csproj
@@ -36,6 +36,7 @@
+
diff --git a/src/Network/Analyzer/PacketCaptureService.cs b/src/Network/Analyzer/PacketCaptureService.cs
new file mode 100644
index 0000000000..63ebd08f53
--- /dev/null
+++ b/src/Network/Analyzer/PacketCaptureService.cs
@@ -0,0 +1,140 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network.Analyzer;
+
+using System.Collections.Concurrent;
+using System.Threading;
+using MUnique.OpenMU.Interfaces;
+
+///
+/// The implementation of the , which collects the
+/// connections of all servers of this process which implement .
+///
+///
+/// In a distributed deployment, the servers of other processes are just proxies which don't
+/// implement - their connections are simply not listed.
+///
+public sealed class PacketCaptureService : IPacketCaptureService
+{
+ private readonly IServerProvider _serverProvider;
+
+ private readonly int _maximumPacketCount;
+
+ private readonly ConcurrentDictionary _runningCaptures = new();
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The provider of the servers of this process.
+ /// The maximum number of packets which are kept in
+ /// memory per capture.
+ public PacketCaptureService(IServerProvider serverProvider, int maximumPacketCount = LiveCapturedConnection.DefaultMaximumPacketCount)
+ {
+ this._serverProvider = serverProvider;
+ this._maximumPacketCount = maximumPacketCount;
+ }
+
+ ///
+ public async ValueTask> GetConnectionsAsync()
+ {
+ var result = new List();
+ foreach (var source in this._serverProvider.Servers.OfType())
+ {
+ result.AddRange(await source.GetConnectionsAsync().ConfigureAwait(false));
+ }
+
+ return result;
+ }
+
+ ///
+ public async ValueTask FindConnectionAsync(Guid connectionId)
+ {
+ var connections = await this.GetConnectionsAsync().ConfigureAwait(false);
+ return connections.FirstOrDefault(connection => connection.Id == connectionId);
+ }
+
+ ///
+ public async ValueTask FindConnectionAsync(int serverId, string accountOrCharacterName)
+ {
+ var connections = await this.GetConnectionsAsync().ConfigureAwait(false);
+ return connections.FirstOrDefault(connection => connection.ServerId == serverId
+ && (string.Equals(connection.CharacterName, accountOrCharacterName, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(connection.AccountName, accountOrCharacterName, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ ///
+ public async ValueTask StartCaptureAsync(Guid connectionId)
+ {
+ if (this._runningCaptures.TryGetValue(connectionId, out var running))
+ {
+ running.AddInterestedParty();
+ return running.Capture;
+ }
+
+ if (await this.FindConnectionAsync(connectionId).ConfigureAwait(false) is not { } connectionInfo)
+ {
+ return null;
+ }
+
+ var capture = new LiveCapturedConnection(connectionInfo, this._maximumPacketCount);
+ var newRunning = new RunningCapture(connectionInfo, capture);
+ var current = this._runningCaptures.GetOrAdd(connectionId, newRunning);
+ if (!ReferenceEquals(current, newRunning))
+ {
+ // Another caller was faster.
+ current.AddInterestedParty();
+ return current.Capture;
+ }
+
+ connectionInfo.AddCaptureSink(capture);
+ return capture;
+ }
+
+ ///
+ public void StopCapture(Guid connectionId)
+ {
+ if (!this._runningCaptures.TryGetValue(connectionId, out var running)
+ || running.RemoveInterestedParty() > 0)
+ {
+ return;
+ }
+
+ if (this._runningCaptures.TryRemove(connectionId, out _))
+ {
+ running.ConnectionInfo.RemoveCaptureSink(running.Capture);
+ }
+ }
+
+ ///
+ public ILiveCapturedConnection? GetRunningCapture(Guid connectionId)
+ {
+ return this._runningCaptures.TryGetValue(connectionId, out var running) ? running.Capture : null;
+ }
+
+ private sealed class RunningCapture
+ {
+ private int _interestedParties = 1;
+
+ public RunningCapture(ICapturedConnectionInfo connectionInfo, LiveCapturedConnection capture)
+ {
+ this.ConnectionInfo = connectionInfo;
+ this.Capture = capture;
+ }
+
+ public ICapturedConnectionInfo ConnectionInfo { get; }
+
+ public LiveCapturedConnection Capture { get; }
+
+ public void AddInterestedParty()
+ {
+ Interlocked.Increment(ref this._interestedParties);
+ }
+
+ public int RemoveInterestedParty()
+ {
+ return Interlocked.Decrement(ref this._interestedParties);
+ }
+ }
+}
diff --git a/src/Startup/MUnique.OpenMU.Startup.csproj b/src/Startup/MUnique.OpenMU.Startup.csproj
index 97cab92594..870bf3c493 100644
--- a/src/Startup/MUnique.OpenMU.Startup.csproj
+++ b/src/Startup/MUnique.OpenMU.Startup.csproj
@@ -36,6 +36,7 @@
+
diff --git a/src/Startup/Program.cs b/src/Startup/Program.cs
index 351eae8e14..e93507d0f9 100644
--- a/src/Startup/Program.cs
+++ b/src/Startup/Program.cs
@@ -24,6 +24,7 @@ namespace MUnique.OpenMU.Startup;
using MUnique.OpenMU.Interfaces;
using MUnique.OpenMU.LoginServer;
using MUnique.OpenMU.Network;
+using MUnique.OpenMU.Network.Analyzer;
using MUnique.OpenMU.Persistence;
using MUnique.OpenMU.Persistence.EntityFramework;
using MUnique.OpenMU.Persistence.EntityFramework.Json;
@@ -291,6 +292,7 @@ private async Task CreateHostAsync(string[] args)
.AddSingleton()
.AddSingleton()
.AddSingleton()
+ .AddSingleton()
.AddSingleton>(this.PlugInConfigurationsFactory)
.AddTransient(provider =>
{
diff --git a/tests/MUnique.OpenMU.Network.Tests/PacketCaptureServiceTest.cs b/tests/MUnique.OpenMU.Network.Tests/PacketCaptureServiceTest.cs
new file mode 100644
index 0000000000..81e78096ed
--- /dev/null
+++ b/tests/MUnique.OpenMU.Network.Tests/PacketCaptureServiceTest.cs
@@ -0,0 +1,274 @@
+//
+// Licensed under the MIT License. See LICENSE file in the project root for full license information.
+//
+
+namespace MUnique.OpenMU.Network.Tests;
+
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Linq;
+using System.Threading;
+using Microsoft.Extensions.Hosting;
+using MUnique.OpenMU.Interfaces;
+using MUnique.OpenMU.Network.Analyzer;
+using MUnique.OpenMU.Network.PlugIns;
+
+///
+/// Tests for the and the .
+///
+[TestFixture]
+public class PacketCaptureServiceTest
+{
+ ///
+ /// Tests if the connections of all servers which can provide them are collected.
+ ///
+ /// The async task.
+ [Test]
+ public async Task ConnectionsOfAllSourcesAreCollectedAsync()
+ {
+ var gameServerConnection = new TestConnectionInfo(ServerType.GameServer, 1) { CharacterName = "TestCharacter" };
+ var connectServerConnection = new TestConnectionInfo(ServerType.ConnectServer, 100);
+ var service = CreateService(
+ new TestServer(ServerType.GameServer, gameServerConnection),
+ new TestServer(ServerType.ConnectServer, connectServerConnection),
+ new ServerWithoutConnections());
+
+ var connections = await service.GetConnectionsAsync().ConfigureAwait(false);
+
+ Assert.That(connections, Has.Count.EqualTo(2));
+ Assert.That(connections, Does.Contain(gameServerConnection));
+ Assert.That(connections, Does.Contain(connectServerConnection));
+ }
+
+ ///
+ /// Tests if a connection is found by its identifier.
+ ///
+ /// The async task.
+ [Test]
+ public async Task ConnectionIsFoundByIdAsync()
+ {
+ var connection = new TestConnectionInfo(ServerType.GameServer, 1);
+ var service = CreateService(new TestServer(ServerType.GameServer, connection));
+
+ Assert.That(await service.FindConnectionAsync(connection.Id).ConfigureAwait(false), Is.EqualTo(connection));
+ Assert.That(await service.FindConnectionAsync(Guid.NewGuid()).ConfigureAwait(false), Is.Null);
+ }
+
+ ///
+ /// Tests if a connection is found by the name of its account or character.
+ ///
+ /// The async task.
+ [Test]
+ public async Task ConnectionIsFoundByNameAsync()
+ {
+ var connection = new TestConnectionInfo(ServerType.GameServer, 1) { AccountName = "testAccount", CharacterName = "TestCharacter" };
+ var service = CreateService(new TestServer(ServerType.GameServer, connection));
+
+ Assert.That(await service.FindConnectionAsync(1, "TestCharacter").ConfigureAwait(false), Is.EqualTo(connection));
+ Assert.That(await service.FindConnectionAsync(1, "testaccount").ConfigureAwait(false), Is.EqualTo(connection));
+ Assert.That(await service.FindConnectionAsync(2, "TestCharacter").ConfigureAwait(false), Is.Null);
+ Assert.That(await service.FindConnectionAsync(1, "Unknown").ConfigureAwait(false), Is.Null);
+ }
+
+ ///
+ /// Tests if a started capture registers itself at the connection, and if it's removed
+ /// again when the capture is stopped.
+ ///
+ /// The async task.
+ [Test]
+ public async Task CaptureIsAttachedAndDetachedAsync()
+ {
+ var connection = new TestConnectionInfo(ServerType.GameServer, 1);
+ var service = CreateService(new TestServer(ServerType.GameServer, connection));
+
+ var capture = await service.StartCaptureAsync(connection.Id).ConfigureAwait(false);
+
+ Assert.That(capture, Is.Not.Null);
+ Assert.That(connection.Sinks, Has.Count.EqualTo(1));
+ Assert.That(service.GetRunningCapture(connection.Id), Is.EqualTo(capture));
+
+ service.StopCapture(connection.Id);
+
+ Assert.That(connection.Sinks, Is.Empty);
+ Assert.That(service.GetRunningCapture(connection.Id), Is.Null);
+ }
+
+ ///
+ /// Tests if a second interested party gets the same capture, and that the capture is only
+ /// stopped when the last one is gone.
+ ///
+ /// The async task.
+ [Test]
+ public async Task CaptureIsSharedByInterestedPartiesAsync()
+ {
+ var connection = new TestConnectionInfo(ServerType.GameServer, 1);
+ var service = CreateService(new TestServer(ServerType.GameServer, connection));
+
+ var first = await service.StartCaptureAsync(connection.Id).ConfigureAwait(false);
+ var second = await service.StartCaptureAsync(connection.Id).ConfigureAwait(false);
+
+ Assert.That(second, Is.EqualTo(first));
+ Assert.That(connection.Sinks, Has.Count.EqualTo(1));
+
+ service.StopCapture(connection.Id);
+ Assert.That(connection.Sinks, Has.Count.EqualTo(1), "The capture is still watched.");
+
+ service.StopCapture(connection.Id);
+ Assert.That(connection.Sinks, Is.Empty);
+ }
+
+ ///
+ /// Tests if starting a capture of an unknown connection returns null.
+ ///
+ /// The async task.
+ [Test]
+ public async Task CaptureOfUnknownConnectionIsNotStartedAsync()
+ {
+ var service = CreateService(new TestServer(ServerType.GameServer));
+
+ Assert.That(await service.StartCaptureAsync(Guid.NewGuid()).ConfigureAwait(false), Is.Null);
+ }
+
+ ///
+ /// Tests if the captured packets are added with the correct direction.
+ ///
+ [Test]
+ public void CapturedPacketsKeepTheirDirection()
+ {
+ var capture = new LiveCapturedConnection(new TestConnectionInfo(ServerType.GameServer, 1));
+
+ capture.PacketCaptured(new byte[] { 0xC1, 0x04, 0xF1, 0x00 }, false);
+ capture.PacketCaptured(new byte[] { 0xC1, 0x04, 0xF1, 0x01 }, true);
+
+ var packets = capture.GetPackets();
+ Assert.That(packets, Has.Count.EqualTo(2));
+ Assert.That(packets[0].ToServer, Is.True, "A received packet goes to the server.");
+ Assert.That(packets[1].ToServer, Is.False, "A sent packet goes to the client.");
+ }
+
+ ///
+ /// Tests if the oldest packets are dropped when the maximum count is reached.
+ ///
+ [Test]
+ public void OldestPacketsAreDroppedWhenBufferIsFull()
+ {
+ var capture = new LiveCapturedConnection(new TestConnectionInfo(ServerType.GameServer, 1), 3);
+
+ for (byte i = 0; i < 5; i++)
+ {
+ capture.PacketCaptured(new byte[] { 0xC1, 0x04, 0xF1, i }, false);
+ }
+
+ var packets = capture.GetPackets();
+ Assert.That(packets, Has.Count.EqualTo(3));
+ Assert.That(packets.Select(packet => packet.Data[3]), Is.EqualTo(new byte[] { 2, 3, 4 }));
+ }
+
+ private static PacketCaptureService CreateService(params IManageableServer[] servers)
+ {
+ return new PacketCaptureService(new TestServerProvider(servers));
+ }
+
+ private sealed class TestConnectionInfo : ICapturedConnectionInfo
+ {
+ public TestConnectionInfo(ServerType serverType, int serverId)
+ {
+ this.ServerType = serverType;
+ this.ServerId = serverId;
+ }
+
+ public IList Sinks { get; } = new List();
+
+ public Guid Id { get; } = Guid.NewGuid();
+
+ public ServerType ServerType { get; }
+
+ public int ServerId { get; }
+
+ public string? AccountName { get; init; }
+
+ public string? CharacterName { get; init; }
+
+ public string? RemoteEndPoint => "127.0.0.1:1234";
+
+ public ClientVersion ClientVersion => default;
+
+ public PacketDefinitionSet DefinitionSet => PacketDefinitionSet.GameServer;
+
+ public bool IsConnected => true;
+
+ public string DisplayName => this.CharacterName ?? this.AccountName ?? this.RemoteEndPoint!;
+
+ public void AddCaptureSink(IPacketCaptureSink sink) => this.Sinks.Add(sink);
+
+ public void RemoveCaptureSink(IPacketCaptureSink sink) => this.Sinks.Remove(sink);
+
+ public ValueTask DisconnectAsync() => ValueTask.CompletedTask;
+ }
+
+ private class ServerWithoutConnections : IManageableServer
+ {
+ public event PropertyChangedEventHandler? PropertyChanged;
+
+ public int Id => 0;
+
+ public Guid ConfigurationId => Guid.Empty;
+
+ public string Description => "Test";
+
+ public ServerType Type => ServerType.GameServer;
+
+ public ServerState ServerState => ServerState.Started;
+
+ public int MaximumConnections => 100;
+
+ public int CurrentConnections => 0;
+
+ public ValueTask StartAsync() => ValueTask.CompletedTask;
+
+ public ValueTask ShutdownAsync() => ValueTask.CompletedTask;
+
+ public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+
+ protected void RaisePropertyChanged(string propertyName)
+ {
+ this.PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
+ }
+ }
+
+ private sealed class TestServer : ServerWithoutConnections, IConnectionSource
+ {
+ private readonly IReadOnlyList _connections;
+
+ public TestServer(ServerType serverType, params ICapturedConnectionInfo[] connections)
+ {
+ this.ServerType = serverType;
+ this._connections = connections;
+ }
+
+ public ServerType ServerType { get; }
+
+ public ValueTask> GetConnectionsAsync()
+ {
+ return ValueTask.FromResult(this._connections);
+ }
+ }
+
+ private sealed class TestServerProvider : IServerProvider
+ {
+ public TestServerProvider(IEnumerable servers)
+ {
+ this.Servers = servers.ToList();
+ }
+
+ public event PropertyChangedEventHandler? PropertyChanged
+ {
+ add { }
+ remove { }
+ }
+
+ public IList Servers { get; }
+ }
+}