From 71c9dfc9ea5ec53333fccf566523dee7556f9e48 Mon Sep 17 00:00:00 2001 From: Pieter-Jan Briers Date: Wed, 22 Jan 2025 00:23:47 +0100 Subject: [PATCH] Add system to kick people if they connect to multiple servers at once. (#34563) --- .../Managers/BanManager.Notification.cs | 29 +---- .../Administration/Managers/BanManager.cs | 7 +- .../Managers/MultiServerKickManager.cs | 114 ++++++++++++++++++ Content.Server/Database/ServerDbBase.cs | 2 + Content.Server/Database/ServerDbManager.cs | 15 +++ Content.Server/Database/ServerDbManagerExt.cs | 76 ++++++++++++ .../ServerDbPostgres.Notifications.cs | 10 ++ Content.Server/Database/ServerDbSqlite.cs | 6 + Content.Server/Entry/EntryPoint.cs | 1 + Content.Server/IoC/ServerContentIoC.cs | 1 + Content.Shared/CCVar/CCVars.Admin.cs | 7 ++ .../administration/multi-server-kick.ftl | 1 + 12 files changed, 242 insertions(+), 27 deletions(-) create mode 100644 Content.Server/Administration/Managers/MultiServerKickManager.cs create mode 100644 Content.Server/Database/ServerDbManagerExt.cs create mode 100644 Resources/Locale/en-US/administration/multi-server-kick.ftl diff --git a/Content.Server/Administration/Managers/BanManager.Notification.cs b/Content.Server/Administration/Managers/BanManager.Notification.cs index e9bfa62884..ff84887f00 100644 --- a/Content.Server/Administration/Managers/BanManager.Notification.cs +++ b/Content.Server/Administration/Managers/BanManager.Notification.cs @@ -1,6 +1,4 @@ -using System.Text.Json; using System.Text.Json.Serialization; -using Content.Server.Database; namespace Content.Server.Administration.Managers; @@ -30,36 +28,15 @@ public sealed partial class BanManager private TimeSpan _banNotificationRateLimitStart; private int _banNotificationRateLimitCount; - private void OnDatabaseNotification(DatabaseNotification notification) + private bool OnDatabaseNotificationEarlyFilter() { - if (notification.Channel != BanNotificationChannel) - return; - - if (notification.Payload == null) - { - _sawmill.Error("Got ban notification with null payload!"); - return; - } - - BanNotificationData data; - try - { - data = JsonSerializer.Deserialize(notification.Payload) - ?? throw new JsonException("Content is null"); - } - catch (JsonException e) - { - _sawmill.Error($"Got invalid JSON in ban notification: {e}"); - return; - } - if (!CheckBanRateLimit()) { _sawmill.Verbose("Not processing ban notification due to rate limit"); - return; + return false; } - _taskManager.RunOnMainThread(() => ProcessBanNotification(data)); + return true; } private async void ProcessBanNotification(BanNotificationData data) diff --git a/Content.Server/Administration/Managers/BanManager.cs b/Content.Server/Administration/Managers/BanManager.cs index 2e21710e51..c65059f55d 100644 --- a/Content.Server/Administration/Managers/BanManager.cs +++ b/Content.Server/Administration/Managers/BanManager.cs @@ -53,7 +53,12 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit { _netManager.RegisterNetMessage(); - _db.SubscribeToNotifications(OnDatabaseNotification); + _db.SubscribeToJsonNotification( + _taskManager, + _sawmill, + BanNotificationChannel, + ProcessBanNotification, + OnDatabaseNotificationEarlyFilter); _userDbData.AddOnLoadPlayer(CachePlayerData); _userDbData.AddOnPlayerDisconnect(ClearPlayerData); diff --git a/Content.Server/Administration/Managers/MultiServerKickManager.cs b/Content.Server/Administration/Managers/MultiServerKickManager.cs new file mode 100644 index 0000000000..abc8bb1f2d --- /dev/null +++ b/Content.Server/Administration/Managers/MultiServerKickManager.cs @@ -0,0 +1,114 @@ +using System.Text.Json; +using System.Text.Json.Serialization; +using Content.Server.Database; +using Content.Shared.CCVar; +using Robust.Server.Player; +using Robust.Shared.Asynchronous; +using Robust.Shared.Configuration; +using Robust.Shared.Enums; +using Robust.Shared.Network; +using Robust.Shared.Player; + +namespace Content.Server.Administration.Managers; + +/// +/// Handles kicking people that connect to multiple servers on the same DB at once. +/// +/// +public sealed class MultiServerKickManager +{ + public const string NotificationChannel = "multi_server_kick"; + + [Dependency] private readonly IPlayerManager _playerManager = null!; + [Dependency] private readonly IServerDbManager _dbManager = null!; + [Dependency] private readonly ILogManager _logManager = null!; + [Dependency] private readonly IConfigurationManager _cfg = null!; + [Dependency] private readonly IAdminManager _adminManager = null!; + [Dependency] private readonly ITaskManager _taskManager = null!; + [Dependency] private readonly IServerNetManager _netManager = null!; + [Dependency] private readonly ILocalizationManager _loc = null!; + [Dependency] private readonly ServerDbEntryManager _serverDbEntry = null!; + + private ISawmill _sawmill = null!; + private bool _allowed; + + public void Initialize() + { + _sawmill = _logManager.GetSawmill("multi_server_kick"); + + _playerManager.PlayerStatusChanged += OnPlayerStatusChanged; + _cfg.OnValueChanged(CCVars.AdminAllowMultiServerPlay, b => _allowed = b, true); + + _dbManager.SubscribeToJsonNotification( + _taskManager, + _sawmill, + NotificationChannel, + OnNotification, + OnNotificationEarlyFilter + ); + } + + // ReSharper disable once AsyncVoidMethod + private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e) + { + if (_allowed) + return; + + if (e.NewStatus != SessionStatus.InGame) + return; + + // Send notification to other servers so they can kick this player that just connected. + try + { + await _dbManager.SendNotification(new DatabaseNotification + { + Channel = NotificationChannel, + Payload = JsonSerializer.Serialize(new NotificationData + { + PlayerId = e.Session.UserId, + ServerId = (await _serverDbEntry.ServerEntity).Id, + }), + }); + } + catch (Exception ex) + { + _sawmill.Error($"Failed to send notification for multi server kick: {ex}"); + } + } + + private bool OnNotificationEarlyFilter() + { + if (_allowed) + { + _sawmill.Verbose("Received notification for player join, but multi server play is allowed on this server. Ignoring"); + return false; + } + + return true; + } + + // ReSharper disable once AsyncVoidMethod + private async void OnNotification(NotificationData notification) + { + if (!_playerManager.TryGetSessionById(new NetUserId(notification.PlayerId), out var player)) + return; + + if (notification.ServerId == (await _serverDbEntry.ServerEntity).Id) + return; + + if (_adminManager.IsAdmin(player, includeDeAdmin: true)) + return; + + _sawmill.Info($"Kicking {player} for connecting to another server. Multi-server play is not allowed."); + _netManager.DisconnectChannel(player.Channel, _loc.GetString("multi-server-kick-reason")); + } + + private sealed class NotificationData + { + [JsonPropertyName("player_id")] + public Guid PlayerId { get; set; } + + [JsonPropertyName("server_id")] + public int ServerId { get; set; } + } +} diff --git a/Content.Server/Database/ServerDbBase.cs b/Content.Server/Database/ServerDbBase.cs index c1a52447b7..b75870ac0e 100644 --- a/Content.Server/Database/ServerDbBase.cs +++ b/Content.Server/Database/ServerDbBase.cs @@ -1801,6 +1801,8 @@ INSERT INTO player_round (players_id, rounds_id) VALUES ({players[player]}, {id} #endregion + public abstract Task SendNotification(DatabaseNotification notification); + // SQLite returns DateTime as Kind=Unspecified, Npgsql actually knows for sure it's Kind=Utc. // Normalize DateTimes here so they're always Utc. Thanks. protected abstract DateTime NormalizeDatabaseTime(DateTime time); diff --git a/Content.Server/Database/ServerDbManager.cs b/Content.Server/Database/ServerDbManager.cs index 9fee2c021b..5ddb3a590c 100644 --- a/Content.Server/Database/ServerDbManager.cs +++ b/Content.Server/Database/ServerDbManager.cs @@ -350,6 +350,15 @@ namespace Content.Server.Database /// The notification to trigger void InjectTestNotification(DatabaseNotification notification); + /// + /// Send a notification to all other servers connected to the same database. + /// + /// + /// The local server will receive the sent notification itself again. + /// + /// The notification to send. + Task SendNotification(DatabaseNotification notification); + #endregion } @@ -1045,6 +1054,12 @@ namespace Content.Server.Database HandleDatabaseNotification(notification); } + public Task SendNotification(DatabaseNotification notification) + { + DbWriteOpsMetric.Inc(); + return RunDbCommand(() => _db.SendNotification(notification)); + } + private async void HandleDatabaseNotification(DatabaseNotification notification) { lock (_notificationHandlers) diff --git a/Content.Server/Database/ServerDbManagerExt.cs b/Content.Server/Database/ServerDbManagerExt.cs new file mode 100644 index 0000000000..fad7a7234e --- /dev/null +++ b/Content.Server/Database/ServerDbManagerExt.cs @@ -0,0 +1,76 @@ +using System.Text.Json; +using Robust.Shared.Asynchronous; + +namespace Content.Server.Database; + +public static class ServerDbManagerExt +{ + /// + /// Subscribe to a database notification on a specific channel, formatted as JSON. + /// + /// The database manager to subscribe on. + /// The task manager used to run the main callback on the main thread. + /// Sawmill to log any errors to. + /// + /// The notification channel to listen on. Only notifications on this channel will be handled. + /// + /// + /// The action to run on the notification data. + /// This runs on the main thread. + /// + /// + /// An early filter callback that runs before the JSON message is deserialized. + /// Return false to not handle the notification. + /// This does not run on the main thread. + /// + /// + /// A filter callback that runs after the JSON message is deserialized. + /// Return false to not handle the notification. + /// This does not run on the main thread. + /// + /// The type of JSON data to deserialize. + public static void SubscribeToJsonNotification( + this IServerDbManager dbManager, + ITaskManager taskManager, + ISawmill sawmill, + string channel, + Action action, + Func? earlyFilter = null, + Func? filter = null) + { + dbManager.SubscribeToNotifications(notification => + { + if (notification.Channel != channel) + return; + + if (notification.Payload == null) + { + sawmill.Error($"Got {channel} notification with null payload!"); + return; + } + + if (earlyFilter != null && !earlyFilter()) + return; + + TData data; + try + { + data = JsonSerializer.Deserialize(notification.Payload) + ?? throw new JsonException("Content is null"); + } + catch (JsonException e) + { + sawmill.Error($"Got invalid JSON in {channel} notification: {e}"); + return; + } + + if (filter != null && !filter(data)) + return; + + taskManager.RunOnMainThread(() => + { + action(data); + }); + }); + } +} diff --git a/Content.Server/Database/ServerDbPostgres.Notifications.cs b/Content.Server/Database/ServerDbPostgres.Notifications.cs index 69cf2c7d77..91db2d100f 100644 --- a/Content.Server/Database/ServerDbPostgres.Notifications.cs +++ b/Content.Server/Database/ServerDbPostgres.Notifications.cs @@ -2,6 +2,7 @@ using System.Threading; using System.Threading.Tasks; using Content.Server.Administration.Managers; +using Microsoft.EntityFrameworkCore; using Npgsql; namespace Content.Server.Database; @@ -17,6 +18,7 @@ public sealed partial class ServerDbPostgres private static readonly string[] NotificationChannels = [ BanManager.BanNotificationChannel, + MultiServerKickManager.NotificationChannel, ]; private static readonly TimeSpan ReconnectWaitIncrease = TimeSpan.FromSeconds(10); @@ -111,6 +113,14 @@ public sealed partial class ServerDbPostgres }); } + public override async Task SendNotification(DatabaseNotification notification) + { + await using var db = await GetDbImpl(); + + await db.PgDbContext.Database.ExecuteSqlAsync( + $"SELECT pg_notify({notification.Channel}, {notification.Payload})"); + } + public override void Shutdown() { _notificationTokenSource.Cancel(); diff --git a/Content.Server/Database/ServerDbSqlite.cs b/Content.Server/Database/ServerDbSqlite.cs index 6ec90c3332..c3109ec6e6 100644 --- a/Content.Server/Database/ServerDbSqlite.cs +++ b/Content.Server/Database/ServerDbSqlite.cs @@ -537,6 +537,12 @@ namespace Content.Server.Database return await base.AddAdminMessage(message); } + public override Task SendNotification(DatabaseNotification notification) + { + // Notifications not implemented on SQLite. + return Task.CompletedTask; + } + protected override DateTime NormalizeDatabaseTime(DateTime time) { DebugTools.Assert(time.Kind == DateTimeKind.Unspecified); diff --git a/Content.Server/Entry/EntryPoint.cs b/Content.Server/Entry/EntryPoint.cs index 3d4ea922dc..b9c20942a0 100644 --- a/Content.Server/Entry/EntryPoint.cs +++ b/Content.Server/Entry/EntryPoint.cs @@ -152,6 +152,7 @@ namespace Content.Server.Entry IoCManager.Resolve().GetEntitySystem().PostInitialize(); IoCManager.Resolve().Initialize(); IoCManager.Resolve().PostInit(); + IoCManager.Resolve().Initialize(); } } diff --git a/Content.Server/IoC/ServerContentIoC.cs b/Content.Server/IoC/ServerContentIoC.cs index 777e134246..50b248a9ea 100644 --- a/Content.Server/IoC/ServerContentIoC.cs +++ b/Content.Server/IoC/ServerContentIoC.cs @@ -75,6 +75,7 @@ namespace Content.Server.IoC IoCManager.Register(); IoCManager.Register(); IoCManager.Register(); + IoCManager.Register(); } } } diff --git a/Content.Shared/CCVar/CCVars.Admin.cs b/Content.Shared/CCVar/CCVars.Admin.cs index 7754a6cbb8..bbce533ed1 100644 --- a/Content.Shared/CCVar/CCVars.Admin.cs +++ b/Content.Shared/CCVar/CCVars.Admin.cs @@ -176,4 +176,11 @@ public sealed partial class CCVars public static readonly CVarDef BanHardwareIds = CVarDef.Create("ban.hardware_ids", true, CVar.SERVERONLY); + + /// + /// If true, players are allowed to connect to multiple game servers at once. + /// If false, they will be kicked from the first when connecting to another. + /// + public static readonly CVarDef AdminAllowMultiServerPlay = + CVarDef.Create("admin.allow_multi_server_play", true, CVar.SERVERONLY); } diff --git a/Resources/Locale/en-US/administration/multi-server-kick.ftl b/Resources/Locale/en-US/administration/multi-server-kick.ftl new file mode 100644 index 0000000000..2aa3c4ed35 --- /dev/null +++ b/Resources/Locale/en-US/administration/multi-server-kick.ftl @@ -0,0 +1 @@ +multi-server-kick-reason = Connected to different server in this community. -- 2.51.2