mirror of
https://github.com/daffyyyy/CS2-SimpleAdmin.git
synced 2026-10-17 20:37:05 +02:00
Fixes for issues reported by server owners, plus one new command.
Unbans took up to a minute to apply, and on SQLite never applied at all:
- css_unban only wrote to the database, but the connect check reads an
in-memory cache that syncs on a 61s timer. BanManager.UnbanPlayer now
marks the bans UNBANNED in CacheManager and rebuilds the indexes, and
logs failures instead of swallowing them.
- SQLite has no ON UPDATE CURRENT_TIMESTAMP and the unban/expire queries
never set updated_at, so the delta refresh never saw the change and
the ban stayed active until a plugin reload. Both queries set it now.
- The delta query uses >= so a change written in the same second as the
refresh snapshot is not skipped.
- css_unban issues removeid when UnlockedCommands is on, clearing the
engine-level banid that css_ban adds.
- RebuildIndexes builds fresh dictionaries and swaps them under a lock,
so the game thread cannot read a half-built index now that unbans can
trigger a rebuild off the timer thread.
Admin permissions were intermittently missing after a restart:
- The startup reload fired at a fixed 3s, usually before ServerManager
had resolved ServerId, so the admin query ran with a null server and
returned only global admins, with nothing reloading afterwards.
ServerManager now reloads admins once ServerId is set, and on both of
its failure paths so global admins still load.
- Loading the plugin mid-map (css_plugins load) fires no map start, so
server loading is kicked off after 5s if it has not happened.
- CreateAdminsJsonFile no longer strips permissions from inside the
ToDictionary lambda, where it ran once per name group and seconds
before the new data was loaded. It returns the new cache and
PermissionManager.ApplyAdminCache applies it on the main thread
immediately before AdminManager.LoadAdminData.
- Reloads are serialized with a semaphore and carry a generation, so
overlapping ones cannot race on the JSON files or apply a stale
snapshot after the hibernation cap.
- GetAllPlayersFlags and GetAllGroupsData rethrow instead of returning
an empty list; ReloadAdmins catches, logs and aborts. A transient
database error previously wrote an empty admins.json and stripped
every admin on the server.
Permanent renames did not survive a restart:
- New sa_renames table (migration 017); css_prename upserts or deletes,
and the table is loaded into memory once the server is ready.
- The rename is re-applied on connect before the DatabaseProvider /
CacheManager early return, which previously skipped it whenever the
DB or cache was not ready yet.
- New EventPlayerChangename handler, and both it and OnPlayerInfo
re-check after a short delay instead of comparing names at event
time; the engine may not have written the client's new name yet, and
Rename itself takes 0.4s to settle.
Smaller fixes:
- css_say printed "ADMIN: <text>" with no name. The caller name is
passed to the localizer and sa_adminsay_prefix carries {1} in all 13
languages.
- css_penalties <player> with @css/kick reassigned only the local user
id while every lookup kept using caller.SteamID, so an admin checking
another player always saw their own penalties. It now uses the
target's SteamID, and replies instead of throwing when that player's
connect data has not finished loading.
- css_rename returned early for an admin in silent mode, which skipped
the rename itself rather than just the announcement.
New: css_history <#userid|name|steamid64> [bans|gags|mutes|silences|warns]
(@css/kick) prints every ban, gag, mute, silence and warn with status,
duration, the admin who issued it, the reason and, for lifted bans and
mutes, who lifted it, when and why. A SteamID64 works for players who
are not online. Also available in the admin menu under Players >
History. The data was always there: unban, unmute and expiry only flip
the status column, rows are never deleted.
Build:
- The csproj listed all 32 migration files by hand, so a new migration
silently never reached the output. Replaced with a glob.
- Commands missing from an existing Commands.json are registered under
their default name, so new commands appear without deleting the file.
An empty alias list still disables a command.
866 lines
No EOL
35 KiB
C#
866 lines
No EOL
35 KiB
C#
using System.Collections.Concurrent;
|
|
using CS2_SimpleAdmin.Database;
|
|
using CS2_SimpleAdmin.Models;
|
|
using Dapper;
|
|
using Microsoft.Extensions.Logging;
|
|
using ZLinq;
|
|
|
|
namespace CS2_SimpleAdmin.Managers;
|
|
|
|
internal class CacheManager: IDisposable
|
|
{
|
|
private readonly ConcurrentDictionary<int, BanRecord> _banCache = [];
|
|
// Rebuilt into fresh instances and swapped, so unlocked readers on the game thread never see a half-built index
|
|
private ConcurrentDictionary<ulong, List<BanRecord>> _steamIdIndex = [];
|
|
private ConcurrentDictionary<uint, List<BanRecord>> _ipIndex = [];
|
|
private readonly object _indexLock = new();
|
|
|
|
private readonly ConcurrentDictionary<ulong, HashSet<IpRecord>> _playerIpsCache = [];
|
|
private HashSet<uint> _cachedIgnoredIps = [];
|
|
|
|
private DateTime _lastUpdateTime = DateTime.MinValue;
|
|
private DateTime? _lastDatabaseTime = null; // Track actual time from database
|
|
private bool _isInitialized;
|
|
private bool _disposed;
|
|
|
|
/// <summary>
|
|
/// Initializes and builds the ban and IP cache from the database. Loads bans, player IP history, and config settings.
|
|
/// </summary>
|
|
/// <returns>Asynchronous task representing the initialization process.</returns>
|
|
public async Task InitializeCacheAsync()
|
|
{
|
|
if (CS2_SimpleAdmin.DatabaseProvider == null) return;
|
|
if (!CS2_SimpleAdmin.ServerLoaded) return;
|
|
if (_isInitialized) return;
|
|
|
|
try
|
|
{
|
|
Clear();
|
|
_cachedIgnoredIps = CS2_SimpleAdmin.Instance.Config.OtherSettings.IgnoredIps
|
|
.AsValueEnumerable()
|
|
.Select(IpHelper.IpToUint)
|
|
.ToHashSet();
|
|
|
|
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
|
|
List<BanRecord> bans;
|
|
|
|
if (CS2_SimpleAdmin.Instance.Config.MultiServerMode)
|
|
{
|
|
bans = (await connection.QueryAsync<BanRecord>(
|
|
"""
|
|
SELECT
|
|
id AS Id,
|
|
player_name AS PlayerName,
|
|
player_steamid AS PlayerSteamId,
|
|
player_ip AS PlayerIp,
|
|
status AS Status
|
|
FROM sa_bans
|
|
""")).ToList();
|
|
}
|
|
else
|
|
{
|
|
bans = (await connection.QueryAsync<BanRecord>(
|
|
"""
|
|
SELECT
|
|
id AS Id,
|
|
player_name AS PlayerName,
|
|
player_steamid AS PlayerSteamId,
|
|
player_ip AS PlayerIp,
|
|
status AS Status
|
|
FROM sa_bans
|
|
WHERE server_id = @serverId
|
|
""", new {serverId = CS2_SimpleAdmin.ServerId})).ToList();
|
|
}
|
|
|
|
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp)
|
|
{
|
|
// Optimization: Load IP history and build cache in single pass
|
|
var ipHistory = await connection.QueryAsync<IpHistoryRow>(
|
|
"SELECT steamid, name, address, used_at FROM sa_players_ips ORDER BY steamid, address, used_at DESC");
|
|
|
|
var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown";
|
|
var currentSteamId = 0UL;
|
|
var currentIpSet = new HashSet<IpRecord>(new IpRecordComparer());
|
|
var latestIpTimestamps = new Dictionary<uint, DateTime>();
|
|
|
|
foreach (var record in ipHistory)
|
|
{
|
|
// When we encounter a new steamid, save the previous one
|
|
if ((ulong)record.Steamid != currentSteamId && currentSteamId != 0)
|
|
{
|
|
_playerIpsCache[currentSteamId] = currentIpSet;
|
|
currentIpSet = new HashSet<IpRecord>(new IpRecordComparer());
|
|
latestIpTimestamps.Clear();
|
|
}
|
|
|
|
currentSteamId = (ulong)record.Steamid;
|
|
|
|
// Only keep the latest timestamp for each IP
|
|
if (!latestIpTimestamps.TryGetValue(record.Address, out var existingTimestamp) ||
|
|
record.Used_at > existingTimestamp)
|
|
{
|
|
latestIpTimestamps[record.Address] = record.Used_at;
|
|
currentIpSet.Add(new IpRecord(
|
|
record.Address,
|
|
record.Used_at,
|
|
string.IsNullOrEmpty(record.Name) ? unknownName : record.Name
|
|
));
|
|
}
|
|
}
|
|
|
|
// Don't forget the last steamid
|
|
if (currentSteamId != 0)
|
|
{
|
|
_playerIpsCache[currentSteamId] = currentIpSet;
|
|
}
|
|
}
|
|
|
|
foreach (var ban in bans.AsValueEnumerable())
|
|
_banCache.TryAdd(ban.Id, ban);
|
|
|
|
RebuildIndexes();
|
|
|
|
_lastUpdateTime = Time.ActualDateTime().AddSeconds(-1);
|
|
_isInitialized = true;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Console.WriteLine(e.ToString());
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears all cached data and reinitializes the cache from the database.
|
|
/// </summary>
|
|
/// <returns>Asynchronous task representing the reinitialization process.</returns>
|
|
public async Task ForceReInitializeCacheAsync()
|
|
{
|
|
_isInitialized = false;
|
|
|
|
_banCache.Clear();
|
|
_playerIpsCache.Clear();
|
|
_cachedIgnoredIps = [];
|
|
_lastUpdateTime = DateTime.MinValue;
|
|
|
|
await InitializeCacheAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Refreshes the in-memory cache with updated or new data from the database since the last update time.
|
|
/// Also updates multi-account IP history if enabled.
|
|
/// </summary>
|
|
/// <returns>Asynchronous task representing the refresh operation.</returns>
|
|
public async Task RefreshCacheAsync()
|
|
{
|
|
if (CS2_SimpleAdmin.DatabaseProvider == null) return;
|
|
if (!_isInitialized) return;
|
|
|
|
try
|
|
{
|
|
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
|
|
IEnumerable<BanRecord> updatedBans;
|
|
|
|
// Get current time from database in local timezone (CURRENT_TIMESTAMP uses session timezone, not UTC)
|
|
var currentDatabaseTime = await connection.QueryFirstAsync<DateTime>("SELECT CURRENT_TIMESTAMP");
|
|
|
|
// Optimization: Only get IDs for comparison if we need to check for deletions
|
|
// Most of the time bans are just added/updated, not deleted
|
|
HashSet<int>? allIds = null;
|
|
|
|
if (CS2_SimpleAdmin.Instance.Config.MultiServerMode)
|
|
{
|
|
// Use previous database time or start from far past if first run
|
|
var lastCheckTime = _lastDatabaseTime ?? DateTime.MinValue;
|
|
|
|
// Get recently updated bans by timestamp (using database time to avoid timezone issues)
|
|
var updatedBans_Query = (await connection.QueryAsync<BanRecord>(
|
|
"""
|
|
SELECT id AS Id,
|
|
player_name AS PlayerName,
|
|
player_steamid AS PlayerSteamId,
|
|
player_ip AS PlayerIp,
|
|
status AS Status
|
|
FROM `sa_bans` WHERE updated_at >= @lastUpdate OR created >= @lastUpdate ORDER BY updated_at DESC
|
|
""",
|
|
new { lastUpdate = lastCheckTime }
|
|
)).ToList();
|
|
|
|
// Detect changes: new bans or status changes
|
|
var updatedList = new List<BanRecord>();
|
|
foreach (var ban in updatedBans_Query)
|
|
{
|
|
if (!_banCache.TryGetValue(ban.Id, out var cachedBan))
|
|
{
|
|
// New ban
|
|
updatedList.Add(ban);
|
|
}
|
|
else if (cachedBan.Status != ban.Status)
|
|
{
|
|
// Status changed
|
|
updatedList.Add(ban);
|
|
}
|
|
}
|
|
|
|
if (updatedList.Count > 0)
|
|
{
|
|
allIds = (await connection.QueryAsync<int>("SELECT id FROM sa_bans")).ToHashSet();
|
|
}
|
|
updatedBans = updatedList;
|
|
|
|
// Update last check time to current database time
|
|
_lastDatabaseTime = currentDatabaseTime;
|
|
}
|
|
else
|
|
{
|
|
// Use previous database time or start from far past if first run
|
|
var lastCheckTime = _lastDatabaseTime ?? DateTime.MinValue;
|
|
|
|
// Get recently updated bans for this server by timestamp (using database time to avoid timezone issues)
|
|
var updatedBans_Query = (await connection.QueryAsync<BanRecord>(
|
|
"""
|
|
SELECT id AS Id,
|
|
player_name AS PlayerName,
|
|
player_steamid AS PlayerSteamId,
|
|
player_ip AS PlayerIp,
|
|
status AS Status
|
|
FROM `sa_bans` WHERE server_id = @serverId AND (updated_at >= @lastUpdate OR created >= @lastUpdate) ORDER BY updated_at DESC
|
|
""",
|
|
new { serverId = CS2_SimpleAdmin.ServerId, lastUpdate = lastCheckTime }
|
|
)).ToList();
|
|
|
|
// Detect changes: new bans or status changes
|
|
var updatedList = new List<BanRecord>();
|
|
foreach (var ban in updatedBans_Query)
|
|
{
|
|
if (!_banCache.TryGetValue(ban.Id, out var cachedBan))
|
|
{
|
|
// New ban
|
|
updatedList.Add(ban);
|
|
}
|
|
else if (cachedBan.Status != ban.Status)
|
|
{
|
|
// Status changed
|
|
updatedList.Add(ban);
|
|
}
|
|
}
|
|
|
|
if (updatedList.Count > 0)
|
|
{
|
|
allIds = (await connection.QueryAsync<int>(
|
|
"SELECT id FROM sa_bans WHERE server_id = @serverId",
|
|
new { serverId = CS2_SimpleAdmin.ServerId }
|
|
)).ToHashSet();
|
|
}
|
|
updatedBans = updatedList;
|
|
|
|
// Update last check time to current database time
|
|
_lastDatabaseTime = currentDatabaseTime;
|
|
}
|
|
|
|
// Optimization: Only process deletions if we have the full ID list
|
|
if (allIds != null)
|
|
{
|
|
foreach (var id in _banCache.Keys)
|
|
{
|
|
if (allIds.Contains(id) || !_banCache.TryRemove(id, out var ban)) continue;
|
|
|
|
if (ban.PlayerSteamId != null &&
|
|
_steamIdIndex.TryGetValue(ban.PlayerSteamId.Value, out var steamBans))
|
|
{
|
|
steamBans.RemoveAll(b => b.Id == id);
|
|
if (steamBans.Count == 0)
|
|
_steamIdIndex.TryRemove(ban.PlayerSteamId.Value, out _);
|
|
}
|
|
|
|
if (string.IsNullOrWhiteSpace(ban.PlayerIp) ||
|
|
!IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt) ||
|
|
!_ipIndex.TryGetValue(ipUInt, out var ipBans))
|
|
continue;
|
|
|
|
ipBans.RemoveAll(b => b.Id == id);
|
|
if (ipBans.Count == 0)
|
|
_ipIndex.TryRemove(ipUInt, out _);
|
|
}
|
|
}
|
|
|
|
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp)
|
|
{
|
|
var ipHistory = (await connection.QueryAsync<IpHistoryRow>(
|
|
"SELECT steamid, name, address, used_at FROM sa_players_ips WHERE used_at >= @lastUpdate ORDER BY used_at DESC LIMIT 300",
|
|
new { lastUpdate = _lastUpdateTime }));
|
|
|
|
foreach (var group in ipHistory.AsValueEnumerable().GroupBy(x => x.Steamid))
|
|
{
|
|
var ipSet = new HashSet<IpRecord>(
|
|
group
|
|
.GroupBy(x => x.Address)
|
|
.Select(g =>
|
|
{
|
|
var latest = g.MaxBy(x => x.Used_at)!; // group is never empty
|
|
return new IpRecord(
|
|
g.Key,
|
|
latest.Used_at,
|
|
!string.IsNullOrEmpty(latest.Name)
|
|
? latest.Name
|
|
: CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown"
|
|
);
|
|
}),
|
|
new IpRecordComparer()
|
|
);
|
|
|
|
_playerIpsCache.AddOrUpdate(
|
|
(ulong)group.Key,
|
|
_ => ipSet,
|
|
(_, existingSet) =>
|
|
{
|
|
foreach (var newEntry in ipSet)
|
|
{
|
|
existingSet.Remove(newEntry);
|
|
existingSet.Add(newEntry);
|
|
}
|
|
|
|
return existingSet;
|
|
});
|
|
}
|
|
}
|
|
|
|
// Update cache with new/modified bans
|
|
var needsRebuild = false;
|
|
foreach (var ban in updatedBans)
|
|
{
|
|
if (_banCache.TryGetValue(ban.Id, out var oldBan) && oldBan.Status != ban.Status)
|
|
{
|
|
// Ban status changed (e.g., ACTIVE -> EXPIRED/UNBANNED), need to rebuild indexes
|
|
needsRebuild = true;
|
|
}
|
|
_banCache.AddOrUpdate(ban.Id, ban, (_, _) => ban);
|
|
}
|
|
|
|
// Rebuild indexes if there were updates or status changes
|
|
if (updatedBans.Any() || needsRebuild)
|
|
{
|
|
RebuildIndexes();
|
|
}
|
|
|
|
_lastUpdateTime = Time.ActualDateTime().AddSeconds(-1);
|
|
}
|
|
catch (Exception)
|
|
{
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Rebuilds the internal indexes for fast lookup of active bans by Steam ID and IP address.
|
|
/// Clears and repopulates both indexes based on the current in-memory ban cache.
|
|
/// </summary>
|
|
private void RebuildIndexes()
|
|
{
|
|
// SetBanStatus (unban thread) and RefreshCacheAsync (timer thread) can both get here
|
|
lock (_indexLock)
|
|
{
|
|
RebuildIndexesCore();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Updates the status of the given bans in the cache and rebuilds the indexes,
|
|
/// so unbans take effect immediately instead of waiting for the next refresh.
|
|
/// </summary>
|
|
/// <param name="banIds">Ban ids to update.</param>
|
|
/// <param name="status">New status.</param>
|
|
public void SetBanStatus(IEnumerable<int> banIds, BanStatus status)
|
|
{
|
|
var changed = false;
|
|
foreach (var id in banIds)
|
|
{
|
|
if (!_banCache.TryGetValue(id, out var ban) || ban.StatusEnum == status) continue;
|
|
_banCache[id] = ban with { Status = status.ToString() };
|
|
changed = true;
|
|
}
|
|
|
|
if (changed) RebuildIndexes();
|
|
}
|
|
|
|
private void RebuildIndexesCore()
|
|
{
|
|
var steamIdIndex = new ConcurrentDictionary<ulong, List<BanRecord>>();
|
|
var ipIndex = new ConcurrentDictionary<uint, List<BanRecord>>();
|
|
|
|
// Optimization: Cache config value to avoid repeated property access
|
|
var banType = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType;
|
|
var checkIpBans = banType != 0;
|
|
|
|
// Optimization: Pre-filter only ACTIVE bans to avoid checking status in loop
|
|
var activeBans = _banCache.Values.Where(b => b.StatusEnum == BanStatus.ACTIVE);
|
|
|
|
foreach (var ban in activeBans)
|
|
{
|
|
// Index by Steam ID
|
|
if (ban.PlayerSteamId.HasValue)
|
|
{
|
|
var steamId = ban.PlayerSteamId.Value;
|
|
if (!steamIdIndex.TryGetValue(steamId, out var steamList))
|
|
{
|
|
steamList = new List<BanRecord>();
|
|
steamIdIndex[steamId] = steamList;
|
|
}
|
|
steamList.Add(ban);
|
|
}
|
|
|
|
// Index by IP (only if IP bans are enabled)
|
|
if (checkIpBans && !string.IsNullOrEmpty(ban.PlayerIp) &&
|
|
IpHelper.TryConvertIpToUint(ban.PlayerIp, out var ipUInt))
|
|
{
|
|
if (!ipIndex.TryGetValue(ipUInt, out var ipList))
|
|
{
|
|
ipList = new List<BanRecord>();
|
|
ipIndex[ipUInt] = ipList;
|
|
}
|
|
ipList.Add(ban);
|
|
}
|
|
}
|
|
|
|
// Reference swap is atomic; readers finish on the old index or start on the complete new one
|
|
Volatile.Write(ref _steamIdIndex, steamIdIndex);
|
|
Volatile.Write(ref _ipIndex, ipIndex);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Retrieves all ban records currently stored in the cache.
|
|
/// </summary>
|
|
/// <returns>List of all <see cref="BanRecord"/> objects.</returns>
|
|
public List<BanRecord> GetAllBans() => _banCache.Values.ToList();
|
|
|
|
/// <summary>
|
|
/// Retrieves only active ban records from the cache.
|
|
/// </summary>
|
|
/// <returns>List of active <see cref="BanRecord"/> objects.</returns>
|
|
public List<BanRecord> GetActiveBans() => _banCache.Values.Where(b => b.StatusEnum == BanStatus.ACTIVE).ToList();
|
|
|
|
/// <summary>
|
|
/// Retrieves all ban records for a specific player by their Steam ID.
|
|
/// </summary>
|
|
/// <param name="steamId">64-bit Steam ID of the player.</param>
|
|
/// <returns>List of <see cref="BanRecord"/> objects associated with the Steam ID.</returns>
|
|
public List<BanRecord> GetPlayerBansBySteamId(ulong steamId) => _steamIdIndex.TryGetValue(steamId, out var bans) ? bans : [];
|
|
|
|
/// <summary>
|
|
/// Gets all known Steam accounts that have used the specified IP address.
|
|
/// </summary>
|
|
/// <param name="ipAddress">The IP address to search for, in string format.</param>
|
|
/// <returns>
|
|
/// List of tuples containing the Steam ID, last used time, and player name for each matching entry.
|
|
/// </returns>
|
|
public List<(ulong SteamId, DateTime UsedAt, string PlayerName)> GetAccountsByIp(string ipAddress)
|
|
{
|
|
var ipAsUint = IpHelper.IpToUint(ipAddress);
|
|
var results = new List<(ulong, DateTime, string)>();
|
|
|
|
// Optimization: Direct lookup using HashSet.Contains instead of TryGetValue
|
|
var searchRecord = new IpRecord(ipAsUint, default, null!);
|
|
|
|
foreach (var (steamId, ipSet) in _playerIpsCache)
|
|
{
|
|
// Optimization: Single pass through the set
|
|
foreach (var entry in ipSet)
|
|
{
|
|
if (entry.Ip == ipAsUint)
|
|
{
|
|
results.Add((steamId, entry.UsedAt, entry.PlayerName));
|
|
}
|
|
}
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
// public IEnumerable<(ulong SteamId, DateTime UsedAt, string PlayerName)> GetAccountsByIp(string ipAddress)
|
|
// {
|
|
// var ipAsUint = IpHelper.IpToUint(ipAddress);
|
|
//
|
|
// return _playerIpsCache.SelectMany(kvp => kvp.Value
|
|
// .Where(entry => entry.Ip == ipAsUint)
|
|
// .Select(entry => (kvp.Key, entry.UsedAt, entry.PlayerName)));
|
|
// }
|
|
|
|
private bool IsIpBanned(string ipAddress)
|
|
{
|
|
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0) return false;
|
|
var ipUInt = IpHelper.IpToUint(ipAddress);
|
|
return !_cachedIgnoredIps.Contains(ipUInt) && _ipIndex.ContainsKey(ipUInt);
|
|
}
|
|
|
|
// public bool IsPlayerBanned(ulong? steamId, string? ipAddress)
|
|
// {
|
|
// if (steamId != null && _steamIdIndex.ContainsKey(steamId.Value))
|
|
// return true;
|
|
//
|
|
// if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0)
|
|
// return false;
|
|
//
|
|
// if (string.IsNullOrEmpty(ipAddress) || !IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt))
|
|
// return false;
|
|
//
|
|
// return !_cachedIgnoredIps.Contains(ipUInt) && _ipIndex.ContainsKey(ipUInt);
|
|
// }
|
|
|
|
/// <summary>
|
|
/// Checks if a player is currently banned by Steam ID or IP address.
|
|
/// If a partial ban record is found, updates it with the latest player information.
|
|
/// </summary>
|
|
/// <param name="playerName">Name of the player attempting to connect.</param>
|
|
/// <param name="steamId">Optional 64-bit Steam ID of the player.</param>
|
|
/// <param name="ipAddress">Optional IP address of the player.</param>
|
|
/// <returns>True if the player is banned, otherwise false.</returns>
|
|
public bool IsPlayerBanned(string playerName, ulong? steamId, string? ipAddress)
|
|
{
|
|
BanRecord? record;
|
|
if (steamId.HasValue && _steamIdIndex.TryGetValue(steamId.Value, out var steamRecords))
|
|
{
|
|
record = steamRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
|
|
if (record != null)
|
|
{
|
|
// Double-check the ban is still active in cache (handle race conditions)
|
|
if (_banCache.TryGetValue(record.Id, out var cachedBan) && cachedBan.StatusEnum == BanStatus.ACTIVE)
|
|
{
|
|
if ((string.IsNullOrEmpty(record.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) ||
|
|
(!record.PlayerSteamId.HasValue))
|
|
{
|
|
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0 || string.IsNullOrEmpty(ipAddress))
|
|
return false;
|
|
|
|
if (string.IsNullOrEmpty(ipAddress) ||
|
|
!IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt) ||
|
|
_cachedIgnoredIps.Contains(ipUInt) ||
|
|
!_ipIndex.TryGetValue(ipUInt, out var ipRecords)) return false;
|
|
|
|
record = ipRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
|
|
if (record == null) return false;
|
|
|
|
// Double-check the ban is still active in cache (handle race conditions)
|
|
if (!_banCache.TryGetValue(record.Id, out var cachedBanIp) || cachedBanIp.StatusEnum != BanStatus.ACTIVE)
|
|
return false;
|
|
|
|
if ((string.IsNullOrEmpty(record.PlayerIp) && !string.IsNullOrEmpty(ipAddress)) ||
|
|
(!record.PlayerSteamId.HasValue && steamId.HasValue))
|
|
{
|
|
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
// public bool IsPlayerOrAnyIpBanned(ulong steamId, string? ipAddress)
|
|
// {
|
|
// if (_steamIdIndex.ContainsKey(steamId))
|
|
// return true;
|
|
//
|
|
// if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0)
|
|
// return false;
|
|
//
|
|
// if (!_playerIpsCache.TryGetValue(steamId, out var ipData))
|
|
// return false;
|
|
//
|
|
// // var now = Time.ActualDateTime();
|
|
// var cutoff = Time.ActualDateTime().AddDays(-CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans);
|
|
// var unknownName = CS2_SimpleAdmin._localizer?["sa_unknown"] ?? "Unknown";
|
|
//
|
|
// if (ipAddress != null && IpHelper.TryConvertIpToUint(ipAddress, out var ipAsUint))
|
|
// {
|
|
// if (!_cachedIgnoredIps.Contains(ipAsUint))
|
|
// {
|
|
// ipData.Add(new IpRecord(
|
|
// ipAsUint,
|
|
// Time.ActualDateTime().AddSeconds(-2),
|
|
// unknownName
|
|
// ));
|
|
// }
|
|
// }
|
|
//
|
|
// // foreach (var ipRecord in ipData)
|
|
// // {
|
|
// // // Skip if too old or in ignored list
|
|
// // if (ipRecord.UsedAt < cutoff || _cachedIgnoredIps.Contains(ipRecord.Ip))
|
|
// // continue;
|
|
// //
|
|
// // // Check if IP is banned
|
|
// // if (_ipIndex.ContainsKey(ipRecord.Ip))
|
|
// // return true;
|
|
// // }
|
|
//
|
|
// foreach (var ipRecord in ipData)
|
|
// {
|
|
// if (ipRecord.UsedAt < cutoff || _cachedIgnoredIps.Contains(ipRecord.Ip))
|
|
// continue;
|
|
//
|
|
// if (!_ipIndex.TryGetValue(ipRecord.Ip, out var banRecords)) continue;
|
|
//
|
|
// var activeBan = banRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
|
|
// if (activeBan == null) continue;
|
|
//
|
|
// if (!string.IsNullOrEmpty(activeBan.PlayerName) && activeBan.PlayerSteamId.HasValue) return true;
|
|
//
|
|
// _ = Task.Run(() => UpdatePlayerData(
|
|
// activeBan.PlayerName,
|
|
// steamId,
|
|
// ipAddress
|
|
// ));
|
|
//
|
|
// if (string.IsNullOrEmpty(activeBan.PlayerName) && !string.IsNullOrEmpty(unknownName))
|
|
// activeBan.PlayerName = unknownName;
|
|
//
|
|
// activeBan.PlayerSteamId ??= steamId;
|
|
//
|
|
// return true;
|
|
// }
|
|
//
|
|
// return false;
|
|
// }
|
|
|
|
/// <summary>
|
|
/// Checks if the player or any IP previously associated with them is currently banned.
|
|
/// Also updates ban records with missing player info if found.
|
|
/// </summary>
|
|
/// <param name="playerName">Current player name.</param>
|
|
/// <param name="steamId">64-bit Steam ID of the player.</param>
|
|
/// <param name="ipAddress">Current IP address of the player (optional).</param>
|
|
/// <returns>True if the player or their known IPs are banned, otherwise false.</returns>
|
|
public bool IsPlayerOrAnyIpBanned(string playerName, ulong steamId, string? ipAddress)
|
|
{
|
|
if (_steamIdIndex.TryGetValue(steamId, out var steamBans))
|
|
{
|
|
var activeBan = steamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE);
|
|
if (activeBan != null)
|
|
{
|
|
// Double-check the ban is still active in cache (handle race conditions)
|
|
if (_banCache.TryGetValue(activeBan.Id, out var cachedBan) && cachedBan.StatusEnum == BanStatus.ACTIVE)
|
|
{
|
|
if (string.IsNullOrEmpty(activeBan.PlayerName) || string.IsNullOrEmpty(activeBan.PlayerIp) && !string.IsNullOrEmpty(ipAddress))
|
|
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
|
|
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0 || string.IsNullOrEmpty(ipAddress))
|
|
return false;
|
|
|
|
if (!IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt))
|
|
return false;
|
|
|
|
if (_cachedIgnoredIps.Contains(ipUInt))
|
|
return false;
|
|
|
|
// Direct ip ban (ban record has player_ip set)
|
|
if (_ipIndex.TryGetValue(ipUInt, out var ipBanRecords))
|
|
{
|
|
var ipBan = ipBanRecords.FirstOrDefault(r => r.StatusEnum == BanStatus.ACTIVE);
|
|
if (ipBan != null && _banCache.TryGetValue(ipBan.Id, out var cachedIpBan) && cachedIpBan.StatusEnum == BanStatus.ACTIVE)
|
|
{
|
|
var expireOldIpBans = CS2_SimpleAdmin.Instance.Config.OtherSettings.ExpireOldIpBans;
|
|
if (expireOldIpBans <= 0 || ipBan.Created >= Time.ActualDateTime().AddDays(-expireOldIpBans))
|
|
{
|
|
if (string.IsNullOrEmpty(ipBan.PlayerName))
|
|
ipBan.PlayerName = playerName;
|
|
ipBan.PlayerSteamId ??= steamId;
|
|
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Multiaccount ban - check if other accounts using current ip are banned
|
|
if (!_playerIpsCache.IsEmpty)
|
|
{
|
|
foreach (var (otherSteamId, ipSet) in _playerIpsCache)
|
|
{
|
|
// Skip current player
|
|
if (otherSteamId == steamId)
|
|
continue;
|
|
|
|
// Check if this ip is in the other accounts ip history
|
|
if (ipSet.All(record => record.Ip != ipUInt)) continue;
|
|
// Found another account using this ip - check if its banned
|
|
if (!_steamIdIndex.TryGetValue(otherSteamId, out var otherSteamBans)) continue;
|
|
var activeBan = otherSteamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE);
|
|
if (activeBan == null || !_banCache.TryGetValue(activeBan.Id, out var cachedBan) ||
|
|
cachedBan.StatusEnum != BanStatus.ACTIVE) continue;
|
|
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// Multiaccount ban - check if this player used any ip where other banned accounts are connected
|
|
// Search sa_players_ips for all accounts sharing the same ips as current player
|
|
if (!CS2_SimpleAdmin.Instance.Config.OtherSettings.CheckMultiAccountsByIp)
|
|
return false;
|
|
|
|
if (!_playerIpsCache.TryGetValue(steamId, out var playerIps))
|
|
return false;
|
|
|
|
// For each ip the player used (current or historical)
|
|
foreach (var playerIpRecord in playerIps)
|
|
{
|
|
// Search sa_players_ips for other accounts using this same ip (as uint)
|
|
foreach (var (otherSteamId, otherIpSet) in _playerIpsCache)
|
|
{
|
|
if (otherSteamId == steamId)
|
|
continue;
|
|
|
|
// Check if this other account used the player ip
|
|
if (otherIpSet.All(record => record.Ip != playerIpRecord.Ip))
|
|
continue;
|
|
|
|
// Check if this other account is banned
|
|
if (!_steamIdIndex.TryGetValue(otherSteamId, out var otherSteamBans))
|
|
continue;
|
|
|
|
var activeBan = otherSteamBans.FirstOrDefault(b => b.StatusEnum == BanStatus.ACTIVE);
|
|
if (activeBan == null || !_banCache.TryGetValue(activeBan.Id, out var cachedBan) ||
|
|
cachedBan.StatusEnum != BanStatus.ACTIVE)
|
|
continue;
|
|
|
|
_ = Task.Run(() => UpdatePlayerData(playerName, steamId, ipAddress));
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Checks if the given IP address is known (previously recorded) for the specified Steam ID.
|
|
/// </summary>
|
|
/// <param name="steamId">64-bit Steam ID of the player.</param>
|
|
/// <param name="ipAddress">IP address to check.</param>
|
|
/// <returns>True if the IP is recorded for the player, otherwise false.</returns>
|
|
public bool HasIpForPlayer(ulong steamId, string ipAddress)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(ipAddress))
|
|
return false;
|
|
|
|
if (!IpHelper.TryConvertIpToUint(ipAddress, out var ipUint))
|
|
return false;
|
|
|
|
return _playerIpsCache.TryGetValue(steamId, out var ipData) &&
|
|
ipData.Contains(new IpRecord(ipUint, default, null!));
|
|
}
|
|
|
|
// public bool HasIpForPlayer(ulong steamId, string ipAddress)
|
|
// {
|
|
// if (string.IsNullOrWhiteSpace(ipAddress))
|
|
// return false;
|
|
//
|
|
// return _playerIpsCache.TryGetValue(steamId, out var ipData)
|
|
// && ipData.Any(x => x.Ip == IpHelper.IpToUint(ipAddress));
|
|
// }
|
|
|
|
/// <summary>
|
|
/// Updates existing active ban records in the database with the latest known player name and IP address.
|
|
/// Also updates in-memory cache to reflect these changes.
|
|
/// </summary>
|
|
/// <param name="playerName">Current player name.</param>
|
|
/// <param name="steamId">Optional Steam ID of the player.</param>
|
|
/// <param name="ipAddress">Optional IP address of the player.</param>
|
|
/// <returns>Asynchronous task representing the update operation.</returns>
|
|
private async Task UpdatePlayerData(string? playerName, ulong? steamId, string? ipAddress)
|
|
{
|
|
if (CS2_SimpleAdmin.DatabaseProvider == null)
|
|
return;
|
|
|
|
var baseSql = """
|
|
UPDATE sa_bans
|
|
SET
|
|
player_ip = COALESCE(player_ip, @PlayerIP),
|
|
player_name = COALESCE(player_name, @PlayerName)
|
|
WHERE
|
|
(player_steamid = @PlayerSteamID OR player_ip = @PlayerIP)
|
|
AND status = 'ACTIVE'
|
|
AND (duration = 0 OR ends > @CurrentTime)
|
|
""";
|
|
|
|
if (!CS2_SimpleAdmin.Instance.Config.MultiServerMode)
|
|
{
|
|
baseSql += " AND server_id = @ServerId;";
|
|
}
|
|
|
|
var parameters = new
|
|
{
|
|
PlayerSteamID = steamId,
|
|
PlayerIP = CS2_SimpleAdmin.Instance.Config.OtherSettings.BanType == 0
|
|
|| string.IsNullOrEmpty(ipAddress)
|
|
|| CS2_SimpleAdmin.Instance.Config.OtherSettings.IgnoredIps.Contains(ipAddress)
|
|
? null
|
|
: ipAddress,
|
|
PlayerName = string.IsNullOrEmpty(playerName) ? string.Empty : playerName,
|
|
CurrentTime = Time.ActualDateTime(),
|
|
CS2_SimpleAdmin.ServerId
|
|
};
|
|
|
|
await using var connection = await CS2_SimpleAdmin.DatabaseProvider.CreateConnectionAsync();
|
|
await connection.ExecuteAsync(baseSql, parameters);
|
|
|
|
if (steamId.HasValue && _steamIdIndex.TryGetValue(steamId.Value, out var steamRecords))
|
|
{
|
|
foreach (var rec in steamRecords.Where(r => r.StatusEnum == BanStatus.ACTIVE))
|
|
{
|
|
if (string.IsNullOrEmpty(rec.PlayerIp) && !string.IsNullOrEmpty(ipAddress))
|
|
rec.PlayerIp = ipAddress;
|
|
|
|
if (string.IsNullOrEmpty(rec.PlayerName) && !string.IsNullOrEmpty(playerName))
|
|
rec.PlayerName = playerName;
|
|
}
|
|
}
|
|
|
|
if (!string.IsNullOrEmpty(ipAddress) && IpHelper.TryConvertIpToUint(ipAddress, out var ipUInt)
|
|
&& _ipIndex.TryGetValue(ipUInt, out var ipRecords))
|
|
{
|
|
foreach (var rec in ipRecords.Where(r => r.StatusEnum == BanStatus.ACTIVE))
|
|
{
|
|
if (!rec.PlayerSteamId.HasValue && steamId.HasValue)
|
|
rec.PlayerSteamId = steamId;
|
|
|
|
if (string.IsNullOrEmpty(rec.PlayerName) && !string.IsNullOrEmpty(playerName))
|
|
rec.PlayerName = playerName;
|
|
}
|
|
}
|
|
}
|
|
|
|
private void Clear()
|
|
{
|
|
_steamIdIndex.Clear();
|
|
_ipIndex.Clear();
|
|
|
|
_banCache.Clear();
|
|
_playerIpsCache.Clear();
|
|
_cachedIgnoredIps.Clear();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears and disposes of all cached data and marks the object as disposed.
|
|
/// </summary>
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
Clear();
|
|
_disposed = true;
|
|
}
|
|
}
|
|
|
|
public class IpRecordComparer : IEqualityComparer<IpRecord>
|
|
{
|
|
public bool Equals(IpRecord x, IpRecord y)
|
|
=> x.Ip == y.Ip;
|
|
|
|
public int GetHashCode(IpRecord obj)
|
|
=> obj.Ip.GetHashCode();
|
|
} |