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.Collections; using Robust.Shared.Configuration; using Robust.Shared.Enums; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Timing; namespace Content.Server.Administration.Managers; public sealed partial class BanManager : IBanManager, IPostInjectInit { [Dependency] private readonly IConfigurationManager _cfg = default!; [Dependency] private readonly IChatManager _chat = default!; [Dependency] private readonly IServerDbManager _db = default!; [Dependency] private readonly ServerDbEntryManager _entryManager = default!; [Dependency] private readonly IGameTiming _gameTiming = default!; [Dependency] private readonly ILocalizationManager _localizationManager = default!; [Dependency] private readonly ILogManager _logManager = default!; [Dependency] private readonly INetManager _netManager = default!; [Dependency] private readonly IPlayerManager _playerManager = default!; [Dependency] private readonly IPrototypeManager _prototypeManager = default!; [Dependency] private readonly IEntitySystemManager _systems = 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 PrefixAntag = "Antag:"; public const string PrefixJob = "Job:"; private readonly Dictionary> _cachedRoleBans = new(); // Cached ban exemption flags are used to handle private readonly Dictionary _cachedBanExemptions = new(); public void Initialize() { _netManager.RegisterNetMessage(); _db.SubscribeToJsonNotification( _taskManager, _sawmill, BanNotificationChannel, ProcessBanNotification, OnDatabaseNotificationEarlyFilter); _userDbData.AddOnLoadPlayer(CachePlayerData); _userDbData.AddOnPlayerDisconnect(ClearPlayerData); } private async Task CachePlayerData(ICommonSession player, CancellationToken cancel) { var flags = await _db.GetBanExemption(player.UserId, cancel); var netChannel = player.Channel; ImmutableArray? hwId = netChannel.UserData.HWId.Length == 0 ? null : netChannel.UserData.HWId; var modernHwids = netChannel.UserData.ModernHWIds; var roleBans = await _db.GetServerRoleBansAsync(netChannel.RemoteEndPoint.Address, player.UserId, hwId, modernHwids, false); var userRoleBans = new List(); foreach (var ban in roleBans) { userRoleBans.Add(ban); } cancel.ThrowIfCancellationRequested(); _cachedBanExemptions[player] = flags; _cachedRoleBans[player] = userRoleBans; SendRoleBans(player); } private void ClearPlayerData(ICommonSession player) { _cachedBanExemptions.Remove(player); } public void Restart() { // Clear out players that have disconnected. var toRemove = new ValueList(); foreach (var player in _cachedRoleBans.Keys) { if (player.Status == SessionStatus.Disconnected) toRemove.Add(player); } foreach (var player in toRemove) { _cachedRoleBans.Remove(player); } // Check for expired bans foreach (var roleBans in _cachedRoleBans.Values) { roleBans.RemoveAll(ban => DateTimeOffset.Now > ban.ExpirationTime); } } #region Server Bans public async void CreateServerBan(NetUserId? target, string? targetUsername, NetUserId? banningAdmin, (IPAddress, int)? addressRange, ImmutableTypedHwid? hwid, uint? minutes, NoteSeverity severity, string reason) { DateTimeOffset? expires = null; if (minutes > 0) { expires = DateTimeOffset.Now + TimeSpan.FromMinutes(minutes.Value); } _systems.TryGetEntitySystem(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); if (_cfg.GetCVar(CCVars.ServerBanResetLastReadRules) && target != null) await _db.SetLastReadRules(target.Value, null); // Reset their last read rules. They probably need a refresher! 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?.ToString() ?? "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, ModernHWIds = player.Channel.UserData.ModernHWIds, // 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 Role 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, ImmutableTypedHwid? hwid, ProtoId role, uint? minutes, NoteSeverity severity, string reason, DateTimeOffset timeOfBan ) where T : class, IPrototype { string encodedRole; // TODO: Note that it's possible to clash IDs here between a job and an antag. The refactor that introduced // this check has consciously avoided refactoring Job and Antag prototype. // Refactor Job- and Antag- Prototype to introduce a common RolePrototype, which will fix this possible clash. //TODO remove this check as part of the above refactor if (_prototypeManager.HasIndex(role) && _prototypeManager.HasIndex(role)) { _sawmill.Error($"Creating role ban for {role}: cannot create role ban, role is both JobPrototype and AntagPrototype."); return; } // Don't trust the input: make sure the job or antag actually exists. if (_prototypeManager.HasIndex(role)) encodedRole = PrefixJob + role; else if (_prototypeManager.HasIndex(role)) encodedRole = PrefixAntag + role; else { _sawmill.Error($"Creating role ban for {role}: cannot create role ban, role is not a JobPrototype or an AntagPrototype."); return; } 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, encodedRole); 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 is not null && _playerManager.TryGetSessionById(target.Value, out var session)) SendRoleBans(session); } private async Task AddRoleBan(ServerRoleBanDef banDef) { banDef = await _db.AddServerRoleBanAsync(banDef); if (banDef.UserId != null && _playerManager.TryGetSessionById(banDef.UserId, out var player) && _cachedRoleBans.TryGetValue(player, out var cachedBans)) { cachedBans.Add(banDef); } return true; } public async Task 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 && _playerManager.TryGetSessionById(player, out var session) && _cachedRoleBans.TryGetValue(session, out var roleBans)) { roleBans.RemoveAll(roleBan => roleBan.Id == ban.Id); SendRoleBans(session); } return $"Pardoned ban with id {banId}"; } public HashSet>? GetJobBans(NetUserId playerUserId) { return GetRoleBans(playerUserId, PrefixJob); } public HashSet>? GetAntagBans(NetUserId playerUserId) { return GetRoleBans(playerUserId, PrefixAntag); } private HashSet>? GetRoleBans(NetUserId playerUserId, string prefix) where T : class, IPrototype { if (!_playerManager.TryGetSessionById(playerUserId, out var session)) return null; return GetRoleBans(session, prefix); } private HashSet>? GetRoleBans(ICommonSession playerSession, string prefix) where T : class, IPrototype { if (!_cachedRoleBans.TryGetValue(playerSession, out var roleBans)) return null; return roleBans .Where(ban => ban.Role.StartsWith(prefix, StringComparison.Ordinal)) .Select(ban => new ProtoId(ban.Role[prefix.Length..])) .ToHashSet(); } public HashSet? GetRoleBans(NetUserId playerUserId) { if (!_playerManager.TryGetSessionById(playerUserId, out var session)) return null; return _cachedRoleBans.TryGetValue(session, out var roleBans) ? roleBans.Select(banDef => banDef.Role).ToHashSet() : null; } public bool IsRoleBanned(ICommonSession player, List> jobs) { return IsRoleBanned(player, jobs, PrefixJob); } public bool IsRoleBanned(ICommonSession player, List> antags) { return IsRoleBanned(player, antags, PrefixAntag); } private bool IsRoleBanned(ICommonSession player, List> roles, string prefix) where T : class, IPrototype { var bans = GetRoleBans(player.UserId); if (bans is null || bans.Count == 0) return false; // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator foreach (var role in roles) { if (bans.Contains(prefix + role)) return true; } return false; } public void SendRoleBans(ICommonSession pSession) { var jobBans = GetRoleBans(pSession, PrefixJob); var jobBansList = new List(jobBans?.Count ?? 0); if (jobBans is not null) { // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator foreach (var encodedId in jobBans) { jobBansList.Add(encodedId.ToString().Replace(PrefixJob, "")); } } var antagBans = GetRoleBans(pSession, PrefixAntag); var antagBansList = new List(antagBans?.Count ?? 0); if (antagBans is not null) { // ReSharper disable once ForeachCanBeConvertedToQueryUsingAnotherGetEnumerator foreach (var encodedId in antagBans) { antagBansList.Add(encodedId.ToString().Replace(PrefixAntag, "")); } } var bans = new MsgRoleBans() { JobBans = jobBansList, AntagBans = antagBansList, }; _sawmill.Debug($"Sent role bans to {pSession.Name}"); _netManager.ServerSendMessage(bans, pSession.Channel); } #endregion public void PostInject() { _sawmill = _logManager.GetSawmill(SawmillId); } }