// BloodKrakenAntiCheat.cs // BloodKrakenSystem — серверный поведенческий анти-чит для Rust (uMod/Oxide). // Основан на скелете AntiCheatMenu.cs (UI-слой), к которому подключён движок детекции по ТЗ. // ДОПОЛНЯЕТ EAC, не заменяет его — EAC на сервере должен оставаться включён. // // Команды: // Чат (право anticheat.admin): // /ac — открыть CUI-меню // /ac check — профиль/score игрока // /ac reset — сбросить score/флаги // /ac reload — перезагрузить конфиг // /ac verdict cheater|clean — метка для обучения (BloodKraken Cloud) // Консоль/RCON: // anticheat.stats — сводка по флагнутым // anticheat.toggle — вкл/выкл модуль // anticheat.exempt — исключение из проверок // anticheat.demo start|stop [minutes] | anticheat.demo list [SteamID] // anticheat.telemetry.status | anticheat.telemetry.test — BloodKraken Cloud (см. region) // UI (кнопки меню, право anticheat.admin): // anticheat.ui.toggle — вкл/выкл модуль + перерисовка // anticheat.ui.setflag — AutoKick/AutoBan/Discord // anticheat.ui.watch — усиленная слежка (Blue) + демка // anticheat.ui.spectate — имя последней демки/инструкция // anticheat.ui.close — закрыть меню // // Права: // bloodkrakenanticheat.admin — доступ к меню/командам // bloodkrakenanticheat.exempt — исключение из проверок // // Что детектит: нативные Rust anti-hack события (fly/speed/noclip), усиленные проверки // flyhack/speedhack/spiderhack по тикам движения, aimbot (трассологическая модель: сверка // прямой/обратной траектории с допуском, учёт рикошетов, только попадания в тело), silent aim // (расхождение взгляда и направления выстрела), no-recoil (BETA, low-confidence, по-оружейные // пороги, результат как вероятность, только флаг для ручного ревью), wallbang (Linecast + // под-кейс In Rock, урон НЕ блокируется), ESP-эвристика (выкл. по умолчанию, low-confidence — // вместо «детекта» триггерит запись демки подозреваемого). // // Внешние интеграции (опциональны, через [PluginReference]/API — чужой код НЕ копируется): // Arkan (GPLv3) — источник нарушений NoRecoil/Aimbot/InRock; при наличии предпочитается нашим // модулям (fallback). Auto Demo Record (ADR) — реализация демо-тира §7.2. Server Armour — // репутационный предфильтр на коннекте (только поднимает слежку, НЕ банит). // Отсутствие любой зависимости НЕ ломает загрузку (graceful fallback, предупреждение в лог). // Зависимости и их авторы/лицензии — см. README рядом с плагином. // // Известные ограничения: NoRecoil и ESP — low-confidence модули (BETA), никогда не ведут к // авто-кику/бану сами по себе: клиентский разброс частично случаен и сервером точно не // пересчитывается, взгляд приходит с задержкой, а прямой серверной детекции ESP не существует. // // Граница ответственности (архитектурно НЕ реализуется — недоступно серверному Oxide-плагину): // - Скриншот экрана игрока: сервер headless, изображения клиента не существует. // Вместо этого при лёгких нарушениях пишется серверный JSON-снапшот (см. #region Evidence). // - Опрос USB/периферии, детект инжектов и обращений процессов к RUST: зона клиентского // анти-чита уровня ядра (EAC), Oxide-плагин туда доступа не имеет. // - Прослушка микрофона: сервер видит только внутриигровой голос по push-to-talk; // доступа к ОС-микрофону нет. Скрытая запись микрофона не реализуется. // // Приватность: пишется только внутриигровое поведение и добровольно переданный игровой голос // (через штатную демо-запись). Укажите в правилах сервера, что ведётся запись для // античит-ревью. Улики хранятся ограниченный срок (Retention в конфиге). using System; using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; using System.Text; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using Oxide.Core; using Oxide.Core.Configuration; using Oxide.Core.Libraries; using Oxide.Core.Plugins; using Oxide.Game.Rust.Cui; using UnityEngine; namespace Oxide.Plugins { [Info("BloodKrakenAntiCheat", "BloodKraken", "1.2.0")] [Description("BloodKrakenSystem — серверный анти-чит + CUI-меню управления")] public class BloodKrakenAntiCheat : RustPlugin { #region Fields // Опциональные зависимости (§4.10/§4.11). Отсутствие любой из них НЕ ломает загрузку — // плагин работает на собственных модулях (graceful fallback). Чужой код не копируется: // взаимодействие только через [PluginReference]/API. [PluginReference] private Plugin ImageLibrary, AutoDemoRecorder, Arkan, ServerArmour; // Префикс имени плагина обязателен для прав Oxide — иначе warning при загрузке. private const string PermAdmin = "bloodkrakenanticheat.admin"; private const string PermExempt = "bloodkrakenanticheat.exempt"; private const string UiRoot = "anticheat.ui.root"; private const string ImageListName = "BloodKrakenAntiCheat"; private const int MaxHistory = 50; // Пути данных: oxide/data/BloodKrakenSystem/BloodKrakenAntiCheat/... private static readonly string BaseDir = Path.Combine(Interface.Oxide.DataDirectory, "BloodKrakenSystem", "BloodKrakenAntiCheat"); private static readonly string DemoDir = Path.Combine(BaseDir, "demo"); private static readonly string SnapshotsDir = Path.Combine(BaseDir, "snapshots"); private static readonly string ImagesDir = Path.Combine(BaseDir, "images"); // JSON-индексы через DataFileSystem (ключи, а не абсолютные пути): private const string PlayersKey = "BloodKrakenSystem/BloodKrakenAntiCheat/players"; private const string DemosKey = "BloodKrakenSystem/BloodKrakenAntiCheat/demos"; private const string SnapshotsKey = "BloodKrakenSystem/BloodKrakenAntiCheat/snapshots"; // Порядок модулей в меню private static readonly string[] Modules = { "Flyhack", "Speedhack", "Spiderhack", "Aimbot", "SilentAim", "NoRecoil", "Wallbang", "ESP" }; private Configuration config; private DynamicConfigFile _playersData; private DynamicConfigFile _demosData; private DynamicConfigFile _snapshotsData; private Dictionary _profiles = new Dictionary(); private List _demoIndex = new List(); private List _snapshotIndex = new List(); private readonly Dictionary _watch = new Dictionary(); private readonly Dictionary _activeDemos = new Dictionary(); private readonly HashSet _openMenus = new HashSet(); private readonly Queue _discordQueue = new Queue(); private Timer _decayTimer; private Timer _retentionTimer; private Timer _saveTimer; private Timer _movementTimer; private Timer _discordTimer; private bool _dataDirty; // Приблизительные физические потолки движения; при апдейтах баланса можно подстроить. private const float MaxNormalSpeed = 7.5f; // спринт, м/с private const float MaxNormalVerticalSpeed = 6.0f; // подъём без опоры, м/с private static readonly HashSet ShotgunNames = new HashSet(StringComparer.OrdinalIgnoreCase) { "shotgun.pump", "shotgun.double", "shotgun.spas12", "shotgun.waterpipe" }; private static readonly HashSet BowNames = new HashSet(StringComparer.OrdinalIgnoreCase) { "bow.hunting", "crossbow" }; private static readonly HashSet NoRecoilEligibleWeapons = new HashSet(StringComparer.OrdinalIgnoreCase) { "rifle.ak", "lmg.m249" }; private static readonly string[] WallbangWhitelistSubstrings = { "window", "embrasure", "shutter", "bars", "gate", "grill" }; // In-Rock whitelist (§4.8): монументы/пещеры, где перекрытие геометрии легитимно — // частый источник фолсов «урон из скалы» у входов в пещеры и на нефтевышке. // ВАЖНО: сюда НЕ входят "rock"/"cliff"/"formation" — это как раз поверхности чита. private static readonly string[] InRockWhitelistSubstrings = { "cave", "monument", "oilrig", "cave_", "ice_", "powerplant", "trainyard", "airfield", "harbor", "junkyard", "excavator", "dungeon" }; // Low-confidence модули: никогда не ведут к авто-кику/бану самостоятельно (§4.7/§4.9). private static readonly HashSet LowConfidenceModules = new HashSet(StringComparer.OrdinalIgnoreCase) { "NoRecoil", "ESP" }; // Состояние интеграций (§4.10/§4.11). private Timer _arkanTimer; private readonly Dictionary _arkanLastCounts = new Dictionary(); // [NoRecoil, Aimbot, InRock] private bool _arkanWarned; private readonly HashSet _reputationFlagged = new HashSet(); private class WatchBuffer { public Vector3 PendingPosition; public float PendingTime; public bool HasPending; public Vector3 LastPosition; public float LastPosTime; // Непрерывная авто-очередь для NoRecoil (§4.7): время выстрела + направление взгляда. public readonly Queue RecoilShots = new Queue(); public string LastWeaponShortname; public readonly List CombatLog = new List(); public int EspTrackFrames; public float NextEspCheck; // дросселирование ESP-проверки (не чаще раза в 2с) } private struct RecoilShot { public float Time; public Vector3 Aim; // player.eyes.HeadForward() в момент выстрела } private class ActiveDemo { public ulong SteamId; public string Reason; public DateTime StartTime; public Timer StopTimer; public bool UsingExternalPlugin; } #endregion #region Config private class ConfigGeneral { [JsonProperty("AutoKick")] public bool AutoKick = false; [JsonProperty("AutoBan")] public bool AutoBan = false; [JsonProperty("NotifyThreshold")] public float NotifyThreshold = 20.0f; [JsonProperty("KickThreshold")] public float KickThreshold = 50.0f; [JsonProperty("BanThreshold")] public float BanThreshold = 100.0f; [JsonProperty("YellowFlagCount")] public int YellowFlagCount = 3; [JsonProperty("ScoreDecayPerMinute")] public float ScoreDecayPerMinute = 1.0f; } private class NoRecoilWeaponCfg { [JsonProperty("NRMinShotsCountToCheck")] public int NRMinShotsCountToCheck = 3; [JsonProperty("NRViolationProbability")] public float NRViolationProbability = 70.0f; [JsonProperty("WeaponMinTimeShotsInterval")] public float WeaponMinTimeShotsInterval = 0.09375f; [JsonProperty("WeaponMaxTimeShotsInterval")] public float WeaponMaxTimeShotsInterval = 0.15625f; } private class ModuleCfg { [JsonProperty("Enabled")] public bool Enabled = true; [JsonProperty("Weight")] public float Weight = 1.0f; // Aimbot (§4.5, трассологическая модель) [JsonProperty("TrajectoryToleranceDeg", NullValueHandling = NullValueHandling.Ignore)] public float? TrajectoryToleranceDeg; [JsonProperty("IgnoreRicochet", NullValueHandling = NullValueHandling.Ignore)] public bool? IgnoreRicochet; [JsonProperty("CheckedHitParts", NullValueHandling = NullValueHandling.Ignore)] public List CheckedHitParts; // SilentAim (§4.6) [JsonProperty("MaxAngleDeg", NullValueHandling = NullValueHandling.Ignore)] public float? MaxAngleDeg; // NoRecoil (§4.7, по-оружейные пороги) [JsonProperty("PerWeapon", NullValueHandling = NullValueHandling.Ignore)] public Dictionary PerWeapon; // Wallbang (§4.8) [JsonProperty("CheckInRock", NullValueHandling = NullValueHandling.Ignore)] public bool? CheckInRock; // ESP (§4.9) [JsonProperty("RecordDemoOnSuspect", NullValueHandling = NullValueHandling.Ignore)] public bool? RecordDemoOnSuspect; } private class ConfigDiscord { [JsonProperty("Enabled")] public bool Enabled = false; [JsonProperty("WebhookUrl")] public string WebhookUrl = ""; [JsonProperty("MaxMessagesPerSecond")] public int MaxMessagesPerSecond = 2; } private class ConfigDemos { [JsonProperty("Enabled")] public bool Enabled = true; [JsonProperty("DemoMinScore")] public float DemoMinScore = 60.0f; [JsonProperty("RecordOnYellow")] public bool RecordOnYellow = false; [JsonProperty("RecordOnRed")] public bool RecordOnRed = true; [JsonProperty("RecordOnBlueWatch")] public bool RecordOnBlueWatch = true; [JsonProperty("MaxLengthMinutes")] public int MaxLengthMinutes = 10; [JsonProperty("StopOnDisconnect")] public bool StopOnDisconnect = true; [JsonProperty("RetentionDays")] public int RetentionDays = 7; [JsonProperty("MaxDiskUsageMb")] public int MaxDiskUsageMb = 2048; [JsonProperty("UploadToDiscord")] public bool UploadToDiscord = false; } private class ConfigSnapshots { [JsonProperty("Enabled")] public bool Enabled = true; [JsonProperty("SnapshotMaxScore")] public float SnapshotMaxScore = 60.0f; [JsonProperty("NearbyRadius")] public float NearbyRadius = 30.0f; [JsonProperty("CombatLogEntries")] public int CombatLogEntries = 10; [JsonProperty("SnapshotRetentionDays")] public int SnapshotRetentionDays = 14; [JsonProperty("EscalateToDemoAtScore")] public float EscalateToDemoAtScore = 60.0f; } // §4.11 — репутационный предфильтр (Server Armour). Только поднимает слежку, НЕ банит. private class ConfigReputation { [JsonProperty("RaiseOnRecentVacDays")] public int RaiseOnRecentVacDays = 90; [JsonProperty("MaxAllowedGameBans")] public int MaxAllowedGameBans = 2; [JsonProperty("FlagFamilyShareEvasion")] public bool FlagFamilyShareEvasion = true; // reserved: в публичном API Server Armour нет геттера VPN — флаг сохранён только // для обратной совместимости JSON, в логике не используется. [JsonProperty("FlagVpnProxy")] public bool FlagVpnProxy = true; [JsonProperty("FlagBloodyA4Tech")] public bool FlagBloodyA4Tech = true; [JsonProperty("ActionOnDirty")] public string ActionOnDirty = "watch"; // watch | notify | none } // §4.10 — внешние интеграции (Arkan / ADR / Server Armour). Все опциональны. private class ConfigIntegrations { [JsonProperty("UseArkan")] public bool UseArkan = true; [JsonProperty("ArkanWeightAimbot")] public float ArkanWeightAimbot = 1.5f; [JsonProperty("ArkanWeightNoRecoil")] public float ArkanWeightNoRecoil = 0.5f; [JsonProperty("ArkanWeightInRock")] public float ArkanWeightInRock = 1.5f; [JsonProperty("UseADR")] public bool UseADR = true; [JsonProperty("UseServerArmour")] public bool UseServerArmour = false; [JsonProperty("Reputation")] public ConfigReputation Reputation = new ConfigReputation(); } private class Configuration { [JsonProperty("General")] public ConfigGeneral General = new ConfigGeneral(); [JsonProperty("Modules")] public Dictionary ModuleConfigs = new Dictionary(); [JsonProperty("Integrations")] public ConfigIntegrations Integrations = new ConfigIntegrations(); [JsonProperty("Discord")] public ConfigDiscord Discord = new ConfigDiscord(); [JsonProperty("Demos")] public ConfigDemos Demos = new ConfigDemos(); [JsonProperty("Snapshots")] public ConfigSnapshots Snapshots = new ConfigSnapshots(); // BloodKraken Cloud (класс — в region "BloodKraken Cloud Telemetry" в конце файла). [JsonProperty("Telemetry")] public TelemetryConfig Telemetry = new TelemetryConfig(); } private ModuleCfg DefaultModuleCfg(string module) { switch (module) { case "Aimbot": return new ModuleCfg { Enabled = true, Weight = 1.5f, TrajectoryToleranceDeg = 3.0f, IgnoreRicochet = true, CheckedHitParts = new List { "head", "chest", "stomach" } }; case "SilentAim": return new ModuleCfg { Enabled = true, Weight = 2.0f, MaxAngleDeg = 15.0f }; case "NoRecoil": return new ModuleCfg { Enabled = false, Weight = 0.5f, PerWeapon = new Dictionary { ["rifle.ak"] = new NoRecoilWeaponCfg { NRMinShotsCountToCheck = 3, NRViolationProbability = 70.0f, WeaponMinTimeShotsInterval = 0.09375f, WeaponMaxTimeShotsInterval = 0.15625f }, ["lmg.m249"] = new NoRecoilWeaponCfg { NRMinShotsCountToCheck = 4, NRViolationProbability = 70.0f, WeaponMinTimeShotsInterval = 0.10000f, WeaponMaxTimeShotsInterval = 0.16000f } } }; case "Wallbang": return new ModuleCfg { Enabled = true, Weight = 1.5f, CheckInRock = true }; case "ESP": return new ModuleCfg { Enabled = false, Weight = 0.5f, RecordDemoOnSuspect = true }; case "NativeOther": // Bucket для неизвестных типов нативного антихака (не сваливаем их в Flyhack). return new ModuleCfg { Enabled = true, Weight = 1.0f }; default: return new ModuleCfg { Enabled = true, Weight = 1.0f }; // Flyhack/Speedhack/Spiderhack } } protected override void LoadDefaultConfig() { config = new Configuration(); foreach (var m in Modules) config.ModuleConfigs[m] = DefaultModuleCfg(m); // NativeOther — служебный bucket, в CUI-меню не показывается (нет в Modules[]). config.ModuleConfigs["NativeOther"] = DefaultModuleCfg("NativeOther"); } protected override void LoadConfig() { base.LoadConfig(); try { config = Config.ReadObject(); if (config == null) throw new Exception("null config"); if (config.ModuleConfigs == null) config.ModuleConfigs = new Dictionary(); // Достраиваем недостающие модули (миграция) foreach (var m in Modules) if (!config.ModuleConfigs.ContainsKey(m)) config.ModuleConfigs[m] = DefaultModuleCfg(m); if (!config.ModuleConfigs.ContainsKey("NativeOther")) config.ModuleConfigs["NativeOther"] = DefaultModuleCfg("NativeOther"); } catch { PrintWarning("Конфиг повреждён или пуст — создаю заново."); LoadDefaultConfig(); } SaveConfig(); } protected override void SaveConfig() => Config.WriteObject(config); #endregion #region Init/Permissions private void Init() { permission.RegisterPermission(PermAdmin, this); permission.RegisterPermission(PermExempt, this); // Гарантируем наличие всех папок структуры BloodKrakenSystem. foreach (var dir in new[] { BaseDir, DemoDir, SnapshotsDir, ImagesDir }) { try { if (!Directory.Exists(dir)) Directory.CreateDirectory(dir); } catch (Exception e) { PrintWarning($"Не удалось создать {dir}: {e.Message}"); } } LoadData(); } private void OnServerInitialized() { if (ImageLibrary != null) RegisterImages(); _movementTimer = timer.Every(0.5f, AnalyzeMovementTick); _decayTimer = timer.Every(60f, DecayScores); _retentionTimer = timer.Every(600f, EnforceRetention); _saveTimer = timer.Every(120f, SaveDataIfDirty); _discordTimer = timer.Every(1f, DrainDiscordQueue); // §4.10 — если Arkan есть и включён, потребляем его нарушения по таймеру. if (config.Integrations.UseArkan && Arkan != null) _arkanTimer = timer.Every(5f, PollArkanViolations); LogIntegrationStatus(); foreach (var p in BasePlayer.activePlayerList) { EnsureProfile(p); if (!_watch.ContainsKey(p.userID)) _watch[p.userID] = new WatchBuffer(); } // BloodKraken Cloud: односторонняя телеметрия, на игровые решения не влияет. Telemetry_Init(); } // Опциональные зависимости не ломают загрузку — просто пишем их статус в лог (§12). private void LogIntegrationStatus() { string arkan = config.Integrations.UseArkan ? (Arkan != null ? "active" : "enabled-but-missing (fallback to own modules)") : "disabled"; string adr = config.Integrations.UseADR ? (AutoDemoRecorder != null ? "active" : "enabled-but-missing (fallback to native record)") : "disabled"; string sa = config.Integrations.UseServerArmour ? (ServerArmour != null ? "active" : "enabled-but-missing (reputation prefilter off)") : "disabled"; string img = ImageLibrary != null ? "active" : "missing (UI icons off)"; string line = $"Integrations — Arkan: {arkan}; ADR: {adr}; ServerArmour: {sa}; ImageLibrary: {img}"; Puts(line); if ((config.Integrations.UseArkan && Arkan == null) || (config.Integrations.UseADR && AutoDemoRecorder == null) || (config.Integrations.UseServerArmour && ServerArmour == null)) PrintWarning("Некоторые включённые интеграции не найдены — работаю на собственных модулях (graceful fallback)."); LogToFile("BloodKrakenAntiCheat", line, this); } private void Unload() { SaveData(); foreach (var steamId in new List(_activeDemos.Keys)) StopDemo(steamId, "unload"); foreach (var p in BasePlayer.activePlayerList) CuiHelper.DestroyUi(p, UiRoot); _openMenus.Clear(); // Телеметрия: дослать обе очереди и остановить таймеры модуля до общего останова. Telemetry_Flush(); Telemetry_FlushVerdicts(); Telemetry_Shutdown(); _movementTimer?.Destroy(); _decayTimer?.Destroy(); _retentionTimer?.Destroy(); _saveTimer?.Destroy(); _discordTimer?.Destroy(); _arkanTimer?.Destroy(); } private void OnPlayerConnected(BasePlayer player) { EnsureProfile(player); if (!_watch.ContainsKey(player.userID)) _watch[player.userID] = new WatchBuffer(); // §4.11 — репутационный предфильтр на коннекте (только поднимает слежку, НЕ банит). if (config.Integrations.UseServerArmour && ServerArmour != null) RunReputationPrefilter(player); } private void OnPlayerDisconnected(BasePlayer player, string reason) { CuiHelper.DestroyUi(player, UiRoot); _openMenus.Remove(player.userID); _watch.Remove(player.userID); if (config.Demos.StopOnDisconnect && _activeDemos.ContainsKey(player.userID)) StopDemo(player.userID, "disconnect"); } // Ловит ЛЮБОЙ бан: консоль, RCON, плагины. Файлы (bans.cfg) не читаем. // Автометка «cheater» для обучения — только если игрок был у нас в поле зрения. // Вердикт НЕ влияет на игровые решения; сырой reason и ipAddress наружу НЕ уходят. private void OnUserBanned(string name, string id, string ipAddress, string reason) { try { if (config?.Telemetry?.Enabled != true) return; if (!ulong.TryParse(id, out var steamId)) return; // ВАЖНО: шлём метку ТОЛЬКО если игрок был у нас в поле зрения. // Забанен за что-то не связанное с античитом → это не наши данные. PlayerProfile profile; if (!_profiles.TryGetValue(steamId, out profile)) return; var cls = ClassifyBanReason(reason); // классификация ЛОКАЛЬНО, наружу только класс string strength = (cls == "cheat_suspected" || cls == "macro_suspected") ? "weak" : "unknown"; Telemetry_Verdict(steamId, "cheater", strength, "ban_hook", cls, profile.ModuleScores?.Keys, profile.TotalScore); } catch (Exception ex) { PrintError($"OnUserBanned failed: {ex.Message}"); } // ipAddress НЕ используем и НЕ логируем — это персональные данные } // Классификация причины бана — локально; сырой текст никуда не сохраняется и не уходит. // Простые подстроки осознанно: класс "other" в обучение не идёт, цена ошибки низкая. private static string ClassifyBanReason(string reason) { if (string.IsNullOrWhiteSpace(reason)) return "none"; string r = reason.ToLowerInvariant(); string[] cheatWords = { "cheat", "cheater", "aim", "aimbot", "esp", "hack", "hacker", "wall", "wh", "fly", "чит", "читер", "аим", "вх", "стен", "хак", "флай" }; string[] macroWords = { "macro", "script", "recoil", "spray", "макрос", "скрипт", "отдач" }; foreach (var w in cheatWords) if (r.Contains(w)) return "cheat_suspected"; foreach (var w in macroWords) if (r.Contains(w)) return "macro_suspected"; return "other"; } private bool IsAdmin(BasePlayer player) => player != null && (player.IsAdmin || permission.UserHasPermission(player.UserIDString, PermAdmin)); private void RegisterImages() { if (!Directory.Exists(ImagesDir)) return; var files = Directory.GetFiles(ImagesDir, "*.png"); if (files.Length == 0) return; var dict = new Dictionary(); foreach (var f in files) dict[Path.GetFileNameWithoutExtension(f)] = f; ImageLibrary.Call("ImportImageList", ImageListName, dict, 0UL, true); } private string GetImage(string key) { if (ImageLibrary == null) return string.Empty; return ImageLibrary.Call("GetImage", key) ?? string.Empty; } #endregion #region Data private enum WatchState { Green, Yellow, Blue, Red } // Green — единичная фиксация; Yellow — многократная (FlagCount >= YellowFlagCount); // Blue — усиленная слежка, ставится/снимается ТОЛЬКО вручную админом (WATCH); // Red — score >= BanThreshold (готов к кику/бану). private class PlayerProfile { public ulong SteamId; public string Name; public Dictionary ModuleScores = new Dictionary(); public float TotalScore; public int FlagCount; public bool ManualWatch; public WatchState State = WatchState.Green; public List History = new List(); public DateTime FirstSeen; public DateTime LastViolation; public bool Notified; } private class ViolationRecord { public string Module; public float Amount; public string Details; public Vector3 Position; public DateTime Time; } private class DemoRecordEntry { public ulong SteamId; public string Name; public string FileName; public string Reason; public DateTime StartTime; public DateTime? EndTime; public bool Active; } private class SnapshotEntry { public ulong SteamId; public string Module; public string FileName; public DateTime Time; public float Amount; } private class SnapshotAttackInfo { public string Target; public ulong TargetSteamId; public string BodyPart; public float Distance; public Vector3 ProjectileDirection; public bool WallbangSuspected; } private class SnapshotCard { public ulong SteamId; public string Name; public string Module; public float Amount; public float TotalScore; public string Status; public Vector3 Position; public Vector3 ViewAngles; public string ActiveWeapon; public List Attachments; public SnapshotAttackInfo Attack; public List NearbyPlayers; public List CombatLog; public DateTime Time; } private void LoadData() { try { _playersData = Interface.Oxide.DataFileSystem.GetFile(PlayersKey); _profiles = _playersData.ReadObject>() ?? new Dictionary(); } catch { _profiles = new Dictionary(); } try { _demosData = Interface.Oxide.DataFileSystem.GetFile(DemosKey); _demoIndex = _demosData.ReadObject>() ?? new List(); } catch { _demoIndex = new List(); } try { _snapshotsData = Interface.Oxide.DataFileSystem.GetFile(SnapshotsKey); _snapshotIndex = _snapshotsData.ReadObject>() ?? new List(); } catch { _snapshotIndex = new List(); } } private void SaveData() { _playersData?.WriteObject(_profiles); _demosData?.WriteObject(_demoIndex); _snapshotsData?.WriteObject(_snapshotIndex); _dataDirty = false; } private void SaveDataIfDirty() { if (_dataDirty) SaveData(); } private void MarkDirty() => _dataDirty = true; private PlayerProfile EnsureProfile(BasePlayer player) { PlayerProfile profile; if (!_profiles.TryGetValue(player.userID, out profile)) { profile = new PlayerProfile { SteamId = player.userID, Name = player.displayName, FirstSeen = DateTime.UtcNow, State = WatchState.Green }; _profiles[player.userID] = profile; MarkDirty(); } else { profile.Name = player.displayName; } return profile; } private WatchState ComputeStatus(PlayerProfile profile) { if (profile.ManualWatch) return WatchState.Blue; if (profile.TotalScore >= config.General.BanThreshold) return WatchState.Red; if (profile.FlagCount >= config.General.YellowFlagCount) return WatchState.Yellow; return WatchState.Green; } #endregion #region Detection Hooks private bool IsExemptOrAdmin(BasePlayer player) { if (player == null) return true; if (player.IsAdmin) return true; return permission.UserHasPermission(player.UserIDString, PermExempt); } private bool IsModuleEnabled(string module) { ModuleCfg mc; return config.ModuleConfigs.TryGetValue(module, out mc) && mc.Enabled; } private static string ResolveNativeModule(string typeName) { string t = typeName.ToLowerInvariant(); if (t.Contains("fly")) return "Flyhack"; if (t.Contains("speed") || t.Contains("teleport")) return "Speedhack"; if (t.Contains("spider") || t.Contains("climb")) return "Spiderhack"; if (t.Contains("noclip")) return "Flyhack"; return "NativeOther"; // неизвестные нативные типы — в отдельный bucket, не искажаем статистику Flyhack } // 4.1 — нативные anti-hack события Rust. Самый дешёвый и надёжный сигнал. // Ничего не возвращаем (void) — дефолтное поведение Rust не переопределяется. private void OnPlayerViolation(BasePlayer player, AntiHackType type, float amount) { if (player == null || amount <= 0f) return; if (IsExemptOrAdmin(player)) return; string module = ResolveNativeModule(type.ToString()); AddViolation(player, module, amount, $"native:{type} amount={amount:0.00}"); } // В горячем хуке только дешёвая буферизация — тяжёлый анализ идёт батчем по таймеру. private void OnPlayerTick(BasePlayer player) { if (player == null) return; WatchBuffer wb; if (!_watch.TryGetValue(player.userID, out wb)) { wb = new WatchBuffer(); _watch[player.userID] = wb; } wb.PendingPosition = player.transform.position; wb.PendingTime = UnityEngine.Time.realtimeSinceStartup; wb.HasPending = true; } private void AnalyzeMovementTick() { foreach (var player in BasePlayer.activePlayerList) { if (player == null || !player.IsConnected) continue; if (IsExemptOrAdmin(player)) continue; WatchBuffer wb; if (!_watch.TryGetValue(player.userID, out wb)) continue; if (!wb.HasPending) { AnalyzeEsp(player, wb); continue; } float dt = wb.PendingTime - wb.LastPosTime; if (wb.LastPosTime > 0f && dt > 0.01f) { Vector3 delta = wb.PendingPosition - wb.LastPosition; float horizontalDist = new Vector2(delta.x, delta.z).magnitude; float vertSpeed = delta.y / dt; float horizSpeed = horizontalDist / dt; // Транспорт/лошади/зиплайны/лифты дают parent или mounted — не считаем. bool mounted = player.isMounted || player.GetMounted() != null || player.GetParentEntity() != null; if (!mounted) { bool grounded = HasSupportBelow(player); AnalyzeSpeedhack(player, horizSpeed); AnalyzeFlyhack(player, vertSpeed, grounded); AnalyzeSpiderhack(player, delta, horizontalDist, grounded); } } wb.LastPosition = wb.PendingPosition; wb.LastPosTime = wb.PendingTime; wb.HasPending = false; AnalyzeEsp(player, wb); } } private bool HasSupportBelow(BasePlayer player) { RaycastHit hit; return Physics.Raycast(player.transform.position + Vector3.up * 0.1f, Vector3.down, out hit, 2.5f, Rust.Layers.Solid); } // 4.3 — Speedhack: горизонтальная скорость против физического максимума. private void AnalyzeSpeedhack(BasePlayer player, float horizSpeed) { if (!IsModuleEnabled("Speedhack")) return; if (horizSpeed > MaxNormalSpeed * 1.4f) // запас против лагов/телепорт-плагинов { float over = horizSpeed - MaxNormalSpeed; AddViolation(player, "Speedhack", over, $"horizSpeed={horizSpeed:0.00}m/s"); } } // 4.2 — Flyhack. Деревья/камни/руда не имеют серверного коллайдера — raycast их // просто не найдёт как опору, отдельная обработка не нужна. private void AnalyzeFlyhack(BasePlayer player, float vertSpeed, bool grounded) { if (!IsModuleEnabled("Flyhack")) return; if (grounded) return; if (vertSpeed > MaxNormalVerticalSpeed) { AddViolation(player, "Flyhack", vertSpeed - MaxNormalVerticalSpeed, $"vertSpeed={vertSpeed:0.00}m/s ungrounded"); } } // 4.4 — Spiderhack: движение вверх по невозможному углу поверхности. private void AnalyzeSpiderhack(BasePlayer player, Vector3 delta, float horizontalDist, bool grounded) { if (!IsModuleEnabled("Spiderhack")) return; if (horizontalDist < 0.05f || grounded) return; float angle = Mathf.Atan2(delta.y, horizontalDist) * Mathf.Rad2Deg; if (angle > 80f && delta.y > 0.3f) { AddViolation(player, "Spiderhack", angle - 80f, $"climbAngle={angle:0.0}deg"); } } // 4.9 — ESP-эвристика (по умолчанию ВЫКЛ, low-confidence): устойчивое сопровождение // взглядом другого игрока сквозь препятствие. Прямой серверной детекции ESP нет. private void AnalyzeEsp(BasePlayer player, WatchBuffer wb) { if (!IsModuleEnabled("ESP")) return; // Дросселирование (P2.4): O(n²)-обход гоняем только по подозреваемым (статус >= Yellow) // и не чаще раза в 2 секунды на игрока. PlayerProfile espProfile; if (!_profiles.TryGetValue(player.userID, out espProfile) || espProfile.State < WatchState.Yellow) return; float espNow = UnityEngine.Time.realtimeSinceStartup; if (espNow < wb.NextEspCheck) return; wb.NextEspCheck = espNow + 2f; Vector3 eyePos = player.eyes.position; Vector3 fwd = player.eyes.HeadForward(); foreach (var other in BasePlayer.activePlayerList) { if (other == null || other == player || !other.IsConnected) continue; Vector3 toOther = other.eyes.position - eyePos; float dist = toOther.magnitude; if (dist > 60f || dist < 3f) continue; Vector3 dirToOther = toOther.normalized; if (Vector3.Angle(fwd, dirToOther) > 4f) continue; RaycastHit hit; bool blocked = Physics.Raycast(eyePos, dirToOther, out hit, dist - 0.3f, Rust.Layers.Solid); if (!blocked) continue; wb.EspTrackFrames++; if (wb.EspTrackFrames >= 6) // ~3 сек устойчивого сопровождения при тике 0.5с { AddViolation(player, "ESP", 1f, $"low-confidence: sustained lock through obstruction on {other.displayName}"); // §4.9 — для ESP реальнее не «детект», а запись демки подозреваемого для ручного ревью. ModuleCfg espCfg; if (config.ModuleConfigs.TryGetValue("ESP", out espCfg) && (espCfg.RecordDemoOnSuspect ?? true)) EnsureDemoRecording(player, "esp-suspect"); wb.EspTrackFrames = 0; } return; } wb.EspTrackFrames = 0; } // 4.5/4.6/4.7 — Aimbot / Silent aim / No-recoil: сэмплируются с живых выстрелов. private void OnPlayerAttack(BasePlayer attacker, HitInfo info) { try { if (attacker == null || info == null) return; if (IsExemptOrAdmin(attacker)) return; WatchBuffer wb; if (!_watch.TryGetValue(attacker.userID, out wb)) { wb = new WatchBuffer(); _watch[attacker.userID] = wb; } AppendCombatLog(wb, attacker, info); var weapon = attacker.GetHeldEntity() as BaseProjectile; if (weapon == null) return; // только пули — ближний бой/метательное не учитываем AnalyzeAimbot(attacker, wb, info, weapon); AnalyzeSilentAim(attacker, wb, info); AnalyzeNoRecoil(attacker, wb, weapon); } catch (Exception ex) { PrintError($"OnPlayerAttack failed: {ex.Message}"); } } private void AppendCombatLog(WatchBuffer wb, BasePlayer attacker, HitInfo info) { string weaponName = (attacker.GetHeldEntity() as BaseProjectile)?.GetItem()?.info.shortname ?? attacker.GetHeldEntity()?.ShortPrefabName ?? "unarmed"; string entry = $"{DateTime.UtcNow:HH:mm:ss} hit={info.HitEntity?.ToString() ?? "none"} bone={(info.isHeadshot ? "head" : "body")} " + $"dmg={info.damageTypes.Total():0.0} weapon={weaponName}"; wb.CombatLog.Add(entry); int maxEntries = Math.Max(1, config.Snapshots.CombatLogEntries); while (wb.CombatLog.Count > maxEntries) wb.CombatLog.RemoveAt(0); } // 4.5 — Aimbot: трассологическая модель (подход как в открытом Arkan, GPL — код НЕ // копируется, только идея). Сверяем прямую (взгляд→снаряд) и обратную (попадание→старт) // траектории; расхождение сверх допуска при попадании в тело = вмешательство в траекторию. // Результат — «вероятность» (масштабируем по величине расхождения), не бинарь. // Если доступен Arkan — предпочитаем его нарушения (§4.10), этот модуль работает как fallback. private void AnalyzeAimbot(BasePlayer attacker, WatchBuffer wb, HitInfo info, BaseProjectile weapon) { if (!IsModuleEnabled("Aimbot")) return; // Если Arkan активен и отдаёт нарушения — не дублируем собственным расчётом. if (config.Integrations.UseArkan && Arkan != null) return; var mc = config.ModuleConfigs["Aimbot"]; // Только попадания в тело игрока; промахи и попадания в постройки игнорируем. if (!(info.HitEntity is BasePlayer)) return; string part = HitPartName(info); var parts = mc.CheckedHitParts ?? new List { "head", "chest", "stomach" }; if (!parts.Contains(part)) return; string shortname = weapon.GetItem()?.info.shortname ?? string.Empty; if (ShotgunNames.Contains(shortname) || BowNames.Contains(shortname)) return; // только пули Vector3 start = info.PointStart; Vector3 end = info.PointEnd; Vector3 hit = info.HitPositionWorld; Vector3 shotDir = end - start; if (shotDir.sqrMagnitude < 0.0001f) return; shotDir.Normalize(); float dist = Vector3.Distance(start, hit); if (dist < 1f) return; // (1) isHitPointOnProjectileTrajectory-аналог: отклонение точки попадания от луча выстрела. Vector3 toHit = hit - start; float along = Vector3.Dot(toHit, shotDir); Vector3 closestOnRay = start + shotDir * Mathf.Clamp(along, 0f, dist); float offRayDist = Vector3.Distance(hit, closestOnRay); // Рикошет: точка попадания далеко от прямого луча — на скалах/вышке частый фолс. bool ricochetLike = offRayDist > 1.5f; if ((mc.IgnoreRicochet ?? true) && ricochetLike) return; // (2) Обратная траектория: направление (hit→start) должно совпасть с -shotDir. // Скоринг — ТОЛЬКО по пересчёту траектории (P2.1): сверка со взглядом из Aimbot // убрана, т.к. данные взгляда приходят с задержкой и фолсят; она осталась в SilentAim. Vector3 reverseDir = (start - hit).normalized; float reverseMismatch = Vector3.Angle(reverseDir, -shotDir); float tol = mc.TrajectoryToleranceDeg ?? 3.0f; // Запас к допуску на случайный плавающий разброс траектории Rust. float effectiveTol = tol + 2.0f; if (reverseMismatch <= effectiveTol) return; // «Вероятность»: чем больше расхождение сверх допуска, тем выше очки. float amount = Mathf.Clamp((reverseMismatch - effectiveTol) * 2.5f, 1f, 40f); AddViolation(attacker, "Aimbot", amount, $"aimbot: reverse traj mismatch {reverseMismatch:0.0}deg, offRay {offRayDist:0.00}m, dist {dist:0.0}m, {part}", info); } // Приблизительная классификация части тела по данным попадания (серверно доступное). private string HitPartName(HitInfo info) { if (info.isHeadshot) return "head"; // Без надёжного имени кости на всех версиях Oxide трактуем не-хедшот как корпус. return "chest"; } // 4.6 — Silent aim: расхождение взгляда и направления снаряда при попадании в игрока. private void AnalyzeSilentAim(BasePlayer attacker, WatchBuffer wb, HitInfo info) { if (!IsModuleEnabled("SilentAim")) return; if (!(info.HitEntity is BasePlayer)) return; var mc = config.ModuleConfigs["SilentAim"]; float maxAngle = mc.MaxAngleDeg ?? 15.0f; Vector3 eyeDir = attacker.eyes.HeadForward(); Vector3 shotDir = (info.PointEnd - info.PointStart); if (shotDir.sqrMagnitude < 0.0001f) return; shotDir.Normalize(); float angle = Vector3.Angle(eyeDir, shotDir); if (angle > maxAngle) AddViolation(attacker, "SilentAim", angle - maxAngle, $"eye/shot divergence {angle:0.0}deg", info); } // 4.7 — No-recoil (BETA, low-confidence). По-оружейные пороги (§11): интервал непрерывной // автоочереди [WeaponMin..MaxTimeShotsInterval], мин. число выстрелов NRMinShotsCountToCheck, // порог NRViolationProbability (%). Результат — «вероятность», НЕ бинарь и НЕ автобан // (см. LowConfidenceModules). Ограничение: после апрельского апдейта разброс имеет // случайную клиентскую составляющую, точно не пересчитывается сервером, взгляд приходит // с задержкой → высокий риск фолсов. Одиночные нарушения за сотни патронов — норма (decay). // Если доступен Arkan — предпочитаем его нарушения (§4.10), этот модуль работает как fallback. private void AnalyzeNoRecoil(BasePlayer attacker, WatchBuffer wb, BaseProjectile weapon) { if (!IsModuleEnabled("NoRecoil")) return; if (config.Integrations.UseArkan && Arkan != null) return; var mc = config.ModuleConfigs["NoRecoil"]; string shortname = weapon.GetItem()?.info.shortname ?? string.Empty; if (!NoRecoilEligibleWeapons.Contains(shortname)) return; // только AK/M249 if (HasAnyAttachment(weapon)) return; // только без обвесов NoRecoilWeaponCfg wc = null; if (mc.PerWeapon == null || !mc.PerWeapon.TryGetValue(shortname, out wc) || wc == null) return; float now = UnityEngine.Time.realtimeSinceStartup; if (wb.LastWeaponShortname != shortname) { wb.RecoilShots.Clear(); wb.LastWeaponShortname = shortname; } // Разрыв очереди больше макс. интервала — начинаем новую серию. if (wb.RecoilShots.Count > 0) { float sinceLast = now - LastRecoilTime(wb); if (sinceLast > wc.WeaponMaxTimeShotsInterval * 2.0f) wb.RecoilShots.Clear(); } wb.RecoilShots.Enqueue(new RecoilShot { Time = now, Aim = attacker.eyes.HeadForward() }); while (wb.RecoilShots.Count > 20) wb.RecoilShots.Dequeue(); if (wb.RecoilShots.Count < wc.NRMinShotsCountToCheck) return; // Проверяем, что серия — непрерывная автоочередь (интервалы в окне оружия), // и считаем, насколько «неестественно» стабилен взгляд между выстрелами. var shots = wb.RecoilShots.ToArray(); int continuous = 0; float totalPitchDelta = 0f; int compared = 0; for (int i = 1; i < shots.Length; i++) { float dt = shots[i].Time - shots[i - 1].Time; if (dt >= wc.WeaponMinTimeShotsInterval && dt <= wc.WeaponMaxTimeShotsInterval) continuous++; float angleDelta = Vector3.Angle(shots[i - 1].Aim, shots[i].Aim); totalPitchDelta += angleDelta; compared++; } if (continuous < wc.NRMinShotsCountToCheck - 1) return; // не непрерывная очередь float avgDelta = compared > 0 ? totalPitchDelta / compared : 0f; // Отдача должна двигать взгляд между выстрелами; аномально малое движение → выше вероятность. // Грубая эвристика: 0deg среднего движения ≈ 100%, >=1.5deg ≈ 0%. float probability = Mathf.Clamp01((1.5f - avgDelta) / 1.5f) * 100f; if (probability < wc.NRViolationProbability) return; float amount = (probability - wc.NRViolationProbability) / 100f + 0.5f; AddViolation(attacker, "NoRecoil", amount, $"low-confidence BETA: {shots.Length} rounds {shortname}, avgAimDelta {avgDelta:0.00}deg, prob {probability:0}% (manual review only, no autoban)"); } private float LastRecoilTime(WatchBuffer wb) { float t = 0f; foreach (var s in wb.RecoilShots) t = s.Time; return t; } private bool HasAnyAttachment(BaseProjectile weapon) { var item = weapon.GetItem(); return item?.contents != null && item.contents.itemList.Count > 0; } // 4.8 — Wallbang: урон сквозь твёрдое препятствие + под-кейс In Rock (стрельба из скалы). // Урон НЕ блокируется — только лог/очки. Если доступен Arkan (даёт In Rock/траекторию) — // не дублируем собственным расчётом. private void OnEntityTakeDamage(BaseCombatEntity entity, HitInfo info) { try { if (entity == null || info == null) return; var victim = entity as BasePlayer; if (victim == null) return; var attacker = info.InitiatorPlayer; if (attacker == null || attacker == victim) return; if (IsExemptOrAdmin(attacker)) return; if (!IsModuleEnabled("Wallbang")) return; if (config.Integrations.UseArkan && Arkan != null) return; var weapon = attacker.GetHeldEntity() as BaseProjectile; if (weapon == null) return; // только пули var mc = config.ModuleConfigs["Wallbang"]; Vector3 origin = attacker.eyes.position; Vector3 hitPoint = info.HitPositionWorld != Vector3.zero ? info.HitPositionWorld : victim.eyes.position; Vector3 dir = hitPoint - origin; float dist = dir.magnitude; if (dist < 1f) return; // In Rock: стрелок находится внутри world-коллайдера (скала) или под террейном. if ((mc.CheckInRock ?? true) && IsInRock(origin)) { AddViolation(attacker, "Wallbang", 8f, $"in-rock damage from inside world collider at {dist:0.0}m", info); return; } // Wallbang: линия глаз→точка попадания пересекает твёрдое препятствие. RaycastHit hit; Vector3 lineEnd = hitPoint - dir.normalized * 0.3f; // не упираемся в саму жертву if (Physics.Linecast(origin, lineEnd, out hit, Rust.Layers.Solid)) { if (IsWallbangWhitelisted(hit.collider)) return; // окна/бойницы/решётки AddViolation(attacker, "Wallbang", 5f, $"damage through {hit.collider.name} at {dist:0.0}m", info); } } catch (Exception ex) { PrintError($"OnEntityTakeDamage wallbang check failed: {ex.Message}"); } } private bool IsWallbangWhitelisted(Collider collider) { if (collider == null) return true; string name = collider.name.ToLowerInvariant(); foreach (var s in WallbangWhitelistSubstrings) if (name.Contains(s)) return true; return false; } // In Rock: точка глаз стрелка внутри мировой геометрии/скалы или ниже террейна. // Whitelist монументов/пещер — источник фолсов у входов в пещеры/на нефтевышке. private bool IsInRock(Vector3 origin) { try { var cols = Physics.OverlapSphere(origin, 0.1f, Rust.Layers.Solid); foreach (var c in cols) { if (c == null) continue; string n = c.name.ToLowerInvariant(); if (IsInRockWhitelisted(n)) continue; // монументы/пещеры/постройки // Считаем «в скале» только для природной мировой геометрии. if (n.Contains("rock") || n.Contains("cliff") || n.Contains("formation") || n.Contains("terrain")) return true; } if (TerrainMeta.HeightMap != null) { float ground = TerrainMeta.HeightMap.GetHeight(origin); if (origin.y < ground - 0.5f) return true; // взгляд ниже уровня террейна } } catch { } return false; } private bool IsInRockWhitelisted(string colliderNameLower) { if (string.IsNullOrEmpty(colliderNameLower)) return true; foreach (var s in InRockWhitelistSubstrings) if (colliderNameLower.Contains(s)) return true; // Постройки/префабы не считаем скалой. if (colliderNameLower.Contains("wall") || colliderNameLower.Contains("foundation") || colliderNameLower.Contains("floor") || colliderNameLower.Contains("assets/")) return true; return false; } // §4.10 — потребляем нарушения Arkan (No Recoil / Aimbot / In Rock) через его API и // заводим их в наш движок с весами ArkanWeight*. Код Arkan НЕ копируется — только вызов API. // Формат ответа может отличаться по версиям Arkan; парсим устойчиво и при ошибке молча // деградируем на собственные модули (§12). private void PollArkanViolations() { if (!config.Integrations.UseArkan || Arkan == null) return; try { var raw = Arkan.Call("API_ArkanGetPlayersViolationsData"); if (raw == null) return; // Ожидаемый ориентир: словарь SteamID -> [noRecoil, aimbot, inRock] (кол-во нарушений). var json = JsonConvert.SerializeObject(raw); var data = JsonConvert.DeserializeObject>(json); if (data == null) return; foreach (var kv in data) { ulong steamId = kv.Key; int[] counts = kv.Value; if (counts == null || counts.Length < 3) continue; var player = BasePlayer.FindByID(steamId); if (player == null) continue; if (IsExemptOrAdmin(player)) continue; // exempt не скорим и не телеметрим int[] last; if (!_arkanLastCounts.TryGetValue(steamId, out last) || last == null || last.Length < 3) last = new int[3]; int dNoRecoil = Math.Max(0, counts[0] - last[0]); int dAimbot = Math.Max(0, counts[1] - last[1]); int dInRock = Math.Max(0, counts[2] - last[2]); // source="arkan": в AddViolation по нему не стартует своя демка и не шлётся // телеметрия; в облако события уходят отсюда с точным именем модуля Arkan. if (dNoRecoil > 0) { float amt = dNoRecoil * config.Integrations.ArkanWeightNoRecoil; AddViolation(player, "NoRecoil", amt, $"Arkan NoRecoil x{dNoRecoil}", null, true, "arkan"); ReportArkanTelemetry(player, "NoRecoil", amt); } if (dAimbot > 0) { float amt = dAimbot * config.Integrations.ArkanWeightAimbot; AddViolation(player, "Aimbot", amt, $"Arkan Aimbot x{dAimbot}", null, true, "arkan"); ReportArkanTelemetry(player, "Aimbot", amt); } if (dInRock > 0) { float amt = dInRock * config.Integrations.ArkanWeightInRock; AddViolation(player, "Wallbang", amt, $"Arkan InRock x{dInRock}", null, true, "arkan"); ReportArkanTelemetry(player, "InRock", amt); // исходное имя модуля Arkan } _arkanLastCounts[steamId] = new[] { counts[0], counts[1], counts[2] }; } } catch (Exception ex) { if (!_arkanWarned) { _arkanWarned = true; PrintWarning($"Не удалось разобрать данные Arkan (формат API мог измениться): {ex.Message}. Работаю на собственных модулях."); } } } // Телеметрия для событий Arkan — единственный путь их отправки в облако: точное имя модуля // Arkan (NoRecoil/Aimbot/InRock) + source="arkan" (модуль сам смаппит в ArkanNoRecoil/ // ArkanAimbot/ArkanInRock). Вызывается ПОСЛЕ AddViolation, чтобы score в событии был свежим. private void ReportArkanTelemetry(BasePlayer player, string arkanModule, float amount) { var profile = EnsureProfile(player); Telemetry_Report(player.userID, arkanModule, amount, profile.TotalScore, profile.State.ToString(), source: "arkan"); } // §4.11 — репутационный предфильтр (Server Armour). Только поднимает приоритет слежки, // НЕ банит. «Железо» игрока напрямую не опрашивается — только репутационная БД. private void RunReputationPrefilter(BasePlayer player) { try { var rep = config.Integrations.Reputation; bool dirty = false; var reasons = new List(); bool isDirty = SafeApiBool(ServerArmour, "API_GetIsPlayerDirty", player.UserIDString); if (isDirty) { dirty = true; reasons.Add("SA:dirty"); } int vac = SafeApiInt(ServerArmour, "API_GetVacBanCount", player.UserIDString); int daysSinceVac = SafeApiInt(ServerArmour, "API_GetDaysSinceLastVacBan", player.UserIDString, 99999); if (vac > 0 && daysSinceVac <= rep.RaiseOnRecentVacDays) { dirty = true; reasons.Add($"recentVAC({daysSinceVac}d)"); } int gameBans = SafeApiInt(ServerArmour, "API_GetGameBanCount", player.UserIDString); if (gameBans > rep.MaxAllowedGameBans) { dirty = true; reasons.Add($"gameBans({gameBans})"); } if (rep.FlagFamilyShareEvasion && SafeApiBool(ServerArmour, "API_GetIsFamilyShareLenderDirty", player.UserIDString)) { dirty = true; reasons.Add("familyShare"); } // Проверка VPN удалена (P2.3): в публичном API Server Armour нет такого геттера; // конфиг-флаг FlagVpnProxy оставлен как reserved для совместимости JSON. if (!dirty) return; string action = (rep.ActionOnDirty ?? "watch").ToLowerInvariant(); string detail = $"reputation prefilter: {string.Join(",", reasons)}"; LogToFile("BloodKrakenAntiCheat", $"[REPUTATION] {player.displayName} ({player.UserIDString}) action={action} {detail}", this); if (action == "none") return; if (action == "notify") { var profileN = EnsureProfile(player); NotifyAdmins(player, profileN); return; } // "watch": поднимаем до Yellow (усиленный сэмплинг) без бана. var profile = EnsureProfile(player); if (profile.FlagCount < config.General.YellowFlagCount) profile.FlagCount = config.General.YellowFlagCount; profile.State = ComputeStatus(profile); _reputationFlagged.Add(player.userID); MarkDirty(); RefreshAllMenus(); } catch (Exception ex) { PrintError($"RunReputationPrefilter failed: {ex.Message}"); } } private bool SafeApiBool(Plugin plugin, string method, string arg) { try { return Convert.ToBoolean(plugin?.Call(method, arg) ?? false); } catch { return false; } } private int SafeApiInt(Plugin plugin, string method, string arg, int fallback = 0) { try { var r = plugin?.Call(method, arg); return r == null ? fallback : Convert.ToInt32(r); } catch { return fallback; } } #endregion #region Violation Engine // preWeighted=true — amount уже является итоговым числом очков (используется для нарушений // Arkan с их собственными весами ArkanWeight*), без повторного умножения на Weight[module]. // source: "own" — собственные модули, "arkan" — нарушения, взятые из Arkan (§4.10). private void AddViolation(BasePlayer player, string module, float amount, string details, HitInfo info = null, bool preWeighted = false, string source = "own") { if (player == null || amount <= 0f) return; if (IsExemptOrAdmin(player)) return; ModuleCfg mc; if (!config.ModuleConfigs.TryGetValue(module, out mc) || !mc.Enabled) return; var profile = EnsureProfile(player); float weighted = preWeighted ? amount : amount * mc.Weight; // Статус ДО этого нарушения — для телеметрии (features.prev_status); // снимается до пересчёта ComputeStatus ниже, иначе всегда был бы текущий. string prevStatus = profile.State.ToString(); float cur; profile.ModuleScores.TryGetValue(module, out cur); profile.ModuleScores[module] = cur + weighted; profile.TotalScore += weighted; profile.FlagCount++; profile.LastViolation = DateTime.UtcNow; profile.History.Add(new ViolationRecord { Module = module, Amount = weighted, Details = details, Position = player.transform.position, Time = DateTime.UtcNow }); if (profile.History.Count > MaxHistory) profile.History.RemoveAt(0); profile.State = ComputeStatus(profile); // Анти-дубли демок: по событиям source=="arkan" собственную демо-запись не стартуем — // связка Arkan + ADR Arkan пишет свою демку сама (§4.10). Снапшоты работают как обычно. CaptureEvidence(player, profile, module, weighted, info, allowDemo: source != "arkan"); LogViolation(player, module, weighted, details, profile); QueueDiscord(player, module, weighted, details, profile); ApplyThresholdActions(player, profile, module); // Телеметрия в облако (не влияет на игровые решения; при выключенной Telemetry — no-op). // События source=="arkan" отправляются напрямую из PollArkanViolations с точным именем // модуля Arkan (InRock и т.п.) — здесь их не дублируем, путь отправки ровно один. if (source != "arkan") Telemetry_Report( player.userID, module, weighted, profile.TotalScore, profile.State.ToString(), weapon: info?.Weapon?.GetItem()?.info?.shortname ?? "", headshot: info != null && info.isHeadshot, distance: info != null ? Vector3.Distance(info.PointStart, info.HitPositionWorld) : 0f, wallbang: module == "Wallbang", nearbyCount: 0, source: source, prevStatus: prevStatus); MarkDirty(); RefreshAllMenus(); } private void ApplyThresholdActions(BasePlayer player, PlayerProfile profile, string triggeringModule) { var g = config.General; if (profile.TotalScore >= g.NotifyThreshold) { if (!profile.Notified) { NotifyAdmins(player, profile); profile.Notified = true; } } else { profile.Notified = false; } // Low-confidence модули (NoRecoil/ESP) НИКОГДА не приводят к авто-действию сами по себе // (§4.7). Кик/бан возможны только когда порог перешагнуло достоверное нарушение. if (LowConfidenceModules.Contains(triggeringModule)) return; if (profile.TotalScore >= g.KickThreshold && g.AutoKick) { player.Kick("BloodKrakenAntiCheat: подозрительная активность"); LogToFile("BloodKrakenAntiCheat", $"[KICK] {player.displayName} ({player.UserIDString}) score={profile.TotalScore:0.0}", this); } if (profile.TotalScore >= g.BanThreshold && g.AutoBan) { ServerUsers.Set(player.userID, ServerUsers.UserGroup.Banned, player.displayName, "BloodKrakenAntiCheat auto-ban"); ServerUsers.Save(); player.Kick("Banned: BloodKrakenAntiCheat auto-ban"); LogToFile("BloodKrakenAntiCheat", $"[BAN] {player.displayName} ({player.UserIDString}) score={profile.TotalScore:0.0}", this); } } private void NotifyAdmins(BasePlayer subject, PlayerProfile profile) { string msg = $"[BloodKrakenAntiCheat] {subject.displayName} score={profile.TotalScore:0.0} status={profile.State}"; foreach (var admin in BasePlayer.activePlayerList) { if (admin == null) continue; if (!IsAdmin(admin)) continue; SendReply(admin, msg); } } // Затухание score, чтобы одиночные фолсы не копились вечно. private void DecayScores() { float decay = config.General.ScoreDecayPerMinute; if (decay <= 0f) return; foreach (var profile in _profiles.Values) { if (profile.TotalScore <= 0f) continue; profile.TotalScore = Mathf.Max(0f, profile.TotalScore - decay); if (profile.ModuleScores.Count > 0) { float perModule = decay / profile.ModuleScores.Count; var keys = new List(profile.ModuleScores.Keys); foreach (var k in keys) profile.ModuleScores[k] = Mathf.Max(0f, profile.ModuleScores[k] - perModule); } profile.State = ComputeStatus(profile); MarkDirty(); } } #endregion #region Evidence // Выбор тира улики: серьёзное (Red/Blue/score>=DemoMinScore) → демка, // лёгкое (score<=SnapshotMaxScore) → JSON-снапшот. // allowDemo=false — демо-запись не стартуем (события source=="arkan": их демку пишет ADR Arkan). private void CaptureEvidence(BasePlayer player, PlayerProfile profile, string module, float amount, HitInfo info, bool allowDemo = true) { var d = config.Demos; var s = config.Snapshots; if (allowDemo) { if (profile.State == WatchState.Red && d.Enabled && d.RecordOnRed) { EnsureDemoRecording(player, "red-threshold"); return; } if (profile.ManualWatch && d.Enabled && d.RecordOnBlueWatch) { EnsureDemoRecording(player, "manual-watch"); return; } if (profile.State == WatchState.Yellow && d.Enabled && d.RecordOnYellow) { EnsureDemoRecording(player, "yellow-threshold"); return; } if (d.Enabled && profile.TotalScore >= d.DemoMinScore) { EnsureDemoRecording(player, "score-threshold"); return; } } if (s.Enabled && profile.TotalScore <= s.SnapshotMaxScore) { WriteSnapshot(player, profile, module, amount, info); // Эскалация: если сумма amount по снапшотам достигла порога — следующее пишем демкой. float snapshotTotal = 0f; foreach (var e in _snapshotIndex) if (e.SteamId == player.userID) snapshotTotal += e.Amount; if (allowDemo && snapshotTotal >= s.EscalateToDemoAtScore) EnsureDemoRecording(player, "snapshot-escalation"); } } // 7.1 — Снапшот-улика (лёгкий тир), вместо «скриншота»: только серверно доступные данные. private void WriteSnapshot(BasePlayer player, PlayerProfile profile, string module, float amount, HitInfo info) { try { WatchBuffer wb; _watch.TryGetValue(player.userID, out wb); SnapshotAttackInfo attack = null; if (info != null) { var victim = info.HitEntity as BasePlayer; attack = new SnapshotAttackInfo { Target = victim?.displayName, TargetSteamId = victim?.userID ?? 0, BodyPart = info.isHeadshot ? "head" : "body", Distance = Vector3.Distance(player.transform.position, info.HitPositionWorld), ProjectileDirection = (info.PointEnd - info.PointStart).normalized, WallbangSuspected = module == "Wallbang" }; } var card = new SnapshotCard { SteamId = player.userID, Name = player.displayName, Module = module, Amount = amount, TotalScore = profile.TotalScore, Status = profile.State.ToString(), Position = player.transform.position, ViewAngles = player.eyes.rotation.eulerAngles, ActiveWeapon = player.GetActiveItem()?.info.shortname ?? "unarmed", Attachments = GetAttachmentNames(player), Attack = attack, NearbyPlayers = GetNearbyPlayers(player), CombatLog = wb != null ? new List(wb.CombatLog) : new List(), Time = DateTime.UtcNow }; string fileName = $"{player.userID}_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{module}.json"; string path = Path.Combine(SnapshotsDir, fileName); File.WriteAllText(path, JsonConvert.SerializeObject(card, Formatting.Indented)); _snapshotIndex.Add(new SnapshotEntry { SteamId = player.userID, Module = module, FileName = fileName, Time = DateTime.UtcNow, Amount = amount }); MarkDirty(); } catch (Exception ex) { PrintError($"WriteSnapshot failed: {ex.Message}"); } } private List GetAttachmentNames(BasePlayer player) { var list = new List(); var item = player.GetActiveItem(); if (item?.contents?.itemList != null) foreach (var mod in item.contents.itemList) list.Add(mod.info.shortname); return list; } private List GetNearbyPlayers(BasePlayer player) { var list = new List(); float radius = config.Snapshots.NearbyRadius; foreach (var other in BasePlayer.activePlayerList) { if (other == null || other == player) continue; float d = Vector3.Distance(player.transform.position, other.transform.position); if (d <= radius) list.Add($"{other.userID}:{d:0.0}m"); } return list; } // 7.2 — Демо-запись (серьёзный тир). Предпочитаем API Auto Demo Recorder // (API_StartRecording/API_StopRecording) через [PluginReference]; иначе оборачиваем // нативные серверные demo-команды. Право demo.record обычным игрокам НЕ выдаётся. private void EnsureDemoRecording(BasePlayer player, string reason) { if (!config.Demos.Enabled) return; if (_activeDemos.ContainsKey(player.userID)) return; // одна активная запись на игрока // §4.10/§7.2 — если ADR включён и присутствует, ведём демо через него; иначе нативная запись. bool usingExternal = config.Integrations.UseADR && AutoDemoRecorder != null; if (usingExternal) AutoDemoRecorder.Call("API_StartRecording", player, reason, config.Demos.MaxLengthMinutes); else ConsoleSystem.Run(ConsoleSystem.Option.Server, "demo.record", player.UserIDString); var active = new ActiveDemo { SteamId = player.userID, Reason = reason, StartTime = DateTime.UtcNow, UsingExternalPlugin = usingExternal }; active.StopTimer = timer.Once(config.Demos.MaxLengthMinutes * 60f, () => StopDemo(player.userID, "max-length")); _activeDemos[player.userID] = active; _demoIndex.Add(new DemoRecordEntry { SteamId = player.userID, Name = player.displayName, Reason = reason, StartTime = active.StartTime, Active = true }); MarkDirty(); LogToFile("BloodKrakenAntiCheat", $"[DEMO-START] {player.displayName} ({player.userID}) reason={reason}", this); } private void StopDemo(ulong steamId, string reason) { ActiveDemo active; if (!_activeDemos.TryGetValue(steamId, out active)) return; active.StopTimer?.Destroy(); _activeDemos.Remove(steamId); var player = BasePlayer.FindByID(steamId); if (active.UsingExternalPlugin && AutoDemoRecorder != null) AutoDemoRecorder.Call("API_StopRecording", player); else ConsoleSystem.Run(ConsoleSystem.Option.Server, "demo.stop", steamId.ToString()); // Нативная запись пишет .dem в корневую demos/ — после стопа перекладываем к себе. timer.Once(1.5f, () => RelocateDemoFile(steamId, reason)); } private void RelocateDemoFile(ulong steamId, string stopReason) { try { string rootDemos = Path.Combine(Interface.Oxide.RootDirectory, "demos"); if (!Directory.Exists(rootDemos)) return; string newest = null; DateTime newestTime = DateTime.MinValue; foreach (var f in Directory.GetFiles(rootDemos, "*.dem")) { if (!f.Contains(steamId.ToString())) continue; var wt = File.GetLastWriteTimeUtc(f); if (wt > newestTime) { newestTime = wt; newest = f; } } if (newest == null) return; string finalName = $"{steamId}_{DateTime.UtcNow:yyyyMMdd_HHmmss}_{stopReason}.dem"; string dest = Path.Combine(DemoDir, finalName); File.Move(newest, dest); var entry = _demoIndex.FindLast(e => e.SteamId == steamId && e.Active); if (entry != null) { entry.Active = false; entry.EndTime = DateTime.UtcNow; entry.FileName = finalName; } MarkDirty(); LogToFile("BloodKrakenAntiCheat", $"[DEMO-STOP] steamid={steamId} file={finalName} reason={stopReason}", this); QueueDiscordDemo(BasePlayer.FindByID(steamId), stopReason, finalName); } catch (Exception ex) { PrintError($"RelocateDemoFile failed: {ex.Message}"); } } // 7.3 — Ретеншн/диск: по таймеру, не блокируя игровой поток. private void EnforceRetention() { try { var demoCutoff = DateTime.UtcNow.AddDays(-config.Demos.RetentionDays); foreach (var f in Directory.GetFiles(DemoDir, "*.dem")) if (File.GetLastWriteTimeUtc(f) < demoCutoff) TryDelete(f); long totalBytes = 0; var remaining = new List(); foreach (var f in Directory.GetFiles(DemoDir, "*.dem")) { var fi = new FileInfo(f); totalBytes += fi.Length; remaining.Add(fi); } long maxBytes = (long)config.Demos.MaxDiskUsageMb * 1024 * 1024; if (totalBytes > maxBytes) { remaining.Sort((a, b) => a.LastWriteTimeUtc.CompareTo(b.LastWriteTimeUtc)); foreach (var fi in remaining) { if (totalBytes <= maxBytes) break; totalBytes -= fi.Length; TryDelete(fi.FullName); } } _demoIndex.RemoveAll(e => !string.IsNullOrEmpty(e.FileName) && !e.Active && !File.Exists(Path.Combine(DemoDir, e.FileName))); var snapCutoff = DateTime.UtcNow.AddDays(-config.Snapshots.SnapshotRetentionDays); foreach (var f in Directory.GetFiles(SnapshotsDir, "*.json")) if (File.GetLastWriteTimeUtc(f) < snapCutoff) TryDelete(f); _snapshotIndex.RemoveAll(e => !File.Exists(Path.Combine(SnapshotsDir, e.FileName))); MarkDirty(); } catch (Exception ex) { PrintError($"EnforceRetention failed: {ex.Message}"); } } private void TryDelete(string path) { try { File.Delete(path); } catch { } } #endregion #region Logging/Discord private void LogViolation(BasePlayer player, string module, float amount, string details, PlayerProfile profile) { string line = $"[{DateTime.UtcNow:yyyy-MM-dd HH:mm:ss}] {player.displayName} ({player.UserIDString}) " + $"module={module} amount={amount:0.00} total={profile.TotalScore:0.00} status={profile.State} details=\"{details}\""; LogToFile("BloodKrakenAntiCheat", line, this); } private int StatusColorInt(WatchState state) { switch (state) { case WatchState.Green: return 0x4CB359; case WatchState.Yellow: return 0xD9BF33; case WatchState.Blue: return 0x4080E6; case WatchState.Red: return 0xD94040; default: return 0xFFFFFF; } } private void QueueDiscord(BasePlayer player, string module, float amount, string details, PlayerProfile profile) { if (!config.Discord.Enabled || string.IsNullOrEmpty(config.Discord.WebhookUrl)) return; var pos = player.transform.position; var embed = new { embeds = new object[] { new { title = "BloodKrakenAntiCheat — Violation", color = StatusColorInt(profile.State), fields = new object[] { new { name = "Player", value = $"{player.displayName} ({player.UserIDString})", inline = false }, new { name = "Module", value = module, inline = true }, new { name = "Amount", value = amount.ToString("0.00", CultureInfo.InvariantCulture), inline = true }, new { name = "Total score", value = profile.TotalScore.ToString("0.00", CultureInfo.InvariantCulture), inline = true }, new { name = "Status", value = profile.State.ToString(), inline = true }, new { name = "Position", value = $"{pos.x:0},{pos.y:0},{pos.z:0}", inline = true }, new { name = "Details", value = string.IsNullOrEmpty(details) ? "-" : details, inline = false } } } } }; _discordQueue.Enqueue(JsonConvert.SerializeObject(embed)); } private void QueueDiscordDemo(BasePlayer player, string reason, string fileName) { if (!config.Discord.Enabled || string.IsNullOrEmpty(config.Discord.WebhookUrl) || !config.Demos.UploadToDiscord) return; var embed = new { embeds = new object[] { new { title = "BloodKrakenAntiCheat — Demo recorded", fields = new object[] { new { name = "Player", value = $"{player?.displayName} ({player?.UserIDString})", inline = false }, new { name = "Reason", value = reason, inline = true }, new { name = "File", value = fileName, inline = true } } } } }; _discordQueue.Enqueue(JsonConvert.SerializeObject(embed)); } // Троттлинг: не более MaxMessagesPerSecond сообщений в секунду. private void DrainDiscordQueue() { int budget = Math.Max(1, config.Discord.MaxMessagesPerSecond); while (budget-- > 0 && _discordQueue.Count > 0) PostDiscord(_discordQueue.Dequeue()); } private void PostDiscord(string json) { webrequest.Enqueue(config.Discord.WebhookUrl, json, (code, response) => { if (code != 200 && code != 204) PrintWarning($"Discord webhook failed: {code}"); }, this, RequestMethod.POST, new Dictionary { ["Content-Type"] = "application/json" }); } #endregion #region Commands [ChatCommand("ac")] private void CmdAc(BasePlayer player, string command, string[] args) { if (!IsAdmin(player)) { SendReply(player, "У вас нет доступа к анти-читу."); return; } if (args == null || args.Length == 0) { OpenMenu(player); return; } switch (args[0].ToLower()) { case "check": CmdCheck(player, args); break; case "reset": CmdReset(player, args); break; case "verdict": CmdVerdict(player, args); break; case "reload": LoadConfig(); SendReply(player, "Конфиг перезагружен."); RefreshAllMenus(); break; default: OpenMenu(player); break; } } private void CmdCheck(BasePlayer player, string[] args) { if (args.Length < 2) { SendReply(player, "Использование: /ac check "); return; } var profile = ResolveProfile(args[1]); if (profile == null) { SendReply(player, "Профиль не найден."); return; } SendReply(player, $"{profile.Name} ({profile.SteamId})\nСтатус: {profile.State}\nScore: {profile.TotalScore:0.0}\n" + $"Flags: {profile.FlagCount}\nManualWatch: {profile.ManualWatch}"); } private void CmdReset(BasePlayer player, string[] args) { if (args.Length < 2) { SendReply(player, "Использование: /ac reset "); return; } ulong steamId; if (!ulong.TryParse(args[1], out steamId)) { SendReply(player, "Некорректный SteamID."); return; } PlayerProfile profile; if (!_profiles.TryGetValue(steamId, out profile)) { SendReply(player, "Профиль не найден."); return; } profile.TotalScore = 0; profile.FlagCount = 0; profile.ModuleScores.Clear(); profile.ManualWatch = false; profile.Notified = false; profile.State = WatchState.Green; MarkDirty(); RefreshAllMenus(); SendReply(player, $"Сброшено: {profile.Name}"); } // /ac verdict cheater|clean — ручная метка админа для обучения (strong). // Доступ уже закрыт проверкой IsAdmin в CmdAc. Вердикт не влияет на игровые решения. // player_hash админу не показываем. private void CmdVerdict(BasePlayer player, string[] args) { if (args.Length < 3) { SendReply(player, "Использование: /ac verdict cheater|clean"); return; } string v = args[2].ToLower(); if (v != "cheater" && v != "clean") { SendReply(player, "Вердикт: cheater или clean"); return; } var profile = ResolveProfile(args[1]); if (profile == null) { SendReply(player, "Профиль не найден — по этому игроку нет наблюдений, вердикт не отправлен."); return; } if (config?.Telemetry?.Enabled != true) { SendReply(player, "Телеметрия выключена — вердикт не отправлен."); return; } Telemetry_Verdict(profile.SteamId, v, "strong", "plugin_cmd", "none", profile.ModuleScores?.Keys, profile.TotalScore); SendReply(player, $"Вердикт записан: {profile.Name} → {v}"); } private PlayerProfile ResolveProfile(string idOrName) { ulong steamId; if (ulong.TryParse(idOrName, out steamId) && _profiles.ContainsKey(steamId)) return _profiles[steamId]; foreach (var p in _profiles.Values) if (p.Name != null && p.Name.IndexOf(idOrName, StringComparison.OrdinalIgnoreCase) >= 0) return p; return null; } private BasePlayer ResolveOnlineTarget(string idOrName) { if (string.IsNullOrEmpty(idOrName)) return null; ulong steamId; if (ulong.TryParse(idOrName, out steamId)) { var byId = BasePlayer.FindByID(steamId); if (byId != null) return byId; } return BasePlayer.Find(idOrName); } // Для серверных команд: null player = консоль сервера/RCON — доверяем. private bool ValidateConsoleAdmin(ConsoleSystem.Arg arg) { var caller = arg.Player(); if (caller == null) return true; return IsAdmin(caller); } [ConsoleCommand("anticheat.stats")] private void CcStats(ConsoleSystem.Arg arg) { if (!ValidateConsoleAdmin(arg)) return; int green = 0, yellow = 0, blue = 0, red = 0; foreach (var p in _profiles.Values) { switch (p.State) { case WatchState.Green: green++; break; case WatchState.Yellow: yellow++; break; case WatchState.Blue: blue++; break; case WatchState.Red: red++; break; } } SendReply(arg, $"BloodKrakenAntiCheat: tracked={_profiles.Count} green={green} yellow={yellow} blue={blue} red={red} activeDemos={_activeDemos.Count}"); } [ConsoleCommand("anticheat.toggle")] private void CcToggleModule(ConsoleSystem.Arg arg) { if (!ValidateConsoleAdmin(arg) || !arg.HasArgs(2)) return; string module = arg.GetString(0); bool value = arg.GetBool(1); ModuleCfg mc; if (module == null || !config.ModuleConfigs.TryGetValue(module, out mc)) { SendReply(arg, "Неизвестный модуль."); return; } mc.Enabled = value; SaveConfig(); RefreshAllMenus(); SendReply(arg, $"{module} => {(value ? "ON" : "OFF")}"); } [ConsoleCommand("anticheat.exempt")] private void CcExempt(ConsoleSystem.Arg arg) { if (!ValidateConsoleAdmin(arg) || !arg.HasArgs(2)) return; string idStr = arg.GetString(0); bool value = arg.GetBool(1); ulong steamId; if (!ulong.TryParse(idStr, out steamId)) { SendReply(arg, "Некорректный SteamID."); return; } if (value) permission.GrantUserPermission(steamId.ToString(), PermExempt, this); else permission.RevokeUserPermission(steamId.ToString(), PermExempt); SendReply(arg, $"{steamId} exempt => {value}"); } [ConsoleCommand("anticheat.demo")] private void CcDemo(ConsoleSystem.Arg arg) { if (!ValidateConsoleAdmin(arg)) return; string sub = arg.GetString(0); if (string.IsNullOrEmpty(sub)) { SendReply(arg, "Использование: anticheat.demo start|stop [minutes] | anticheat.demo list [SteamID]"); return; } switch (sub.ToLower()) { case "start": { var target = ResolveOnlineTarget(arg.GetString(1)); if (target == null) { SendReply(arg, "Игрок не найден или оффлайн."); return; } EnsureDemoRecording(target, "manual-command"); SendReply(arg, $"Запись начата для {target.displayName}."); break; } case "stop": { var target = ResolveOnlineTarget(arg.GetString(1)); if (target == null) { SendReply(arg, "Игрок не найден или оффлайн."); return; } StopDemo(target.userID, "manual-command"); SendReply(arg, $"Запись остановлена для {target.displayName}."); break; } case "list": { string filter = arg.GetString(1); ulong filterId; bool hasFilter = ulong.TryParse(filter, out filterId); foreach (var e in _demoIndex) { if (hasFilter && e.SteamId != filterId) continue; SendReply(arg, $"{e.SteamId} {e.Name} {e.Reason} {e.StartTime:yyyy-MM-dd HH:mm} -> {(e.Active ? "REC" : e.FileName)}"); } break; } default: SendReply(arg, "Неизвестная подкоманда."); break; } } [ConsoleCommand("anticheat.ui.toggle")] private void CcToggle(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!IsAdmin(player) || !arg.HasArgs(1)) return; string module = arg.GetString(0); if (config.ModuleConfigs.TryGetValue(module, out var m)) { m.Enabled = !m.Enabled; SaveConfig(); } OpenMenu(player); // перерисовать } [ConsoleCommand("anticheat.ui.setflag")] private void CcSetFlag(ConsoleSystem.Arg arg) { var player = arg.Player(); if (!IsAdmin(player) || !arg.HasArgs(2)) return; string key = arg.GetString(0); bool val = arg.GetBool(1); switch (key) { case "AutoKick": config.General.AutoKick = val; break; case "AutoBan": config.General.AutoBan = val; break; case "Discord": config.Discord.Enabled = val; break; } SaveConfig(); OpenMenu(player); } [ConsoleCommand("anticheat.ui.watch")] private void CcWatch(ConsoleSystem.Arg arg) { var admin = arg.Player(); if (!IsAdmin(admin) || !arg.HasArgs(1)) return; var target = ResolveOnlineTarget(arg.GetString(0)); if (target == null) { SendReply(admin, "Игрок не найден."); return; } var profile = EnsureProfile(target); profile.ManualWatch = !profile.ManualWatch; profile.State = ComputeStatus(profile); MarkDirty(); if (profile.ManualWatch) { if (config.Demos.RecordOnBlueWatch) EnsureDemoRecording(target, "manual-watch"); } else { ActiveDemo active; if (_activeDemos.TryGetValue(target.userID, out active) && active.Reason == "manual-watch") StopDemo(target.userID, "watch-off"); } RefreshAllMenus(); } [ConsoleCommand("anticheat.ui.spectate")] private void CcSpectate(ConsoleSystem.Arg arg) { var admin = arg.Player(); if (!IsAdmin(admin) || !arg.HasArgs(1)) return; var profile = ResolveProfile(arg.GetString(0)); if (profile == null) { SendReply(admin, "Игрок не найден."); return; } var last = _demoIndex.FindLast(e => e.SteamId == profile.SteamId && !string.IsNullOrEmpty(e.FileName)); if (last != null) SendReply(admin, $"Последняя демка: {last.FileName}. Воспроизведение в клиенте: demo.play {Path.GetFileNameWithoutExtension(last.FileName)}"); else if (_activeDemos.ContainsKey(profile.SteamId)) SendReply(admin, "Запись ведётся сейчас — файл будет доступен после остановки."); else SendReply(admin, "Демо-записей для этого игрока не найдено."); } [ConsoleCommand("anticheat.ui.close")] private void CcClose(ConsoleSystem.Arg arg) { var player = arg.Player(); if (player == null) return; CuiHelper.DestroyUi(player, UiRoot); _openMenus.Remove(player.userID); } #endregion #region CUI Menu // Цвета private const string ColBg = "0.08 0.08 0.09 0.98"; private const string ColHeader = "0.14 0.14 0.16 1"; private const string ColRow = "0.13 0.13 0.15 0.9"; private const string ColOn = "0.30 0.70 0.35 1"; private const string ColOff = "0.75 0.25 0.25 1"; private const string ColClose = "0.75 0.25 0.25 1"; private const string ColText = "0.9 0.9 0.9 1"; private const string ColBtn = "0.25 0.25 0.28 1"; // Цвета статусов (ТЗ §10) private const string ColGreen = "0.30 0.70 0.35 1"; private const string ColYellow = "0.85 0.75 0.20 1"; private const string ColBlue = "0.25 0.50 0.90 1"; private const string ColRed = "0.85 0.25 0.25 1"; private void OpenMenu(BasePlayer player) { if (!IsAdmin(player)) return; CuiHelper.DestroyUi(player, UiRoot); var c = new CuiElementContainer(); // Фон-панель (расширена под две колонки: модули слева, подозреваемые справа) c.Add(new CuiPanel { Image = { Color = ColBg }, RectTransform = { AnchorMin = "0.18 0.12", AnchorMax = "0.82 0.88" }, CursorEnabled = true }, "Overlay", UiRoot); // Шапка c.Add(new CuiPanel { Image = { Color = ColHeader }, RectTransform = { AnchorMin = "0 0.93", AnchorMax = "1 1" } }, UiRoot, UiRoot + ".header"); c.Add(new CuiLabel { Text = { Text = " ANTI-CHEAT · Панель управления", FontSize = 16, Align = TextAnchor.MiddleLeft, Color = ColText }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0.85 1" } }, UiRoot + ".header"); // Крестик закрытия c.Add(new CuiButton { Button = { Command = "anticheat.ui.close", Color = ColClose }, RectTransform = { AnchorMin = "0.95 0.15", AnchorMax = "0.99 0.85" }, Text = { Text = "X", FontSize = 16, Align = TextAnchor.MiddleCenter, Color = ColText } }, UiRoot + ".header"); BuildModuleColumn(c); BuildSuspectColumn(c); CuiHelper.AddUi(player, c); _openMenus.Add(player.userID); } // Левая колонка: строки модулей + тумблеры AutoKick/AutoBan/Discord внизу. private void BuildModuleColumn(CuiElementContainer c) { c.Add(new CuiLabel { Text = { Text = " Модули детекции", FontSize = 13, Align = TextAnchor.MiddleLeft, Color = "0.7 0.7 0.75 1" }, RectTransform = { AnchorMin = "0 0.88", AnchorMax = "0.5 0.925" } }, UiRoot); float top = 0.87f; float rowH = 0.075f; float gap = 0.008f; for (int i = 0; i < Modules.Length; i++) { string module = Modules[i]; bool enabled = config.ModuleConfigs.TryGetValue(module, out var m) && m.Enabled; float yMax = top - i * (rowH + gap); float yMin = yMax - rowH; // фон строки c.Add(new CuiPanel { Image = { Color = ColRow }, RectTransform = { AnchorMin = $"0.02 {F(yMin)}", AnchorMax = $"0.47 {F(yMax)}" } }, UiRoot, UiRoot + ".row" + i); // имя модуля (BETA-модули помечаем) string label = (module == "NoRecoil" || module == "ESP") ? module + " (BETA)" : module; c.Add(new CuiLabel { Text = { Text = label, FontSize = 14, Align = TextAnchor.MiddleLeft, Color = ColText }, RectTransform = { AnchorMin = "0.04 0", AnchorMax = "0.6 1" } }, UiRoot + ".row" + i); // кнопка ВКЛ/ВЫКЛ c.Add(new CuiButton { Button = { Command = $"anticheat.ui.toggle {module}", Color = enabled ? ColOn : ColOff }, RectTransform = { AnchorMin = "0.72 0.15", AnchorMax = "0.96 0.85" }, Text = { Text = enabled ? "ВКЛ" : "ВЫКЛ", FontSize = 13, Align = TextAnchor.MiddleCenter, Color = ColText } }, UiRoot + ".row" + i); } // Нижняя секция: флаги действий AddFlagButton(c, "AutoKick", config.General.AutoKick, "0.02 0.03", "0.16 0.11"); AddFlagButton(c, "AutoBan", config.General.AutoBan, "0.175 0.03", "0.315 0.11"); AddFlagButton(c, "Discord", config.Discord.Enabled, "0.33 0.03", "0.47 0.11"); } private void AddFlagButton(CuiElementContainer c, string key, bool state, string anchorMin, string anchorMax) { c.Add(new CuiButton { Button = { Command = $"anticheat.ui.setflag {key} {(!state).ToString().ToLower()}", Color = state ? ColOn : "0.3 0.3 0.32 1" }, RectTransform = { AnchorMin = anchorMin, AnchorMax = anchorMax }, Text = { Text = $"{key}: {(state ? "ON" : "OFF")}", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = ColText } }, UiRoot); } // Правая колонка: легенда статусов + динамический список подозреваемых. private void BuildSuspectColumn(CuiElementContainer c) { c.Add(new CuiLabel { Text = { Text = " Подозреваемые", FontSize = 13, Align = TextAnchor.MiddleLeft, Color = "0.7 0.7 0.75 1" }, RectTransform = { AnchorMin = "0.5 0.88", AnchorMax = "1 0.925" } }, UiRoot); // Легенда: green/yellow/blue/red AddLegendDot(c, ColGreen, "1 флаг", 0.53f, 0.545f, 0.55f, 0.635f); AddLegendDot(c, ColYellow, "повторно", 0.64f, 0.655f, 0.66f, 0.755f); AddLegendDot(c, ColBlue, "слежка", 0.76f, 0.775f, 0.78f, 0.865f); AddLegendDot(c, ColRed, "100%", 0.87f, 0.885f, 0.89f, 0.98f); // Динамический список из PlayerProfile: в списке все, у кого score > NotifyThreshold // или статус не Green (в макете эти строки статичны, здесь — реальные данные). var suspects = new List(_profiles.Values); suspects.Sort((a, b) => b.TotalScore.CompareTo(a.TotalScore)); float top = 0.815f; float rowH = 0.065f; float gap = 0.007f; int shown = 0; const int maxRows = 10; foreach (var profile in suspects) { if (shown >= maxRows) break; if (profile.State == WatchState.Green && profile.TotalScore <= config.General.NotifyThreshold) continue; float yMax = top - shown * (rowH + gap); float yMin = yMax - rowH; AddSuspectRow(c, profile, yMin, yMax, shown); shown++; } if (shown == 0) { c.Add(new CuiLabel { Text = { Text = "Подозреваемых нет", FontSize = 12, Align = TextAnchor.MiddleCenter, Color = "0.55 0.55 0.6 1" }, RectTransform = { AnchorMin = "0.5 0.4", AnchorMax = "1 0.5" } }, UiRoot); } } private void AddLegendDot(CuiElementContainer c, string color, string label, float dotMinX, float dotMaxX, float lblMinX, float lblMaxX) { c.Add(new CuiPanel { Image = { Color = color }, RectTransform = { AnchorMin = $"{F(dotMinX)} 0.845", AnchorMax = $"{F(dotMaxX)} 0.875" } }, UiRoot); c.Add(new CuiLabel { Text = { Text = label, FontSize = 10, Align = TextAnchor.MiddleLeft, Color = "0.75 0.75 0.8 1" }, RectTransform = { AnchorMin = $"{F(lblMinX)} 0.84", AnchorMax = $"{F(lblMaxX)} 0.88" } }, UiRoot); } private void AddSuspectRow(CuiElementContainer c, PlayerProfile profile, float yMin, float yMax, int index) { string statusColor = profile.State == WatchState.Red ? ColRed : profile.State == WatchState.Blue ? ColBlue : profile.State == WatchState.Yellow ? ColYellow : ColGreen; string rowName = UiRoot + ".suspect" + index; // фон строки c.Add(new CuiPanel { Image = { Color = ColRow }, RectTransform = { AnchorMin = $"0.5 {F(yMin)}", AnchorMax = $"0.98 {F(yMax)}" } }, UiRoot, rowName); // цветная полоса статуса c.Add(new CuiPanel { Image = { Color = statusColor }, RectTransform = { AnchorMin = "0 0", AnchorMax = "0.025 1" } }, rowName); // ник + инфо (флаги/процент) int percent = Mathf.Clamp(Mathf.RoundToInt(profile.TotalScore / Mathf.Max(1f, config.General.BanThreshold) * 100f), 0, 999); c.Add(new CuiLabel { Text = { Text = $"{profile.Name} · {profile.FlagCount} флагов · {percent}%", FontSize = 11, Align = TextAnchor.MiddleLeft, Color = ColText }, RectTransform = { AnchorMin = "0.04 0", AnchorMax = "0.58 1" } }, rowName); // индикатор записи if (_activeDemos.ContainsKey(profile.SteamId)) { c.Add(new CuiLabel { Text = { Text = "● REC", FontSize = 10, Align = TextAnchor.MiddleLeft, Color = "0.9 0.2 0.2 1" }, RectTransform = { AnchorMin = "0.58 0", AnchorMax = "0.7 1" } }, rowName); } // WATCH (синий статус) и SPEC c.Add(new CuiButton { Button = { Command = $"anticheat.ui.watch {profile.SteamId}", Color = profile.ManualWatch ? ColBlue : ColBtn }, RectTransform = { AnchorMin = "0.71 0.12", AnchorMax = "0.84 0.88" }, Text = { Text = "WATCH", FontSize = 10, Align = TextAnchor.MiddleCenter, Color = ColText } }, rowName); c.Add(new CuiButton { Button = { Command = $"anticheat.ui.spectate {profile.SteamId}", Color = ColBtn }, RectTransform = { AnchorMin = "0.85 0.12", AnchorMax = "0.98 0.88" }, Text = { Text = "SPEC", FontSize = 10, Align = TextAnchor.MiddleCenter, Color = ColText } }, rowName); } // Перерисовка открытых меню (после действий/новых нарушений). private void RefreshAllMenus() { if (_openMenus.Count == 0) return; foreach (var uid in new List(_openMenus)) { var viewer = BasePlayer.FindByID(uid); if (viewer != null) OpenMenu(viewer); else _openMenus.Remove(uid); } } // Инвариантный формат float для CUI-анкоров (независимо от локали сервера). private static string F(float v) => v.ToString("0.####", CultureInfo.InvariantCulture); #endregion #region BloodKraken Cloud Telemetry // ------------------------- конфиг (секция "Telemetry") ------------------------- public class TelemetryConfig { public bool Enabled = false; public string BaseUrl = "https://api.bloodkrakenanticheat.ru"; public string ServerId = ""; public string ApiSecret = ""; // из кабинета; хранить как пароль public string HashPepper = ""; // соль для player_hash; генерится сама при первом старте public int HeartbeatIntervalSec = 300; public int BatchIntervalSec = 60; public int MaxQueue = 5000; public bool AllowInsecureHttp = false; // только для локальной отладки, в проде НИКОГДА } // ------------------------- состояние ------------------------- private readonly List> _tmQueue = new List>(); private readonly object _tmLock = new object(); // Очередь вердиктов (меток для обучения, POST /v1/verdicts) — по образцу очереди событий. private readonly List> _tmVerdictQueue = new List>(); private bool _tmVerdictInFlight; // Контрактные значения полей вердикта (v1.1): не из списка — не отправляем. private static readonly HashSet _tmVerdictValues = new HashSet { "cheater", "clean", "unknown" }; private static readonly HashSet _tmStrengthValues = new HashSet { "strong", "weak", "unknown" }; private static readonly HashSet _tmVerdictSources = new HashSet { "admin_ui", "ban_hook", "plugin_cmd" }; private Oxide.Plugins.Timer _tmBatchTimer, _tmHeartbeatTimer; private bool _tmDisabled; // фатальная остановка (отозван ключ / плохой конфиг) private bool _tmInFlight; // защита от параллельных отправок батча private int _tmFailures; // подряд неудач (для backoff) private double _tmBackoffUntil; // unix-время, до которого не шлём private int _tmDropped; // сколько событий выкинули из-за переполнения private DateTime _tmLastUpdateNotice = DateTime.MinValue; private double _tmStartedAt; private static double TmNow() => DateTimeOffset.UtcNow.ToUnixTimeSeconds(); // Разрешённые модулем бэкенда значения (контракт §3.1). Остальное не отправляем. private static readonly HashSet _tmAllowedModules = new HashSet { "Aimbot","SilentAim","NoRecoil","Wallbang","Flyhack","Speedhack","Spiderhack","ESP", "ArkanAimbot","ArkanNoRecoil","ArkanInRock" }; // ------------------------- инициализация / остановка ------------------------- private void Telemetry_Init() { var t = config?.Telemetry; if (t == null || !t.Enabled) { Puts("[Telemetry] выключена (Telemetry.Enabled=false)"); return; } if (string.IsNullOrEmpty(t.ServerId) || string.IsNullOrEmpty(t.ApiSecret)) { PrintWarning("[Telemetry] не заполнены ServerId/ApiSecret — получи ключ в кабинете " + "(bloodkrakenanticheat.ru/dashboard) и вставь в конфиг. Телеметрия не запущена."); return; } if (!t.BaseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) && !t.AllowInsecureHttp) { PrintError("[Telemetry] BaseUrl должен начинаться с https:// — телеметрия не запущена."); return; } if (string.IsNullOrEmpty(t.HashPepper)) { // pepper делает player_hash невосстановимым без конфига этого сервера t.HashPepper = Guid.NewGuid().ToString("N") + Guid.NewGuid().ToString("N"); SaveConfig(); Puts("[Telemetry] сгенерирован HashPepper для псевдонимизации SteamID (сохранён в конфиг)."); } _tmDisabled = false; _tmStartedAt = TmNow(); _tmBatchTimer = timer.Every(Math.Max(15, t.BatchIntervalSec), () => { Telemetry_Flush(); Telemetry_FlushVerdicts(); }); _tmHeartbeatTimer = timer.Every(Math.Max(60, t.HeartbeatIntervalSec), Telemetry_Heartbeat); timer.Once(10f, Telemetry_Heartbeat); // первый пульс вскоре после старта Puts($"[Telemetry] запущена → {t.BaseUrl} (батч {Math.Max(15, t.BatchIntervalSec)}с, пульс {Math.Max(60, t.HeartbeatIntervalSec)}с)"); } private void Telemetry_Shutdown() { _tmBatchTimer?.Destroy(); _tmHeartbeatTimer?.Destroy(); _tmBatchTimer = _tmHeartbeatTimer = null; } // ------------------------- публичный вход: репорт нарушения ------------------------- // Вызывать из AddViolation. source: "own" | "arkan". // prevStatus — статус игрока ДО текущего нарушения (для features.prev_status); // передаётся из AddViolation, для других путей может быть null (ключ не добавится). public void Telemetry_Report(ulong steamId, string module, float amount, float totalScore, string status, string weapon = "", bool headshot = false, float distance = 0f, bool wallbang = false, int nearbyCount = 0, string source = "own", string prevStatus = null) { var t = config?.Telemetry; if (t == null || !t.Enabled || _tmDisabled) return; string mapped = Telemetry_MapModule(module, source); if (mapped == null) return; // не входит в контрактный enum — не шлём var ev = new Dictionary { ["event_id"] = Guid.NewGuid().ToString(), ["ts"] = DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), ["player_hash"] = Telemetry_PlayerHash(steamId), ["module"] = mapped, ["amount"] = Math.Round(amount, 2), ["total_score"] = Math.Round(totalScore, 2), ["status"] = string.IsNullOrEmpty(status) ? "Green" : status, ["weapon"] = (weapon ?? "").Length > 64 ? weapon.Substring(0, 64) : (weapon ?? ""), ["headshot"] = headshot, ["distance"] = Math.Round(distance, 1), ["wallbang"] = wallbang, ["nearby_count"]= nearbyCount, ["source"] = source == "arkan" ? "arkan" : "own" }; // Признаки для обучения (v1.2): только уже посчитанные производные числа; // сбор в try/catch внутри и не может уронить AddViolation. Нет — ключ не добавляем. var features = Telemetry_BuildFeatures(steamId, module, prevStatus); if (features != null && features.Count > 0) ev["features"] = features; lock (_tmLock) { if (_tmQueue.Count >= Math.Max(100, t.MaxQueue)) { _tmQueue.RemoveAt(0); // выкидываем старейшее, сервер важнее телеметрии _tmDropped++; } _tmQueue.Add(ev); } } private string Telemetry_MapModule(string module, string source) { if (string.IsNullOrEmpty(module)) return null; if (source == "arkan") { switch (module) { case "Aimbot": return "ArkanAimbot"; case "NoRecoil": return "ArkanNoRecoil"; case "InRock": return "ArkanInRock"; default: return _tmAllowedModules.Contains("Arkan" + module) ? "Arkan" + module : null; } } return _tmAllowedModules.Contains(module) ? module : null; } private string Telemetry_PlayerHash(ulong steamId) => TmHmacHex(config.Telemetry.HashPepper, steamId.ToString()); // ------------------------- вердикты (метки для обучения, v1.2) ------------------------- // Вердикт — только метка для датасета. НЕ влияет на игровые решения/score/кик/бан. // verdict: cheater|clean|unknown; strength: strong|weak|unknown; // source: admin_ui|ban_hook|plugin_cmd; reasonClass: cheat_suspected|macro_suspected|other|none. public void Telemetry_Verdict(ulong steamId, string verdict, string strength, string source, string reasonClass, IEnumerable modules = null, float score = 0f) { var t = config?.Telemetry; if (t == null || !t.Enabled || _tmDisabled) return; if (!_tmVerdictValues.Contains(verdict) || !_tmStrengthValues.Contains(strength) || !_tmVerdictSources.Contains(source)) { PrintWarning($"[Telemetry] вердикт отклонён: недопустимые значения (verdict={verdict}, strength={strength}, source={source})"); return; } var vd = new Dictionary { ["verdict_id"] = Guid.NewGuid().ToString(), ["player_hash"] = Telemetry_PlayerHash(steamId), ["verdict"] = verdict, ["strength"] = strength, ["source"] = source, ["reason_class"] = reasonClass, ["modules_at_verdict"] = modules?.Take(20).ToList(), ["score_at_verdict"] = Math.Round(score, 2), ["ts"] = DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'") }; lock (_tmLock) { if (_tmVerdictQueue.Count >= Math.Max(100, t.MaxQueue)) { _tmVerdictQueue.RemoveAt(0); // выкидываем старейшее, сервер важнее телеметрии _tmDropped++; } _tmVerdictQueue.Add(vd); } } // Отправка очереди вердиктов на POST /v1/verdicts (аналог Telemetry_Flush, батч <=200). // Backoff общий с событиями (_tmBackoffUntil) — это осознанно. private void Telemetry_FlushVerdicts() { var t = config?.Telemetry; if (t == null || !t.Enabled || _tmDisabled || _tmVerdictInFlight) return; if (TmNow() < _tmBackoffUntil) return; List> batch; lock (_tmLock) { if (_tmVerdictQueue.Count == 0) return; batch = _tmVerdictQueue.GetRange(0, Math.Min(_tmVerdictQueue.Count, 200)); } var payload = new Dictionary { ["server_id"] = t.ServerId, ["plugin_version"] = Version.ToString(), ["sent_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), ["verdicts"] = batch }; string body = JsonConvert.SerializeObject(payload); _tmVerdictInFlight = true; Telemetry_SignedPost("/v1/verdicts", body, (code, resp) => { _tmVerdictInFlight = false; if (code == 200) { lock (_tmLock) _tmVerdictQueue.RemoveRange(0, Math.Min(batch.Count, _tmVerdictQueue.Count)); _tmFailures = 0; Telemetry_HandleVersion(resp); } else Telemetry_OnFailure(code, resp); }); } // ------------------------- признаки события (features, v1.2) ------------------------- // Только производные от УЖЕ посчитанного (_profiles/_watch): числа/строки <=32 символов, // плоский объект <=30 ключей. Никаких координат, имён, IP, текста CombatLog. // Любая ошибка -> null (сбор признаков не должен уронить AddViolation). private Dictionary Telemetry_BuildFeatures(ulong steamId, string module, string prevStatus) { try { PlayerProfile profile; if (!_profiles.TryGetValue(steamId, out profile)) return null; var f = new Dictionary { ["flags_total"] = profile.FlagCount, ["flags_last_hour"] = profile.History.Count(h => (DateTime.UtcNow - h.Time).TotalHours <= 1), ["session_minutes"] = (int)(DateTime.UtcNow - profile.FirstSeen).TotalMinutes }; float moduleScore; profile.ModuleScores.TryGetValue(module, out moduleScore); f["module_score"] = Math.Round(moduleScore, 2); if (!string.IsNullOrEmpty(prevStatus)) f["prev_status"] = prevStatus; // статус ДО этого нарушения (снят в AddViolation) WatchBuffer wb; if (_watch.TryGetValue(steamId, out wb)) // буфера может не быть — ключи не добавляем { f["burst_len"] = wb.RecoilShots.Count; f["weapon_short"] = wb.LastWeaponShortname ?? ""; if (wb.RecoilShots.Count >= 2) { var shots = wb.RecoilShots.ToArray(); float span = shots[shots.Length - 1].Time - shots[0].Time; f["shots_per_sec"] = Math.Round(span > 0f ? shots.Length / span : 0f, 2); f["time_since_prev_shot_ms"] = (int)((shots[shots.Length - 1].Time - shots[shots.Length - 2].Time) * 1000f); // std углов между соседними направлениями взгляда: аномально низкий = макрос. if (shots.Length >= 3) { var angles = new List(); for (int i = 1; i < shots.Length; i++) angles.Add(Vector3.Angle(shots[i - 1].Aim, shots[i].Aim)); double mean = angles.Average(); double variance = angles.Sum(a => (a - mean) * (a - mean)) / angles.Count; f["angle_std_deg"] = Math.Round(Math.Sqrt(variance), 2); } } } return f; } catch { return null; } } // ------------------------- отправка батча ------------------------- private void Telemetry_Flush() { var t = config?.Telemetry; if (t == null || !t.Enabled || _tmDisabled || _tmInFlight) return; if (TmNow() < _tmBackoffUntil) return; List> batch; lock (_tmLock) { if (_tmQueue.Count == 0) return; batch = _tmQueue.GetRange(0, Math.Min(_tmQueue.Count, 100)); // ≤100 событий, тело ≪ 256 КБ } var payload = new Dictionary { ["server_id"] = t.ServerId, ["plugin_version"] = Version.ToString(), ["sent_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss'Z'"), ["events"] = batch }; string body = JsonConvert.SerializeObject(payload); _tmInFlight = true; Telemetry_SignedPost("/v1/events", body, (code, resp) => { _tmInFlight = false; if (code == 200) { lock (_tmLock) _tmQueue.RemoveRange(0, Math.Min(batch.Count, _tmQueue.Count)); _tmFailures = 0; Telemetry_HandleVersion(resp); } else Telemetry_OnFailure(code, resp); }); } // ------------------------- пульс ------------------------- private void Telemetry_Heartbeat() { var t = config?.Telemetry; if (t == null || !t.Enabled || _tmDisabled) return; if (TmNow() < _tmBackoffUntil) return; var payload = new Dictionary { ["server_id"] = t.ServerId, ["plugin_version"] = Version.ToString(), ["players_online"] = BasePlayer.activePlayerList?.Count ?? 0, ["uptime_s"] = (long)(TmNow() - _tmStartedAt) }; Telemetry_SignedPost("/v1/heartbeat", JsonConvert.SerializeObject(payload), (code, resp) => { if (code == 200) { _tmFailures = 0; Telemetry_HandleVersion(resp); } else Telemetry_OnFailure(code, resp); }); } // ------------------------- подписанный POST (контракт §2.2) ------------------------- // canonical = METHOD \n PATH \n TIMESTAMP \n NONCE \n SHA256_hex(body) // подпись = hex(HMAC_SHA256(api_secret, canonical)) private void Telemetry_SignedPost(string path, string body, Action cb) { var t = config.Telemetry; string ts = ((long)TmNow()).ToString(); string nonce = Guid.NewGuid().ToString(); string canonical = "POST\n" + path + "\n" + ts + "\n" + nonce + "\n" + TmSha256Hex(body ?? ""); string sig = TmHmacHex(t.ApiSecret, canonical); var headers = new Dictionary { ["Content-Type"] = "application/json", ["X-BK-Server"] = t.ServerId, ["X-BK-Timestamp"] = ts, ["X-BK-Nonce"] = nonce, ["X-BK-Signature"] = sig }; webrequest.Enqueue(t.BaseUrl.TrimEnd('/') + path, body, (code, resp) => { try { cb(code, resp); } catch (Exception e) { PrintError("[Telemetry] callback error: " + e.Message); _tmInFlight = false; } }, this, RequestMethod.POST, headers, 12f); } // ------------------------- деградация и ошибки ------------------------- private void Telemetry_OnFailure(int code, string resp) { _tmFailures++; // отозванный ключ — фатально до перезагрузки с новым ключом if (code == 401 && resp != null && resp.Contains("revoked_key")) { _tmDisabled = true; PrintError("[Telemetry] Ключ сервера ОТОЗВАН. Телеметрия остановлена. " + "Ротируй ключ в кабинете (bloodkrakenanticheat.ru/dashboard) и обнови конфиг, затем oxide.reload плагина."); return; } if (code == 401 && resp != null && resp.Contains("clock_skew")) PrintWarning("[Telemetry] Часы сервера расходятся с реальным временем БОЛЕЕ чем на 5 минут — " + "подпись отклоняется. Синхронизируй время (NTP) на игровом сервере."); // экспоненциальный backoff: 10с → 20с → 40с … максимум 1 час double backoff = Math.Min(3600, 5 * Math.Pow(2, Math.Min(9, _tmFailures))); _tmBackoffUntil = TmNow() + backoff; int qlen; lock (_tmLock) qlen = _tmQueue.Count; if (_tmFailures <= 3 || _tmFailures % 10 == 0) PrintWarning($"[Telemetry] Отправка не удалась (HTTP {code}). Повтор через {backoff:F0} c. " + $"В очереди {qlen} событий (сервер работает нормально, телеметрия догонит)."); } // ------------------------- уведомление о версии (обновление ТОЛЬКО вручную) ------------------------- private void Telemetry_HandleVersion(string resp) { try { if (string.IsNullOrEmpty(resp)) return; var jo = JObject.Parse(resp); var lv = jo["latest_version"]; if (lv == null) return; string latest = (string)lv["version"] ?? ""; string minSup = (string)lv["min_supported"] ?? ""; string sev = (string)lv["severity"] ?? "info"; string url = (string)lv["download_url"] ?? ""; string msg = (string)lv["message"] ?? ""; string mine = Version.ToString(); if (TmCmpVer(mine, latest) >= 0) return; // мы не отстали if ((DateTime.UtcNow - _tmLastUpdateNotice).TotalHours < 6) // не спамим чаще раза в 6 часов return; _tmLastUpdateNotice = DateTime.UtcNow; bool hard = TmCmpVer(mine, minSup) < 0 || sev == "critical"; if (hard) PrintError($"⚠ АНТИ-ЧИТ УСТАРЕЛ! Установлена v{mine}, актуальная v{latest}. " + $"Обнови плагин вручную: {url} {(string.IsNullOrEmpty(msg) ? "" : "— " + msg)}"); else PrintWarning($"[Telemetry] Доступна новая версия v{latest} (у тебя v{mine}). Скачать: {url}"); } catch { /* некритично */ } } private static int TmCmpVer(string a, string b) { if (string.IsNullOrEmpty(a) || string.IsNullOrEmpty(b)) return 0; var pa = a.Split('.'); var pb = b.Split('.'); for (int i = 0; i < Math.Max(pa.Length, pb.Length); i++) { int xa = i < pa.Length && int.TryParse(pa[i], out var va) ? va : 0; int xb = i < pb.Length && int.TryParse(pb[i], out var vb) ? vb : 0; if (xa != xb) return xa.CompareTo(xb); } return 0; } // ------------------------- крипто-хелперы ------------------------- private static string TmSha256Hex(string s) { using (var sha = System.Security.Cryptography.SHA256.Create()) { var b = sha.ComputeHash(Encoding.UTF8.GetBytes(s ?? "")); var sb = new StringBuilder(b.Length * 2); foreach (var x in b) sb.Append(x.ToString("x2")); return sb.ToString(); } } private static string TmHmacHex(string key, string msg) { using (var h = new System.Security.Cryptography.HMACSHA256(Encoding.UTF8.GetBytes(key ?? ""))) { var b = h.ComputeHash(Encoding.UTF8.GetBytes(msg ?? "")); var sb = new StringBuilder(b.Length * 2); foreach (var x in b) sb.Append(x.ToString("x2")); return sb.ToString(); } } // ------------------------- консольные команды ------------------------- [ConsoleCommand("anticheat.telemetry.status")] private void CmdTelemetryStatus(ConsoleSystem.Arg arg) { if (arg.Connection != null) return; // только серверная консоль var t = config?.Telemetry; int qlen, vqlen; lock (_tmLock) { qlen = _tmQueue.Count; vqlen = _tmVerdictQueue.Count; } Puts("[Telemetry] status:" + $"\n enabled={t?.Enabled == true}, disabled_by_error={_tmDisabled}" + $"\n queue={qlen}, verdict_queue={vqlen}, dropped={_tmDropped}, failures_in_row={_tmFailures}" + $"\n backoff_until={( _tmBackoffUntil > TmNow() ? (_tmBackoffUntil - TmNow()).ToString("F0") + "s" : "нет")}" + $"\n base={t?.BaseUrl}, server={t?.ServerId}"); } [ConsoleCommand("anticheat.telemetry.test")] private void CmdTelemetryTest(ConsoleSystem.Arg arg) { if (arg.Connection != null) return; // только серверная консоль // Фиксированный тест-вектор — должен совпасть с бэкендом байт-в-байт. const string secret = "sk_test_secret"; const string ts = "1783467516"; const string nonce = "7e3c0000-0000-0000-0000-000000000000"; const string body = "{\"events\":[]}"; const string expected = "0431964b29de2356e86b3f303dff58fc249fc781c3756bb8dfdb6d450aaf120a"; string canonical = "POST\n/v1/events\n" + ts + "\n" + nonce + "\n" + TmSha256Hex(body); string sig = TmHmacHex(secret, canonical); Puts("[Telemetry] self-test подписи:" + $"\n получено : {sig}" + $"\n ожидается: {expected}" + $"\n РЕЗУЛЬТАТ: {(sig == expected ? "СОВПАЛО ✔ — формат совместим с сервером" : "НЕ СОВПАЛО ✖ — сообщи разработчику")}"); } #endregion } }