-using System.Text.Json;
using System.Text.Json.Serialization;
-using Content.Server.Database;
namespace Content.Server.Administration.Managers;
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)
{
_netManager.RegisterNetMessage<MsgRoleBans>();
- _db.SubscribeToNotifications(OnDatabaseNotification);
+ _db.SubscribeToJsonNotification<BanNotificationData>(
+ _taskManager,
+ _sawmill,
+ BanNotificationChannel,
+ ProcessBanNotification,
+ OnDatabaseNotificationEarlyFilter);
_userDbData.AddOnLoadPlayer(CachePlayerData);
_userDbData.AddOnPlayerDisconnect(ClearPlayerData);
--- /dev/null
+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; }
+ }
+}
#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);
/// <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
}
HandleDatabaseNotification(notification);
}
+ public Task SendNotification(DatabaseNotification notification)
+ {
+ DbWriteOpsMetric.Inc();
+ return RunDbCommand(() => _db.SendNotification(notification));
+ }
+
private async void HandleDatabaseNotification(DatabaseNotification notification)
{
lock (_notificationHandlers)
--- /dev/null
+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);
+ });
+ });
+ }
+}
using System.Threading;
using System.Threading.Tasks;
using Content.Server.Administration.Managers;
+using Microsoft.EntityFrameworkCore;
using Npgsql;
namespace Content.Server.Database;
private static readonly string[] NotificationChannels =
[
BanManager.BanNotificationChannel,
+ MultiServerKickManager.NotificationChannel,
];
private static readonly TimeSpan ReconnectWaitIncrease = TimeSpan.FromSeconds(10);
});
}
+ 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();
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);
IoCManager.Resolve<IEntitySystemManager>().GetEntitySystem<GameTicker>().PostInitialize();
IoCManager.Resolve<IBanManager>().Initialize();
IoCManager.Resolve<IConnectionManager>().PostInit();
+ IoCManager.Resolve<MultiServerKickManager>().Initialize();
}
}
IoCManager.Register<MappingManager>();
IoCManager.Register<IWatchlistWebhookManager, WatchlistWebhookManager>();
IoCManager.Register<ConnectionManager>();
+ IoCManager.Register<MultiServerKickManager>();
}
}
}
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);
}
--- /dev/null
+multi-server-kick-reason = Connected to different server in this community.