diff --git a/src/GameLogic/IHasIpAddress.cs b/src/GameLogic/IHasIpAddress.cs new file mode 100644 index 0000000..924443d --- /dev/null +++ b/src/GameLogic/IHasIpAddress.cs @@ -0,0 +1,16 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic; + +/// +/// Interface for objects that expose a remote IP address. +/// +public interface IHasIpAddress +{ + /// + /// Gets the IP address of the remote connection. + /// + string? IpAddress { get; } +} diff --git a/src/GameLogic/Player.cs b/src/GameLogic/Player.cs index df72f5c..1012558 100644 --- a/src/GameLogic/Player.cs +++ b/src/GameLogic/Player.cs @@ -203,6 +203,11 @@ public class Player : AsyncDisposable, IBucketMapObserver, IAttackable, IAttacke /// public ushort Id { get; set; } + /// + /// Gets or sets a custom login result to override the default when login fails. + /// + public Views.Login.LoginResult? LoginResultOverride { get; set; } + /// public string Name => this.SelectedCharacter?.Name ?? string.Empty; diff --git a/src/GameLogic/PlayerActions/LoginAction.cs b/src/GameLogic/PlayerActions/LoginAction.cs index bbbc6a5..8dea162 100644 --- a/src/GameLogic/PlayerActions/LoginAction.cs +++ b/src/GameLogic/PlayerActions/LoginAction.cs @@ -189,7 +189,9 @@ public class LoginAction private async ValueTask HandleAlreadyConnectedAsync(Player player, string username) { - await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(LoginResult.AccountAlreadyConnected)).ConfigureAwait(false); + var result = player.LoginResultOverride ?? LoginResult.AccountAlreadyConnected; + player.LoginResultOverride = null; + await player.InvokeViewPlugInAsync(p => p.ShowLoginResultAsync(result)).ConfigureAwait(false); if (player.GameContext is IGameServerContext gameServerContext) { await gameServerContext.EventPublisher.PlayerAlreadyLoggedInAsync(gameServerContext.Id, username).ConfigureAwait(false); diff --git a/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugIn.cs b/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugIn.cs new file mode 100644 index 0000000..ec7efec --- /dev/null +++ b/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugIn.cs @@ -0,0 +1,68 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +using System.ComponentModel.DataAnnotations; +using System.Linq; +using System.Runtime.InteropServices; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using MUnique.OpenMU.PlugIns; + +/// +/// A plugin that limits the number of simultaneous active player sessions connected from the same IP address. +/// +[PlugIn] +[Display(Name = "Maximum Connections Per IP", Description = "Limits the maximum number of parallel connections from the same IP address.")] +[Guid("2C779F5E-379E-4CE0-BFCC-CA6455D757B3")] +public class MaximumConnectionsPerIpPlugIn : IPlayerStateChangingPlugIn, ISupportCustomConfiguration, ISupportDefaultCustomConfiguration, IDisabledByDefault +{ + /// + public MaximumConnectionsPerIpPlugInConfiguration? Configuration { get; set; } + + /// + public async ValueTask PlayerStateChangingAsync(Player player, StateMachine.StateChangeEventArgs eventArgs) + { + if (eventArgs.NextState != PlayerState.Authenticated) + { + return; + } + + var ipAddress = (player as IHasIpAddress)?.IpAddress; + if (string.IsNullOrEmpty(ipAddress)) + { + return; + } + + var config = this.Configuration ?? CreateDefaultConfiguration(); + var players = await player.GameContext.GetPlayersAsync().ConfigureAwait(false); + var sameIpCount = players.Count(p => + (p as IHasIpAddress)?.IpAddress == ipAddress + && p != player + && p.PlayerState.CurrentState != PlayerState.Initial + && p.PlayerState.CurrentState != PlayerState.LoginScreen); + + if (sameIpCount >= config.MaximumConnectionsPerIp) + { + player.Logger.LogWarning( + "Login request for IP '{IpAddress}' was cancelled. It exceeded the maximum connection limit of {Limit}.", + ipAddress, + config.MaximumConnectionsPerIp); + player.LoginResultOverride = Views.Login.LoginResult.ServerIsFull; + eventArgs.Cancel = true; + } + } + + /// + public object CreateDefaultConfig() + { + return CreateDefaultConfiguration(); + } + + private static MaximumConnectionsPerIpPlugInConfiguration CreateDefaultConfiguration() + { + return new MaximumConnectionsPerIpPlugInConfiguration(); + } +} diff --git a/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugInConfiguration.cs b/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugInConfiguration.cs new file mode 100644 index 0000000..1d2d658 --- /dev/null +++ b/src/GameLogic/PlugIns/MaximumConnectionsPerIpPlugInConfiguration.cs @@ -0,0 +1,16 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.GameLogic.PlugIns; + +/// +/// Configuration for the . +/// +public class MaximumConnectionsPerIpPlugInConfiguration +{ + /// + /// Gets or sets the maximum number of concurrent connections per IP address. + /// + public int MaximumConnectionsPerIp { get; set; } = 3; +} diff --git a/src/GameServer/RemoteView/RemotePlayer.cs b/src/GameServer/RemoteView/RemotePlayer.cs index a455d91..2c0b584 100644 --- a/src/GameServer/RemoteView/RemotePlayer.cs +++ b/src/GameServer/RemoteView/RemotePlayer.cs @@ -1,4 +1,4 @@ -// +// // Licensed under the MIT License. See LICENSE file in the project root for full license information. // @@ -16,12 +16,14 @@ using MUnique.OpenMU.PlugIns; /// /// A player which is playing through a remote connection. /// -public class RemotePlayer : Player, IClientVersionProvider +public class RemotePlayer : Player, IClientVersionProvider, IHasIpAddress { private readonly byte[] _packetBuffer = new byte[0xFF]; private ClientVersion _clientVersion; + private readonly string? _ipAddress; + /// /// Initializes a new instance of the class. /// @@ -33,6 +35,9 @@ public class RemotePlayer : Player, IClientVersionProvider { this.Connection = connection; this._clientVersion = clientVersion; + this._ipAddress = connection.EndPoint is System.Net.IPEndPoint ipEndPoint + ? (ipEndPoint.Address.IsIPv4MappedToIPv6 ? ipEndPoint.Address.MapToIPv4() : ipEndPoint.Address).ToString() + : null; this.MainPacketHandler = new MainPacketHandlerPlugInContainer(this, gameContext.PlugInManager, gameContext.LoggerFactory); this.MainPacketHandler.Initialize(); this.Connection!.PacketReceived += this.PacketReceivedAsync; @@ -42,6 +47,9 @@ public class RemotePlayer : Player, IClientVersionProvider /// public event EventHandler? ClientVersionChanged; + /// + public string? IpAddress => this._ipAddress; + /// /// Gets the game server context. /// diff --git a/tests/MUnique.OpenMU.Tests/MaximumConnectionsPerIpPlugInTests.cs b/tests/MUnique.OpenMU.Tests/MaximumConnectionsPerIpPlugInTests.cs new file mode 100644 index 0000000..6a4b196 --- /dev/null +++ b/tests/MUnique.OpenMU.Tests/MaximumConnectionsPerIpPlugInTests.cs @@ -0,0 +1,200 @@ +// +// Licensed under the MIT License. See LICENSE file in the project root for full license information. +// + +namespace MUnique.OpenMU.Tests; + + +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using NUnit.Framework; +using MUnique.OpenMU.AttributeSystem; +using MUnique.OpenMU.DataModel.Configuration; +using MUnique.OpenMU.DataModel.Configuration.Items; +using MUnique.OpenMU.DataModel.Entities; +using MUnique.OpenMU.GameLogic; +using MUnique.OpenMU.GameLogic.Attributes; +using MUnique.OpenMU.GameLogic.PlugIns; +using MUnique.OpenMU.Persistence.InMemory; +using MUnique.OpenMU.PlugIns; + +/// +/// Unit tests for the . +/// +[TestFixture] +public class MaximumConnectionsPerIpPlugInTests +{ + private GameContext _gameContext = null!; + private MaximumConnectionsPerIpPlugIn _plugin = null!; + + [SetUp] + public async Task SetUp() + { + var dummyPlayer = await PlayerTestHelper.CreatePlayerAsync().ConfigureAwait(false); + this._gameContext = (GameContext)dummyPlayer.GameContext; + this._plugin = new MaximumConnectionsPerIpPlugIn + { + Configuration = new MaximumConnectionsPerIpPlugInConfiguration + { + MaximumConnectionsPerIp = 3 + } + }; + } + + [Test] + public async ValueTask UnderLimitSucceedsAsync() + { + // Arrange + var player1 = new TestPlayer(this._gameContext); + player1.SetIpAddress("127.0.0.1"); + await player1.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player1.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player1).ConfigureAwait(false); + + var player2 = new TestPlayer(this._gameContext); + player2.SetIpAddress("127.0.0.1"); + await player2.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player2.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player2).ConfigureAwait(false); + + var joiningPlayer = new TestPlayer(this._gameContext); + joiningPlayer.SetIpAddress("127.0.0.1"); + await joiningPlayer.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + + var eventArgs = new StateMachine.StateChangeEventArgs { NextState = PlayerState.Authenticated }; + + // Act + await this._plugin.PlayerStateChangingAsync(joiningPlayer, eventArgs).ConfigureAwait(false); + + // Assert + Assert.That(eventArgs.Cancel, Is.False); + } + + [Test] + public async ValueTask AtLimitCancelsTransitionAsync() + { + // Arrange + var player1 = new TestPlayer(this._gameContext); + player1.SetIpAddress("127.0.0.1"); + await player1.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player1.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player1).ConfigureAwait(false); + + var player2 = new TestPlayer(this._gameContext); + player2.SetIpAddress("127.0.0.1"); + await player2.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player2.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player2).ConfigureAwait(false); + + var player3 = new TestPlayer(this._gameContext); + player3.SetIpAddress("127.0.0.1"); + await player3.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player3.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player3).ConfigureAwait(false); + + var joiningPlayer = new TestPlayer(this._gameContext); + joiningPlayer.SetIpAddress("127.0.0.1"); + await joiningPlayer.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + + var eventArgs = new StateMachine.StateChangeEventArgs { NextState = PlayerState.Authenticated }; + + // Act + await this._plugin.PlayerStateChangingAsync(joiningPlayer, eventArgs).ConfigureAwait(false); + + // Assert + Assert.That(eventArgs.Cancel, Is.True); + Assert.That(joiningPlayer.LoginResultOverride, Is.EqualTo(GameLogic.Views.Login.LoginResult.ServerIsFull)); + } + + [Test] + public async ValueTask DifferentIpDoesNotCountAsync() + { + // Arrange + var player1 = new TestPlayer(this._gameContext); + player1.SetIpAddress("127.0.0.1"); + await player1.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player1.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player1).ConfigureAwait(false); + + var player2 = new TestPlayer(this._gameContext); + player2.SetIpAddress("127.0.0.1"); + await player2.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player2.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player2).ConfigureAwait(false); + + var player3 = new TestPlayer(this._gameContext); + player3.SetIpAddress("127.0.0.1"); + await player3.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player3.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player3).ConfigureAwait(false); + + var joiningPlayer = new TestPlayer(this._gameContext); + joiningPlayer.SetIpAddress(new System.Net.IPAddress(new byte[] { 192, 168, 1, 100 }).ToString()); + await joiningPlayer.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + + var eventArgs = new StateMachine.StateChangeEventArgs { NextState = PlayerState.Authenticated }; + + // Act + await this._plugin.PlayerStateChangingAsync(joiningPlayer, eventArgs).ConfigureAwait(false); + + // Assert + Assert.That(eventArgs.Cancel, Is.False); + } + + [Test] + public async ValueTask LoginScreenAndInitialPlayersDoNotCountAsync() + { + // Arrange + var player1 = new TestPlayer(this._gameContext); + player1.SetIpAddress("127.0.0.1"); + await player1.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player1.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player1).ConfigureAwait(false); + + var player2 = new TestPlayer(this._gameContext); + player2.SetIpAddress("127.0.0.1"); + await player2.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await player2.PlayerState.TryAdvanceToAsync(PlayerState.Authenticated).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player2).ConfigureAwait(false); + + // This player is only at the login screen + var player3 = new TestPlayer(this._gameContext); + player3.SetIpAddress("127.0.0.1"); + await player3.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + await this._gameContext.AddPlayerAsync(player3).ConfigureAwait(false); + + var joiningPlayer = new TestPlayer(this._gameContext); + joiningPlayer.SetIpAddress("127.0.0.1"); + await joiningPlayer.PlayerState.TryAdvanceToAsync(PlayerState.LoginScreen).ConfigureAwait(false); + + var eventArgs = new StateMachine.StateChangeEventArgs { NextState = PlayerState.Authenticated }; + + // Act + await this._plugin.PlayerStateChangingAsync(joiningPlayer, eventArgs).ConfigureAwait(false); + + // Assert + Assert.That(eventArgs.Cancel, Is.False); + } + + private class TestPlayer : Player, IHasIpAddress + { + private string? _ipAddress; + + public TestPlayer(IGameContext gameContext) + : base(gameContext) + { + } + + /// + public string? IpAddress => this._ipAddress; + + /// Sets the IP address for testing purposes. + public void SetIpAddress(string? ip) => this._ipAddress = ip; + + protected override ICustomPlugInContainer CreateViewPlugInContainer() + { + return new MockViewPlugInContainer(); + } + } +}