Files
tbd-station-14/Content.Server/Administration/Managers/BanManager.cs
Julian Giebel df95be1ce5 Kick on ban for entire server group (#28649)
* Start work on PostgresNotificationManager
Implement initial version of init and listening code

* Finish implementing PostgresNotificationManager
Implement ban insert trigger

* Implement ignoring notifications if the ban was from the same server

* Address reviews

* Fixes and refactorings

Fix typo in migration SQL

Pull new code in BanManager out into its own partial file.

Unify logic to kick somebody with that when a new ban is placed directly on the server.

New bans are now checked against all parameters (IP, HWID) instead of just user ID.

Extracted SQLite ban matching code into a new class so that it can mostly be re-used by the ban notification code. No copy-paste here.

Database notifications are now not implicitly sent to the main thread, this means basic checks will happen in the thread pool beforehand.

Bans without user ID are now sent to servers. Bans are rate limited to avoid undue work from mass ban imports, beyond the rate limit they are dropped.

Improved error handling and logging for the whole system.

Matching bans against connected players requires knowing their ban exemption flags. These are now cached when the player connects.

ServerBanDef now has exemption flags, again to allow matching full ban details for ban notifications.

Made database notifications a proper struct type to reduce copy pasting a tuple.

Remove copy pasted connection string building code by just... passing the string into the constructor.

Add lock around _notificationHandlers just in case.

Fixed postgres connection wait not being called in a loop and therefore spamming LISTEN commands for every received notification.

Added more error handling and logging to notification listener.

Removed some copy pasting from SQLite database layer too while I was at it because god forbid we expect anybody else to do all the work in this project.

Sorry Julian

---------

Co-authored-by: Pieter-Jan Briers <pieterjan.briers+git@gmail.com>
2024-08-20 23:31:33 +02:00

361 lines
13 KiB
C#

using System.Collections.Immutable;
using System.Linq;
using System.Net;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Content.Server.Chat.Managers;
using Content.Server.Database;
using Content.Server.GameTicking;
using Content.Shared.CCVar;
using Content.Shared.Database;
using Content.Shared.Players;
using Content.Shared.Players.PlayTimeTracking;
using Content.Shared.Roles;
using Robust.Server.Player;
using Robust.Shared.Asynchronous;
using Robust.Shared.Configuration;
using Robust.Shared.Enums;
using Robust.Shared.Network;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
using Robust.Shared.Timing;
using Robust.Shared.Utility;
namespace Content.Server.Administration.Managers;
public sealed partial class BanManager : IBanManager, IPostInjectInit
{
[Dependency] private readonly IServerDbManager _db = default!;
[Dependency] private readonly IPlayerManager _playerManager = default!;
[Dependency] private readonly IPrototypeManager _prototypeManager = default!;
[Dependency] private readonly IEntitySystemManager _systems = default!;
[Dependency] private readonly IConfigurationManager _cfg = default!;
[Dependency] private readonly ILocalizationManager _localizationManager = default!;
[Dependency] private readonly ServerDbEntryManager _entryManager = default!;
[Dependency] private readonly IChatManager _chat = default!;
[Dependency] private readonly INetManager _netManager = default!;
[Dependency] private readonly ILogManager _logManager = default!;
[Dependency] private readonly IGameTiming _gameTiming = default!;
[Dependency] private readonly ITaskManager _taskManager = default!;
[Dependency] private readonly UserDbDataManager _userDbData = default!;
private ISawmill _sawmill = default!;
public const string SawmillId = "admin.bans";
public const string JobPrefix = "Job:";
private readonly Dictionary<NetUserId, HashSet<ServerRoleBanDef>> _cachedRoleBans = new();
// Cached ban exemption flags are used to handle
private readonly Dictionary<ICommonSession, ServerBanExemptFlags> _cachedBanExemptions = new();
public void Initialize()
{
_playerManager.PlayerStatusChanged += OnPlayerStatusChanged;
_netManager.RegisterNetMessage<MsgRoleBans>();
_db.SubscribeToNotifications(OnDatabaseNotification);
_userDbData.AddOnLoadPlayer(CachePlayerData);
_userDbData.AddOnPlayerDisconnect(ClearPlayerData);
}
private async Task CachePlayerData(ICommonSession player, CancellationToken cancel)
{
// Yeah so role ban loading code isn't integrated with exempt flag loading code.
// Have you seen how garbage role ban code code is? I don't feel like refactoring it right now.
var flags = await _db.GetBanExemption(player.UserId, cancel);
cancel.ThrowIfCancellationRequested();
_cachedBanExemptions[player] = flags;
}
private void ClearPlayerData(ICommonSession player)
{
_cachedBanExemptions.Remove(player);
}
private async void OnPlayerStatusChanged(object? sender, SessionStatusEventArgs e)
{
if (e.NewStatus != SessionStatus.Connected || _cachedRoleBans.ContainsKey(e.Session.UserId))
return;
var netChannel = e.Session.Channel;
ImmutableArray<byte>? hwId = netChannel.UserData.HWId.Length == 0 ? null : netChannel.UserData.HWId;
await CacheDbRoleBans(e.Session.UserId, netChannel.RemoteEndPoint.Address, hwId);
SendRoleBans(e.Session);
}
private async Task<bool> AddRoleBan(ServerRoleBanDef banDef)
{
banDef = await _db.AddServerRoleBanAsync(banDef);
if (banDef.UserId != null)
{
_cachedRoleBans.GetOrNew(banDef.UserId.Value).Add(banDef);
}
return true;
}
public HashSet<string>? GetRoleBans(NetUserId playerUserId)
{
return _cachedRoleBans.TryGetValue(playerUserId, out var roleBans)
? roleBans.Select(banDef => banDef.Role).ToHashSet()
: null;
}
private async Task CacheDbRoleBans(NetUserId userId, IPAddress? address = null, ImmutableArray<byte>? hwId = null)
{
var roleBans = await _db.GetServerRoleBansAsync(address, userId, hwId, false);
var userRoleBans = new HashSet<ServerRoleBanDef>();
foreach (var ban in roleBans)
{
userRoleBans.Add(ban);
}
_cachedRoleBans[userId] = userRoleBans;
}
public void Restart()
{
// Clear out players that have disconnected.
var toRemove = new List<NetUserId>();
foreach (var player in _cachedRoleBans.Keys)
{
if (!_playerManager.TryGetSessionById(player, out _))
toRemove.Add(player);
}
foreach (var player in toRemove)
{
_cachedRoleBans.Remove(player);
}
// Check for expired bans
foreach (var roleBans in _cachedRoleBans.Values)
{
roleBans.RemoveWhere(ban => DateTimeOffset.Now > ban.ExpirationTime);
}
}
#region Server Bans
public async void CreateServerBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableArray<byte>? hwid, uint? minutes, NoteSeverity severity, string reason)
{
DateTimeOffset? expires = null;
if (minutes > 0)
{
expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes.Value);
}
_systems.TryGetEntitySystem<GameTicker>(out var ticker);
int? roundId = ticker == null || ticker.RoundId == 0 ? null : ticker.RoundId;
var playtime = target == null ? TimeSpan.Zero : (await _db.GetPlayTimes(target.Value)).Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall)?.TimeSpent ?? TimeSpan.Zero;
var banDef = new ServerBanDef(
null,
target,
addressRange,
hwid,
DateTimeOffset.Now,
expires,
roundId,
playtime,
reason,
severity,
banningAdmin,
null);
await _db.AddServerBanAsync(banDef);
var adminName = banningAdmin == null
? Loc.GetString("system-user")
: (await _db.GetPlayerRecordByUserId(banningAdmin.Value))?.LastSeenUserName ?? Loc.GetString("system-user");
var targetName = target is null ? "null" : $"{targetUsername} ({target})";
var addressRangeString = addressRange != null
? $"{addressRange.Value.Item1}/{addressRange.Value.Item2}"
: "null";
var hwidString = hwid != null
? string.Concat(hwid.Value.Select(x => x.ToString("x2")))
: "null";
var expiresString = expires == null ? Loc.GetString("server-ban-string-never") : $"{expires}";
var key = _cfg.GetCVar(CCVars.AdminShowPIIOnBan) ? "server-ban-string" : "server-ban-string-no-pii";
var logMessage = Loc.GetString(
key,
("admin", adminName),
("severity", severity),
("expires", expiresString),
("name", targetName),
("ip", addressRangeString),
("hwid", hwidString),
("reason", reason));
_sawmill.Info(logMessage);
_chat.SendAdminAlert(logMessage);
KickMatchingConnectedPlayers(banDef, "newly placed ban");
}
private void KickMatchingConnectedPlayers(ServerBanDef def, string source)
{
foreach (var player in _playerManager.Sessions)
{
if (BanMatchesPlayer(player, def))
{
KickForBanDef(player, def);
_sawmill.Info($"Kicked player {player.Name} ({player.UserId}) through {source}");
}
}
}
private bool BanMatchesPlayer(ICommonSession player, ServerBanDef ban)
{
var playerInfo = new BanMatcher.PlayerInfo
{
UserId = player.UserId,
Address = player.Channel.RemoteEndPoint.Address,
HWId = player.Channel.UserData.HWId,
// It's possible for the player to not have cached data loading yet due to coincidental timing.
// If this is the case, we assume they have all flags to avoid false-positives.
ExemptFlags = _cachedBanExemptions.GetValueOrDefault(player, ServerBanExemptFlags.All),
IsNewPlayer = false,
};
return BanMatcher.BanMatches(ban, playerInfo);
}
private void KickForBanDef(ICommonSession player, ServerBanDef def)
{
var message = def.FormatBanMessage(_cfg, _localizationManager);
player.Channel.Disconnect(message);
}
#endregion
#region Job Bans
// If you are trying to remove timeOfBan, please don't. It's there because the note system groups role bans by time, reason and banning admin.
// Removing it will clutter the note list. Please also make sure that department bans are applied to roles with the same DateTimeOffset.
public async void CreateRoleBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableArray<byte>? hwid, string role, uint? minutes, NoteSeverity severity, string reason, DateTimeOffset timeOfBan)
{
if (!_prototypeManager.TryIndex(role, out JobPrototype? _))
{
throw new ArgumentException($"Invalid role '{role}'", nameof(role));
}
role = string.Concat(JobPrefix, role);
DateTimeOffset? expires = null;
if (minutes > 0)
{
expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes.Value);
}
_systems.TryGetEntitySystem(out GameTicker? ticker);
int? roundId = ticker == null || ticker.RoundId == 0 ? null : ticker.RoundId;
var playtime = target == null ? TimeSpan.Zero : (await _db.GetPlayTimes(target.Value)).Find(p => p.Tracker == PlayTimeTrackingShared.TrackerOverall)?.TimeSpent ?? TimeSpan.Zero;
var banDef = new ServerRoleBanDef(
null,
target,
addressRange,
hwid,
timeOfBan,
expires,
roundId,
playtime,
reason,
severity,
banningAdmin,
null,
role);
if (!await AddRoleBan(banDef))
{
_chat.SendAdminAlert(Loc.GetString("cmd-roleban-existing", ("target", targetUsername ?? "null"), ("role", role)));
return;
}
var length = expires == null ? Loc.GetString("cmd-roleban-inf") : Loc.GetString("cmd-roleban-until", ("expires", expires));
_chat.SendAdminAlert(Loc.GetString("cmd-roleban-success", ("target", targetUsername ?? "null"), ("role", role), ("reason", reason), ("length", length)));
if (target != null)
{
SendRoleBans(target.Value);
}
}
public async Task<string> PardonRoleBan(int banId, NetUserId? unbanningAdmin, DateTimeOffset unbanTime)
{
var ban = await _db.GetServerRoleBanAsync(banId);
if (ban == null)
{
return $"No ban found with id {banId}";
}
if (ban.Unban != null)
{
var response = new StringBuilder("This ban has already been pardoned");
if (ban.Unban.UnbanningAdmin != null)
{
response.Append($" by {ban.Unban.UnbanningAdmin.Value}");
}
response.Append($" in {ban.Unban.UnbanTime}.");
return response.ToString();
}
await _db.AddServerRoleUnbanAsync(new ServerRoleUnbanDef(banId, unbanningAdmin, DateTimeOffset.Now));
if (ban.UserId is { } player && _cachedRoleBans.TryGetValue(player, out var roleBans))
{
roleBans.RemoveWhere(roleBan => roleBan.Id == ban.Id);
SendRoleBans(player);
}
return $"Pardoned ban with id {banId}";
}
public HashSet<ProtoId<JobPrototype>>? GetJobBans(NetUserId playerUserId)
{
if (!_cachedRoleBans.TryGetValue(playerUserId, out var roleBans))
return null;
return roleBans
.Where(ban => ban.Role.StartsWith(JobPrefix, StringComparison.Ordinal))
.Select(ban => new ProtoId<JobPrototype>(ban.Role[JobPrefix.Length..]))
.ToHashSet();
}
#endregion
public void SendRoleBans(NetUserId userId)
{
if (!_playerManager.TryGetSessionById(userId, out var player))
{
return;
}
SendRoleBans(player);
}
public void SendRoleBans(ICommonSession pSession)
{
var roleBans = _cachedRoleBans.GetValueOrDefault(pSession.UserId) ?? new HashSet<ServerRoleBanDef>();
var bans = new MsgRoleBans()
{
Bans = roleBans.Select(o => o.Role).ToList()
};
_sawmill.Debug($"Sent rolebans to {pSession.Name}");
_netManager.ServerSendMessage(bans, pSession.Channel);
}
public void PostInject()
{
_sawmill = _logManager.GetSawmill(SawmillId);
}
}