From 36b2b6ddfc04164ec562fefa911bf1abb211c1b3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 21:31:38 +0000 Subject: [PATCH] Add the packet capture service and the connection sources The service collects the connections of all servers which run in this process and captures their traffic on request. It's the layer between the capture hook of the connections and the admin panel page. * A server which can provide the connections of its clients implements IConnectionSource. That's the game server, the connect server and the chat server; the proxies of a distributed deployment don't, so their connections are simply not listed. * Each connection is described by an ICapturedConnectionInfo, which is implemented by the servers themselves. That way the network connection stays inside the server which owns it: the info exposes the account and character name, the endpoint, the client version, the packet definition set which applies - and it attaches or detaches a capture sink on request. * The PacketCaptureService finds those servers over the already registered IServerProvider, so no additional registration is needed when servers are created or removed at runtime. * A capture is shared by all interested parties and stops when the last one is gone. Its packets are kept in a capped buffer, so a busy connection can't fill the memory. Registered in the all-in-one startup only, like the live map page. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Pb82LmoaUVdZtBtQs7xrtA --- src/ChatServer/ChatClient.cs | 5 + src/ChatServer/ChatClientConnectionInfo.cs | 81 ++++++ src/ChatServer/ChatServer.cs | 17 +- .../MUnique.OpenMU.ChatServer.csproj | 1 + src/ConnectServer/ClientConnectionInfo.cs | 76 +++++ src/ConnectServer/ConnectServer.cs | 12 +- .../MUnique.OpenMU.ConnectServer.csproj | 1 + src/GameServer/GameServer.cs | 18 +- .../MUnique.OpenMU.GameServer.csproj | 1 + src/GameServer/RemotePlayerConnectionInfo.cs | 73 +++++ .../Analyzer/ICapturedConnectionInfo.cs | 87 ++++++ src/Network/Analyzer/IConnectionSource.cs | 22 ++ .../Analyzer/ILiveCapturedConnection.cs | 37 +++ src/Network/Analyzer/IPacketCaptureService.cs | 59 ++++ .../Analyzer/LiveCapturedConnection.cs | 90 ++++++ .../MUnique.OpenMU.Network.Analyzer.csproj | 1 + src/Network/Analyzer/PacketCaptureService.cs | 140 +++++++++ src/Startup/MUnique.OpenMU.Startup.csproj | 1 + src/Startup/Program.cs | 2 + .../PacketCaptureServiceTest.cs | 274 ++++++++++++++++++ 20 files changed, 995 insertions(+), 3 deletions(-) create mode 100644 src/ChatServer/ChatClientConnectionInfo.cs create mode 100644 src/ConnectServer/ClientConnectionInfo.cs create mode 100644 src/GameServer/RemotePlayerConnectionInfo.cs create mode 100644 src/Network/Analyzer/ICapturedConnectionInfo.cs create mode 100644 src/Network/Analyzer/IConnectionSource.cs create mode 100644 src/Network/Analyzer/ILiveCapturedConnection.cs create mode 100644 src/Network/Analyzer/IPacketCaptureService.cs create mode 100644 src/Network/Analyzer/LiveCapturedConnection.cs create mode 100644 src/Network/Analyzer/PacketCaptureService.cs create mode 100644 tests/MUnique.OpenMU.Network.Tests/PacketCaptureServiceTest.cs 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; } + } +}