diff --git a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs index 19a5546..78a93ee 100644 --- a/CS2-SimpleAdmin/CS2-SimpleAdmin.cs +++ b/CS2-SimpleAdmin/CS2-SimpleAdmin.cs @@ -22,7 +22,7 @@ public partial class CS2_SimpleAdmin : BasePlugin, IPluginConfig "CS2-SimpleAdmin" + (Helper.IsDebugBuild ? " (DEBUG)" : " (RELEASE)"); public override string ModuleDescription => "Simple admin plugin for Counter-Strike 2 :)"; public override string ModuleAuthor => "daffyy"; - public override string ModuleVersion => "1.8.2b-zio1"; + public override string ModuleVersion => "1.8.2b-zio3"; public override void Load(bool hotReload) { diff --git a/CS2-SimpleAdmin/Events.cs b/CS2-SimpleAdmin/Events.cs index ede2121..58e95f9 100644 --- a/CS2-SimpleAdmin/Events.cs +++ b/CS2-SimpleAdmin/Events.cs @@ -95,6 +95,7 @@ public partial class CS2_SimpleAdmin CachedPlayers.Remove(player); BotPlayers.Remove(player); SilentPlayers.Remove(player.Slot); + PendingBanKicks.Remove(player.Slot); if (player.IsBot) { @@ -309,6 +310,10 @@ public partial class CS2_SimpleAdmin var command = info.GetArg(0).ToLower(); + // zio: a banned player waiting to be kicked can't talk or join a team. + if (PendingBanKicks.Contains(player.Slot) && (command.Contains("say") || command == "jointeam")) + return HookResult.Stop; + if (Config.OtherSettings.AdditionalCommandsToLog.Contains(command)) Helper.LogCommand(player, info); @@ -405,21 +410,10 @@ public partial class CS2_SimpleAdmin if (remaining is null) player.SendLocalizedMessage(_localizer, $"sa_player_penalty_{kind}_active_perm", reason); else - player.SendLocalizedMessage(_localizer, $"sa_player_penalty_{kind}_active", FormatRemaining(remaining.Value), reason); + player.SendLocalizedMessage(_localizer, $"sa_player_penalty_{kind}_active", Helper.FormatRemaining(remaining.Value), reason); } } - private static string FormatRemaining(TimeSpan remaining) - { - if (remaining.TotalMinutes < 1) - return "less than a minute"; - var parts = new List(); - if (remaining.Days > 0) parts.Add($"{remaining.Days}d"); - if (remaining.Hours > 0) parts.Add($"{remaining.Hours}h"); - if (remaining.Minutes > 0 && remaining.Days == 0) parts.Add($"{remaining.Minutes}m"); - return string.Join(" ", parts); - } - /*public HookResult OnCommandSay(CCSPlayerController? player, CommandInfo info) { if (player == null || !player.IsValid || player.IsBot) @@ -534,7 +528,9 @@ public partial class CS2_SimpleAdmin if (player == null || !player.IsValid || player.IsBot || !SilentPlayers.Contains(player.Slot)) return HookResult.Continue; - if (@event is not { Oldteam: <= 1, Team: >= 1 }) return HookResult.Continue; + // zio: only joining T or CT unhides. Moving to spectators doesn't, so a plugin can take a + // hidden admin there (e.g. to close the team select screen) and keep them hidden. + if (@event is not { Oldteam: <= 1, Team: >= 2 }) return HookResult.Continue; SilentPlayers.Remove(player.Slot); SimpleAdminApi?.OnAdminToggleSilentEvent(player.Slot, false); diff --git a/CS2-SimpleAdmin/Helper.cs b/CS2-SimpleAdmin/Helper.cs index aff807d..833b200 100644 --- a/CS2-SimpleAdmin/Helper.cs +++ b/CS2-SimpleAdmin/Helper.cs @@ -132,6 +132,18 @@ internal static class Helper } } + // zio: "2d 4h", "3h 15m", "45m" or "less than a minute", for time left on a penalty. + internal static string FormatRemaining(TimeSpan remaining) + { + if (remaining.TotalMinutes < 1) + return "less than a minute"; + var parts = new List(); + if (remaining.Days > 0) parts.Add($"{remaining.Days}d"); + if (remaining.Hours > 0) parts.Add($"{remaining.Hours}h"); + if (remaining.Minutes > 0 && remaining.Days == 0) parts.Add($"{remaining.Minutes}m"); + return string.Join(" ", parts); + } + public static void KickPlayer(int userId, NetworkDisconnectionReason reason = NetworkDisconnectionReason.NETWORK_DISCONNECT_KICKED, int delay = 0) { var player = Utilities.GetPlayerFromUserid(userId); diff --git a/CS2-SimpleAdmin/Managers/CacheManager.cs b/CS2-SimpleAdmin/Managers/CacheManager.cs index d02e036..db74f2c 100644 --- a/CS2-SimpleAdmin/Managers/CacheManager.cs +++ b/CS2-SimpleAdmin/Managers/CacheManager.cs @@ -478,8 +478,12 @@ internal class CacheManager: IDisposable /// Optional 64-bit Steam ID of the player. /// Optional IP address of the player. /// True if the player is banned, otherwise false. - public bool IsPlayerBanned(string playerName, ulong? steamId, string? ipAddress) + public bool IsPlayerBanned(string playerName, ulong? steamId, string? ipAddress) => IsPlayerBanned(playerName, steamId, ipAddress, out _); + + // zio: also returns the ban that matched, so the player can be told why. + public bool IsPlayerBanned(string playerName, ulong? steamId, string? ipAddress, out BanRecord? ban) { + ban = null; BanRecord? record; if (steamId.HasValue && _steamIdIndex.TryGetValue(steamId.Value, out var steamRecords)) { @@ -495,6 +499,7 @@ internal class CacheManager: IDisposable _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); } + ban = record; return true; } } @@ -521,6 +526,7 @@ internal class CacheManager: IDisposable _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); } + ban = record; return true; } @@ -599,8 +605,12 @@ internal class CacheManager: IDisposable /// 64-bit Steam ID of the player. /// Current IP address of the player (optional). /// True if the player or their known IPs are banned, otherwise false. - public bool IsPlayerOrAnyIpBanned(string playerName, ulong steamId, string? ipAddress) + public bool IsPlayerOrAnyIpBanned(string playerName, ulong steamId, string? ipAddress) => IsPlayerOrAnyIpBanned(playerName, steamId, ipAddress, out _); + + // zio: also returns the ban that matched, so the player can be told why. + public bool IsPlayerOrAnyIpBanned(string playerName, ulong steamId, string? ipAddress, out BanRecord? ban) { + ban = null; if (_steamIdIndex.TryGetValue(steamId, out var steamBans)) { var activeBan = steamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE); @@ -612,6 +622,7 @@ internal class CacheManager: IDisposable if (string.IsNullOrEmpty(activeBan.PlayerName) || string.IsNullOrEmpty(activeBan.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); + ban = activeBan; return true; } } @@ -639,6 +650,7 @@ internal class CacheManager: IDisposable ipBan.PlayerName = playerName; ipBan.PlayerSteamId ??= steamId; _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); + ban = ipBan; return true; } } @@ -661,6 +673,7 @@ internal class CacheManager: IDisposable if (activeBan == null || !_banCache.TryGetValue(activeBan.Id, out var cachedBan) || cachedBan.StatusEnum != BanStatus.ACTIVE) continue; _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); + ban = activeBan; return true; } } @@ -696,6 +709,7 @@ internal class CacheManager: IDisposable continue; _ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress)); + ban = activeBan; return true; } } diff --git a/CS2-SimpleAdmin/Managers/PlayerManager.cs b/CS2-SimpleAdmin/Managers/PlayerManager.cs index f2aba1d..44debaa 100644 --- a/CS2-SimpleAdmin/Managers/PlayerManager.cs +++ b/CS2-SimpleAdmin/Managers/PlayerManager.cs @@ -4,6 +4,7 @@ using CounterStrikeSharp.API.Modules.Admin; using CounterStrikeSharp.API.Modules.Entities; using CounterStrikeSharp.API.Modules.Timers; using CounterStrikeSharp.API.ValveConstants.Protobuf; +using CS2_SimpleAdmin.Models; using CS2_SimpleAdminApi; using Dapper; using Microsoft.Extensions.Logging; @@ -28,6 +29,51 @@ internal class PlayerManager /// This method validates the player's identity, checks for bans, updates the IP history table, /// loads penalties (mutes/gags/warns), and optionally notifies admin players about the connecting player's penalties. /// + // zio: players or groups with this aren't held back by IP bans, or by bans on other accounts that + // share their IP; only a ban on their own SteamID applies. @css/root has it too, as it has every + // @css flag. + internal const string IpBanBypassPermission = "@css/bypassipban"; + + // zio: the reason and time left of the ban that matched, which the ban cache doesn't hold. + private static async Task<(string? Reason, int Duration, DateTime? Ends)> GetBanDetails(int banId) + { + try + { + if (CS2_SimpleAdmin.DatabaseProvider == null) + return default; + await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync(); + return await connection.QueryFirstOrDefaultAsync<(string? Reason, int Duration, DateTime? Ends)>( + "SELECT reason, duration, ends FROM sa_bans WHERE id = @Id", new { Id = banId }); + } + catch (Exception ex) + { + CS2_SimpleAdmin._logger?.LogError($"Couldn't read ban {banId}: {ex.Message}"); + return default; + } + } + + // zio: tells a banned player why, in chat and on screen, before they're kicked. + private static void TellBanned(CCSPlayerController player, (string? Reason, int Duration, DateTime? Ends) ban, bool otherAccount) + { + var localizer = CS2_SimpleAdmin._localizer; + if (localizer == null) + return; + + var reason = string.IsNullOrWhiteSpace(ban.Reason) ? localizer["sa_player_penalty_no_reason"].Value : ban.Reason; + var left = ban.Duration == 0 || ban.Ends == null + ? null + : Helper.FormatRemaining(ban.Ends.Value - Time.ActualDateTime()); + + if (left == null) + player.SendLocalizedMessage(localizer, "sa_player_ban_active_perm", reason); + else + player.SendLocalizedMessage(localizer, "sa_player_ban_active", left, reason); + if (otherAccount) + player.SendLocalizedMessage(localizer, "sa_player_ban_active_other_account"); + player.SendLocalizedMessage(localizer, "sa_player_ban_active_kick"); + player.PrintToCenter(left == null ? $"Banned permanently: {reason}" : $"Banned for {left}: {reason}"); + } + public void LoadPlayerData(CCSPlayerController player, bool fullConnect = false) { if (!player.UserId.HasValue) @@ -43,6 +89,8 @@ internal class PlayerManager ? player.PlayerName : CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"; var ipAddress = player.IpAddress?.Split(":")[0]; + // zio: read on the main thread; the ban check below runs on a worker thread. + var bypassIpBans = AdminManager.PlayerHasPermissions(new SteamID(steamId), IpBanBypassPermission); if (CS2_SimpleAdmin.DatabaseProvider == null || CS2_SimpleAdmin.Instance.CacheManager == null) return; @@ -56,24 +104,52 @@ internal class PlayerManager await SavePlayerIpAddress(steamId, playerName, ipAddress); // Always check bans first, regardless of PlayersInfo state - var isBanned = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType switch + BanRecord? ban; + bool isBanned; + if (bypassIpBans) { - 0 => CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, null), - _ => CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp - ? CS2_SimpleAdmin.Instance.CacheManager.IsPlayerOrAnyIpBanned(playerName, steamId, - ipAddress) - : CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, ipAddress) - }; + // zio: only a ban on their own SteamID applies, never an IP ban or a ban on another + // account that shares their IP. + ban = CS2_SimpleAdmin.Instance.CacheManager.GetPlayerBansBySteamId(steamId) + .FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE); + isBanned = ban != null; + } + else + { + isBanned = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType switch + { + 0 => CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, null, out ban), + _ => CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp + ? CS2_SimpleAdmin.Instance.CacheManager.IsPlayerOrAnyIpBanned(playerName, steamId, + ipAddress, out ban) + : CS2_SimpleAdmin.Instance.CacheManager.IsPlayerBanned(playerName, steamId, ipAddress, out ban) + }; + } CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] Player {playerName} ({steamId}) IP: {ipAddress} - BanType: {CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType} - CheckMultiAccounts: {CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp} - isBanned: {isBanned}"); if (isBanned) { + // zio: the disconnect screen can't carry a reason, so let them finish connecting, + // tell them why in chat and on screen, then kick after KickTime. + if (!fullConnect) + return; + CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] KICKING {playerName} ({steamId})"); + var details = ban == null ? default : await GetBanDetails(ban.Id); + var otherAccount = CS2_SimpleAdmin.Instance.CacheManager.GetPlayerBansBySteamId(steamId) + .All(b => b.StatusEnum != BanStatus.ACTIVE); await Server.NextWorldUpdateAsync(() => { CS2_SimpleAdmin._logger?.LogInformation($"[BAN CHECK] Executing kick for {playerName}"); - Helper.KickPlayer(userId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED); + var banned = Utilities.GetPlayerFromUserid(userId); + if (banned is { IsValid: true }) + { + CS2_SimpleAdmin.PendingBanKicks.Add(banned.Slot); + TellBanned(banned, details, otherAccount); + } + Helper.KickPlayer(userId, NetworkDisconnectionReason.NETWORK_DISCONNECT_REJECT_BANNED, + Math.Max(1, CS2_SimpleAdmin.Instance.Config.OtherSettings.KickTime)); }); return; @@ -299,9 +375,14 @@ internal class PlayerManager var validPlayers = Helper.GetValidPlayers(); // Use ValueTuple instead of anonymous type - better performance and less allocations var tempPlayers = new List<(string PlayerName, ulong SteamID, string? IpAddress, int? UserId, int Slot)>(validPlayers.Count); + // zio: players with IpBanBypassPermission, read here on the main thread, only count bans on + // their own SteamID. + var bypassSteamIds = new HashSet(); foreach (var p in validPlayers) { tempPlayers.Add((p.PlayerName, p.SteamID, p.IpAddress, p.UserId, p.Slot)); + if (AdminManager.PlayerHasPermissions(new SteamID(p.SteamID), IpBanBypassPermission)) + bypassSteamIds.Add(p.SteamID); } var pluginInstance = CS2_SimpleAdmin.Instance; @@ -352,7 +433,9 @@ internal class PlayerManager var steamId = player.SteamID; var ip = player.IpAddress?.Split(':')[0]; - bool isBanned = banType switch + bool isBanned = bypassSteamIds.Contains(steamId) + ? pluginInstance.CacheManager.GetPlayerBansBySteamId(steamId).Any(b => b.StatusEnum == BanStatus.ACTIVE) + : banType switch { 0 => pluginInstance.CacheManager.IsPlayerBanned(playerName, steamId, null), _ => checkMultiAccounts diff --git a/CS2-SimpleAdmin/Variables.cs b/CS2-SimpleAdmin/Variables.cs index 35424a9..d3566d6 100644 --- a/CS2-SimpleAdmin/Variables.cs +++ b/CS2-SimpleAdmin/Variables.cs @@ -41,6 +41,8 @@ public partial class CS2_SimpleAdmin // Player Management internal static readonly HashSet SilentPlayers = []; + // zio: banned players let in only to be told why, then kicked. They can't chat or join a team. + internal static readonly HashSet PendingBanKicks = []; internal static readonly Dictionary RenamedPlayers = []; internal static readonly ConcurrentDictionary PlayersInfo = []; internal static readonly List CachedPlayers = []; diff --git a/CS2-SimpleAdmin/lang/en.json b/CS2-SimpleAdmin/lang/en.json index 99ee96d..af18974 100644 --- a/CS2-SimpleAdmin/lang/en.json +++ b/CS2-SimpleAdmin/lang/en.json @@ -70,6 +70,10 @@ "sa_player_penalty_silence_active": "{lightred}You are silenced for {grey}{0}{lightred}. Reason: {grey}{1}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", "sa_player_penalty_silence_active_perm": "{lightred}You are silenced permanently. Reason: {grey}{0}\n{grey}Team chat starting with {lightred}@{grey} still reaches online staff.", "sa_player_penalty_no_reason": "none given", + "sa_player_ban_active": "{lightred}You are banned from this server for {grey}{0}{lightred}. Reason: {grey}{1}", + "sa_player_ban_active_perm": "{lightred}You are banned from this server permanently. Reason: {grey}{0}", + "sa_player_ban_active_other_account": "{grey}The ban is on another account that has played from your IP address.", + "sa_player_ban_active_kick": "{grey}You will be disconnected in a few seconds.", "sa_player_penalty_info_active_mute": "➔ Mute [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", "sa_player_penalty_info_active_gag": "➔ Gag [{lightred}❌{default}] - Expire [{lightred}{0}{default}]", diff --git a/README.md b/README.md index 2ad3635..ed6660b 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,12 @@ from the `build-1.8.2b` tag). Changes, all marked `zio:` in the code: place of "Your chat is blocked to: ". New lang keys `sa_player_penalty_gag_active`, `..._gag_active_perm`, `..._silence_active`, `..._silence_active_perm` and `sa_player_penalty_no_reason`, in `lang/en.json` only (other languages fall back to it). +- Moving to spectators doesn't unhide a hidden admin; only joining T or CT does. +- A banned player is let in long enough to be told the reason and time left, then kicked after + `KickTime`, instead of seeing only "Your client is not allowed to join this server". +- New permission `@css/bypassipban`, for players or groups: IP bans, and bans on other accounts + that share their IP, don't apply to them; a ban on their own SteamID still does. `@css/root` + has it, as it has every `@css` flag. Publish with `FORGEJO_TOKEN=... ./release.sh v` (ModuleVersion is `1.8.2b-zioN`). It rebuilds from the committed source and uploads `SimpleAdmin-.tar.gz` to git.zio.sh