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; } + } +}