zio fork 1.8.2b-zio3: ban reasons on join, @css/bypassipban, spectators keep hidden
Some checks failed
Build and Publish / build (push) Has been cancelled
Build and Publish / publish (push) Has been cancelled

Banned players are let in long enough to be told the ban's reason and time left, then kicked
after KickTime, instead of only seeing "Your client is not allowed to join this server"; they
can't chat, talk or join a team meanwhile. New permission @css/bypassipban (players or groups,
implied by @css/root) skips IP and shared-IP bans, leaving only bans on their own SteamID, on
join and in the periodic check. Moving to spectators no longer unhides a hidden admin; only
joining T or CT does.
This commit is contained in:
Astra 2026-09-28 11:23:11 +01:00
parent 6171162ae9
commit 2069c1a690
8 changed files with 142 additions and 25 deletions

View file

@ -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.
/// </remarks>
// 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<ulong>();
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