]> git.smokeofanarchy.ru Git - space-station-14.git/commitdiff
Add system to kick people if they connect to multiple servers at once. (#34563)
authorPieter-Jan Briers <pieterjan.briers+git@gmail.com>
Tue, 21 Jan 2025 23:23:47 +0000 (00:23 +0100)
committerGitHub <noreply@github.com>
Tue, 21 Jan 2025 23:23:47 +0000 (00:23 +0100)
12 files changed:
Content.Server/Administration/Managers/BanManager.Notification.cs
Content.Server/Administration/Managers/BanManager.cs
Content.Server/Administration/Managers/MultiServerKickManager.cs [new file with mode: 0644]
Content.Server/Database/ServerDbBase.cs
Content.Server/Database/ServerDbManager.cs
Content.Server/Database/ServerDbManagerExt.cs [new file with mode: 0644]
Content.Server/Database/ServerDbPostgres.Notifications.cs
Content.Server/Database/ServerDbSqlite.cs
Content.Server/Entry/EntryPoint.cs
Content.Server/IoC/ServerContentIoC.cs
Content.Shared/CCVar/CCVars.Admin.cs
Resources/Locale/en-US/administration/multi-server-kick.ftl [new file with mode: 0644]

index e9bfa6288415d538e8c0fb3e95f18fb7e496374a..ff84887f00df129fdbe98b5c872eba76b26f74b8 100644 (file)
@@ -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<BanNotificationData>(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)
index 2e21710e51d21771c00130d598e206bd0e1fc4bd..c65059f55ddc0bd666a3134f32363e2c6749e9c4 100644 (file)
@@ -53,7 +53,12 @@ public sealed partial class BanManager : IBanManager, IPostInjectInit
     {
         _netManager.RegisterNetMessage<MsgRoleBans>();
 
-        _db.SubscribeToNotifications(OnDatabaseNotification);
+        _db.SubscribeToJsonNotification<BanNotificationData>(
+            _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 (file)
index 0000000..abc8bb1
--- /dev/null
@@ -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;
+
+/// <summary>
+/// Handles kicking people that connect to multiple servers on the same DB at once.
+/// </summary>
+/// <seealso cref="CCVars.AdminAllowMultiServerPlay"/>
+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<NotificationData>(
+            _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; }
+    }
+}
index c1a52447b799a26864f6a4e22cc2d35981d58261..b75870ac0e64f3adae21e28262c7305ee7d7df4e 100644 (file)
@@ -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);
index 9fee2c021bd30c18c20cca9586bd2ca2dc77361d..5ddb3a590cbe49cb6856ed76fad45fdd31ecac7b 100644 (file)
@@ -350,6 +350,15 @@ namespace Content.Server.Database
         /// <param name="notification">The notification to trigger</param>
         void InjectTestNotification(DatabaseNotification notification);
 
+        /// <summary>
+        /// Send a notification to all other servers connected to the same database.
+        /// </summary>
+        /// <remarks>
+        /// The local server will receive the sent notification itself again.
+        /// </remarks>
+        /// <param name="notification">The notification to send.</param>
+        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 (file)
index 0000000..fad7a72
--- /dev/null
@@ -0,0 +1,76 @@
+using System.Text.Json;
+using Robust.Shared.Asynchronous;
+
+namespace Content.Server.Database;
+
+public static class ServerDbManagerExt
+{
+    /// <summary>
+    /// Subscribe to a database notification on a specific channel, formatted as JSON.
+    /// </summary>
+    /// <param name="dbManager">The database manager to subscribe on.</param>
+    /// <param name="taskManager">The task manager used to run the main callback on the main thread.</param>
+    /// <param name="sawmill">Sawmill to log any errors to.</param>
+    /// <param name="channel">
+    /// The notification channel to listen on. Only notifications on this channel will be handled.
+    /// </param>
+    /// <param name="action">
+    /// The action to run on the notification data.
+    /// This runs on the main thread.
+    /// </param>
+    /// <param name="earlyFilter">
+    /// 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.
+    /// </param>
+    /// <param name="filter">
+    /// 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.
+    /// </param>
+    /// <typeparam name="TData">The type of JSON data to deserialize.</typeparam>
+    public static void SubscribeToJsonNotification<TData>(
+        this IServerDbManager dbManager,
+        ITaskManager taskManager,
+        ISawmill sawmill,
+        string channel,
+        Action<TData> action,
+        Func<bool>? earlyFilter = null,
+        Func<TData, bool>? 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<TData>(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);
+            });
+        });
+    }
+}
index 69cf2c7d775d529835adaec2caeea4f5d37c3148..91db2d100f8a283be5578660e01dfa49ea6756fd 100644 (file)
@@ -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();
index 6ec90c3332f137328c1118682854f1b920263fa1..c3109ec6e6692b6189994be429c562ecd752328c 100644 (file)
@@ -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);
index 3d4ea922dc0e4e63f9cf0cf27280253a951c3974..b9c20942a02910299716ebd95c365ea230c67634 100644 (file)
@@ -152,6 +152,7 @@ namespace Content.Server.Entry
                 IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<GameTicker>().PostInitialize();
                 IoCManager.Resolve<IBanManager>().Initialize();
                 IoCManager.Resolve<IConnectionManager>().PostInit();
+                IoCManager.Resolve<MultiServerKickManager>().Initialize();
             }
         }
 
index 777e134246994b7b2b653679a7c7c7a8bd5c5b3a..50b248a9ea37c09492562e3d2534d7006552114c 100644 (file)
@@ -75,6 +75,7 @@ namespace Content.Server.IoC
             IoCManager.Register<MappingManager>();
             IoCManager.Register<IWatchlistWebhookManager, WatchlistWebhookManager>();
             IoCManager.Register<ConnectionManager>();
+            IoCManager.Register<MultiServerKickManager>();
         }
     }
 }
index 7754a6cbb8b6d2dec94a94476c47ffb79057eaec..bbce533ed1bb3797ac3dce7a2806cf172c6d6950 100644 (file)
@@ -176,4 +176,11 @@ public sealed partial class CCVars
 
     public static readonly CVarDef<bool> BanHardwareIds =
         CVarDef.Create("ban.hardware_ids", true, CVar.SERVERONLY);
+
+    /// <summary>
+    /// 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.
+    /// </summary>
+    public static readonly CVarDef<bool> 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 (file)
index 0000000..2aa3c4e
--- /dev/null
@@ -0,0 +1 @@
+multi-server-kick-reason = Connected to different server in this community.