Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of CatosAntiCheat v1.0.4
CatosAntiCheat.dll
Decompiled 5 hours agousing System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Net.Http; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Text; using System.Threading.Tasks; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Configuration; using BepInEx.Logging; using HarmonyLib; using Microsoft.CodeAnalysis; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETFramework,Version=v4.8", FrameworkDisplayName = ".NET Framework 4.8")] [assembly: AssemblyCompany("CatosAntiCheat")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyFileVersion("1.0.4.0")] [assembly: AssemblyInformationalVersion("1.0.4+c08f2239a28e6e6654c74b58b952caf4f9e0ee3c")] [assembly: AssemblyProduct("CatosAntiCheat")] [assembly: AssemblyTitle("CatosAntiCheat")] [assembly: AssemblyVersion("1.0.4.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Embedded] [AttributeUsage(AttributeTargets.Module, AllowMultiple = false, Inherited = false)] internal sealed class RefSafetyRulesAttribute : Attribute { public readonly int Version; public RefSafetyRulesAttribute(int P_0) { Version = P_0; } } } namespace CatosAntiCheat { internal static class AdminCheck { public static bool IsAdmin(ZNetPeer peer) { if (peer == null || (Object)(object)ZNet.instance == (Object)null) { return false; } try { object value = Traverse.Create((object)ZNet.instance).Field("m_adminList").GetValue(); if (value == null) { return false; } List<string> value2 = Traverse.Create(value).Method("GetList", Array.Empty<object>()).GetValue<List<string>>(); if (value2 == null || value2.Count == 0) { return false; } string text = ResolveSteamId(peer); if (string.IsNullOrEmpty(text)) { return false; } string text2 = "Steam_" + text; foreach (string item in value2) { if (!string.IsNullOrEmpty(item) && (item == text || item == text2)) { return true; } } } catch (Exception ex) { Plugin.Log.LogWarning((object)("AdminCheck.IsAdmin failed: " + ex.Message)); } return false; } private static string ResolveSteamId(ZNetPeer peer) { ISocket socket = peer.m_socket; if (socket == null) { return null; } try { object value = Traverse.Create((object)socket).Method("GetPeerID", Array.Empty<object>()).GetValue(); if (value != null) { object value2 = Traverse.Create(value).Field("m_SteamID").GetValue(); if (value2 != null) { return value2.ToString(); } } } catch { } try { string value3 = Traverse.Create((object)socket).Method("GetHostName", Array.Empty<object>()).GetValue<string>(); if (!string.IsNullOrEmpty(value3)) { return value3; } } catch { } return null; } } internal static class DiscordPoster { private static readonly HttpClient _client = new HttpClient { Timeout = TimeSpan.FromSeconds(10.0) }; public static void PostMismatchKick(string playerName, ulong steamId, List<string> problems) { if (!Plugin.DiscordPostMismatchKicks.Value) { return; } string value = Plugin.DiscordWebhookUrl.Value; if (!string.IsNullOrWhiteSpace(value)) { string value2 = Plugin.DiscordServerLabel.Value ?? ""; string value3 = SafeName(playerName); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("**[CatosAntiCheat] Kick — mod mismatch**"); if (!string.IsNullOrEmpty(value2)) { stringBuilder.Append(" `").Append(value2).Append("`"); } stringBuilder.Append('\n'); stringBuilder.Append("Player: `").Append(value3).Append("` (Steam_") .Append(steamId) .Append(")\n"); stringBuilder.Append("Reasons (").Append(problems.Count).Append("):\n"); int num = Math.Min(problems.Count, 15); for (int i = 0; i < num; i++) { stringBuilder.Append("• ").Append(problems[i]).Append('\n'); } if (problems.Count > num) { stringBuilder.Append("• … and ").Append(problems.Count - num).Append(" more\n"); } FireAndForget(value, stringBuilder.ToString()); } } public static void PostTimeoutKick(string playerName, ulong steamId, float timeoutSeconds) { if (!Plugin.DiscordPostTimeoutKicks.Value) { return; } string value = Plugin.DiscordWebhookUrl.Value; if (!string.IsNullOrWhiteSpace(value)) { string value2 = Plugin.DiscordServerLabel.Value ?? ""; string value3 = SafeName(playerName); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append("**[CatosAntiCheat] Kick — no mod-list reply**"); if (!string.IsNullOrEmpty(value2)) { stringBuilder.Append(" `").Append(value2).Append("`"); } stringBuilder.Append('\n'); stringBuilder.Append("Player: `").Append(value3).Append("` (Steam_") .Append(steamId) .Append(")\n"); stringBuilder.Append("Reason: did not respond within ").Append(timeoutSeconds).Append("s "); stringBuilder.Append("(client likely missing CatosAntiCheat or running vanilla)."); FireAndForget(value, stringBuilder.ToString()); } } private static void FireAndForget(string url, string content) { string json = "{\"content\":\"" + JsonEscape(content) + "\"}"; Task.Run(async delegate { try { StringContent body = new StringContent(json, Encoding.UTF8, "application/json"); try { HttpResponseMessage val = await _client.PostAsync(url, (HttpContent)(object)body).ConfigureAwait(continueOnCapturedContext: false); try { if (!val.IsSuccessStatusCode) { Plugin.Log.LogWarning((object)$"DiscordPoster: webhook returned {(int)val.StatusCode} {val.ReasonPhrase}"); } } finally { ((IDisposable)val)?.Dispose(); } } finally { ((IDisposable)body)?.Dispose(); } } catch (Exception ex) { Plugin.Log.LogWarning((object)("DiscordPoster: post failed: " + ex.GetType().Name + ": " + ex.Message)); } }); } private static string JsonEscape(string s) { if (string.IsNullOrEmpty(s)) { return ""; } StringBuilder stringBuilder = new StringBuilder(s.Length + 16); foreach (char c in s) { switch (c) { case '"': stringBuilder.Append("\\\""); continue; case '\\': stringBuilder.Append("\\\\"); continue; case '\n': stringBuilder.Append("\\n"); continue; case '\r': stringBuilder.Append("\\r"); continue; case '\t': stringBuilder.Append("\\t"); continue; } if (c < ' ') { stringBuilder.Append($"\\u{(int)c:X4}"); } else { stringBuilder.Append(c); } } return stringBuilder.ToString(); } private static string SafeName(string name) { if (string.IsNullOrEmpty(name)) { return "<unknown>"; } return name.Replace("`", "").Replace("@", "@\u200b"); } } internal static class ModEnforcer { private struct Pending { public ZNetPeer Peer; public DateTime SentAt; public int Attempts; } private static readonly Dictionary<long, Pending> _pending = new Dictionary<long, Pending>(); private static bool IsAuditOnly => string.Equals(Plugin.EnforcementMode.Value, "AuditOnly", StringComparison.OrdinalIgnoreCase); public static void RegisterPending(ZNetPeer peer) { if (peer != null) { _pending[peer.m_uid] = new Pending { Peer = peer, SentAt = DateTime.UtcNow, Attempts = 0 }; } } public static void TickTimeouts() { if (_pending.Count == 0) { return; } TimeSpan timeSpan = TimeSpan.FromSeconds(Plugin.KickTimeoutSeconds.Value); DateTime utcNow = DateTime.UtcNow; List<long> list = null; foreach (KeyValuePair<long, Pending> item in _pending) { if (!(utcNow - item.Value.SentAt <= timeSpan)) { if (list == null) { list = new List<long>(); } list.Add(item.Key); } } if (list == null) { return; } foreach (long item2 in list) { Pending value = _pending[item2]; if (value.Attempts < Plugin.HandshakeRetries.Value) { value.Attempts++; value.SentAt = DateTime.UtcNow; _pending[item2] = value; Plugin.Log.LogWarning((object)($"No mod-list reply from {Describe(value.Peer)} after {Plugin.KickTimeoutSeconds.Value:0.#}s; " + $"sending handshake retry {value.Attempts}/{Plugin.HandshakeRetries.Value}.")); try { ZRpc rpc = value.Peer.m_rpc; if (rpc != null) { rpc.Invoke("CatosAC_Request", Array.Empty<object>()); } } catch (Exception ex) { _pending.Remove(item2); Reject(value.Peer, "could not retry the mod-list handshake: " + ex.Message); } } else { _pending.Remove(item2); string text = $"no mod-list reply within {Plugin.KickTimeoutSeconds.Value:0.#} seconds. " + "Client likely lacks CatosAntiCheat or is running an incompatible version."; if (!IsAuditOnly) { Plugin.Log.LogWarning((object)("Kicking peer " + Describe(value.Peer) + ": " + text)); SendKickReason(value.Peer, "Connection rejected: " + text); DiscordPoster.PostTimeoutKick(value.Peer.m_playerName, (ulong)value.Peer.m_uid, Plugin.KickTimeoutSeconds.Value); Kick(value.Peer); } else { Plugin.Log.LogWarning((object)("Audit-only: allowing peer " + Describe(value.Peer) + " despite timeout: " + text)); } } } } public static void OnReceiveClientList(ZNetPeer peer, Dictionary<string, string> clientMods) { if (peer == null) { return; } _pending.Remove(peer.m_uid); bool flag = AdminCheck.IsAdmin(peer); List<string> list = Validate(clientMods, flag); if (list.Count == 0) { if (Plugin.LogAcceptedConnections.Value) { Plugin.Log.LogInfo((object)string.Format("Peer {0} mod set OK ({1} mods{2}).", Describe(peer), clientMods.Count, flag ? ", admin" : "")); } return; } string text = FormatProblems(list); if (IsAuditOnly) { Plugin.Log.LogWarning((object)("Audit-only: would reject peer " + Describe(peer) + (flag ? " (admin)" : "") + " - " + $"{list.Count} mod problem(s): {text}")); } else { Plugin.Log.LogWarning((object)string.Format("Kicking peer {0}{1} - {2} mod problem(s): {3}", Describe(peer), flag ? " (admin)" : "", list.Count, text)); DiscordPoster.PostMismatchKick(peer.m_playerName, (ulong)peer.m_uid, GetDisplayedProblems(list)); SendKickReason(peer, "Connection rejected: " + text); Kick(peer); } } public static void Reject(ZNetPeer peer, string reason) { if (peer != null) { _pending.Remove(peer.m_uid); if (IsAuditOnly) { Plugin.Log.LogWarning((object)("Audit-only: would reject peer " + Describe(peer) + " - " + reason)); return; } Plugin.Log.LogWarning((object)("Kicking peer " + Describe(peer) + " - " + reason)); DiscordPoster.PostMismatchKick(peer.m_playerName, (ulong)peer.m_uid, new List<string> { reason }); SendKickReason(peer, "Connection rejected: " + reason); Kick(peer); } } private static List<string> Validate(Dictionary<string, string> client, bool isAdmin) { List<string> list = new List<string>(); Dictionary<string, string> mods = ServerModSet.Mods; if (mods.TryGetValue("com.catosvalheim.anticheat", out var value)) { if (!client.TryGetValue("com.catosvalheim.anticheat", out var value2)) { list.Add("missing required CatosAntiCheat v" + value); } else if (!string.Equals(value2, value, StringComparison.OrdinalIgnoreCase)) { list.Add("CatosAntiCheat version mismatch: client=" + value2 + " server=" + value + " (exact match required)"); } } if (Plugin.RequireClientHasAllServerMods.Value) { foreach (KeyValuePair<string, string> item in mods) { if (string.Equals(item.Key, "com.catosvalheim.anticheat", StringComparison.OrdinalIgnoreCase) || Whitelists.ServerOnly.Allows(item.Key, item.Value)) { continue; } if (!client.TryGetValue(item.Key, out var value3)) { list.Add("missing required " + item.Key + " v" + item.Value); continue; } string text = CheckVersion(item.Key, value3, item.Value); if (text != null) { list.Add(text); } } } foreach (KeyValuePair<string, string> item2 in client) { if (!mods.ContainsKey(item2.Key) && !Whitelists.Extra.Allows(item2.Key, item2.Value) && (!isAdmin || !Whitelists.Admin.Allows(item2.Key, item2.Value))) { list.Add(isAdmin ? ("unauthorized client mod " + item2.Key + " v" + item2.Value + " (not in extra/admin whitelist)") : ("unauthorized client mod " + item2.Key + " v" + item2.Value)); } } return list; } private static string CheckVersion(string guid, string clientVer, string serverVer) { string a = Plugin.VersionPolicy.Value ?? "ClientAtLeastServer"; if (string.Equals(a, "Ignore", StringComparison.OrdinalIgnoreCase)) { return null; } bool flag = string.Equals(clientVer ?? "", serverVer ?? "", StringComparison.OrdinalIgnoreCase); if (string.Equals(a, "Exact", StringComparison.OrdinalIgnoreCase)) { if (!flag) { return "version mismatch " + guid + ": client=" + clientVer + " server=" + serverVer; } return null; } if (flag) { return null; } if (Version.TryParse(clientVer, out Version result) && Version.TryParse(serverVer, out Version result2)) { if (!(result >= result2)) { return "older version " + guid + ": client=" + clientVer + " server=" + serverVer + " (minimum is server's)"; } return null; } return "version unverifiable " + guid + ": client=" + clientVer + " server=" + serverVer; } private static void Kick(ZNetPeer peer) { try { ZRpc rpc = peer.m_rpc; if (rpc != null) { rpc.Invoke("Disconnect", Array.Empty<object>()); } } catch { } try { ZNet instance = ZNet.instance; if (instance != null) { instance.Disconnect(peer); } } catch (Exception ex) { Plugin.Log.LogDebug((object)("Disconnect threw: " + ex.Message)); } } private static void SendKickReason(ZNetPeer peer, string reason) { //IL_0054: Unknown result type (might be due to invalid IL or missing references) //IL_005a: Expected O, but got Unknown if (!Plugin.SendKickMessages.Value || peer == null || string.IsNullOrWhiteSpace(reason)) { return; } try { string text = ((reason.Length > 420) ? (reason.Substring(0, 417) + "...") : reason); string text2 = Plugin.KickMessagePrefix.Value ?? "CatosAntiCheat"; ZPackage val = new ZPackage(); val.Write(text2); val.Write(text); val.Write(0L); ZRoutedRpc instance = ZRoutedRpc.instance; if (instance != null) { instance.InvokeRoutedRPC(peer.m_uid, "ChatMessage", new object[1] { val }); } } catch (Exception ex) { Plugin.Log.LogDebug((object)("Could not send kick reason to " + Describe(peer) + ": " + ex.Message)); } } public static void ClearPending(long uid) { _pending.Remove(uid); } private static string FormatProblems(List<string> problems) { int num = Math.Max(1, Plugin.MaxDisplayedProblems.Value); if (problems.Count > num) { return string.Join("; ", problems.GetRange(0, num)) + $"; ... (+{problems.Count - num} more)"; } return string.Join("; ", problems); } private static List<string> GetDisplayedProblems(List<string> problems) { int num = Math.Max(1, Plugin.MaxDisplayedProblems.Value); if (problems.Count <= num) { return problems; } return problems.GetRange(0, num); } private static string Describe(ZNetPeer p) { if (p != null) { return string.Format("{0}/{1}", p.m_uid, p.m_playerName ?? "<noname>"); } return "<null>"; } } [HarmonyPatch(typeof(ZNet), "OnNewConnection")] internal static class ZNet_OnNewConnection_Patch { private static void Postfix(ZNetPeer peer) { //IL_002b: Unknown result type (might be due to invalid IL or missing references) //IL_0035: Expected O, but got Unknown if (!Plugin.Enabled.Value || peer == null || peer.m_rpc == null) { return; } try { peer.m_rpc.Register("CatosAC_Request", new Method(RpcHandlers.OnRequest)); peer.m_rpc.Register<ZPackage>("CatosAC_Reply", (Action<ZRpc, ZPackage>)RpcHandlers.OnReply); } catch (Exception ex) { Plugin.Log.LogError((object)("Failed to register CatosAntiCheat RPCs on peer: " + ex.Message)); } } } [HarmonyPatch(typeof(ZNet), "RPC_PeerInfo")] internal static class ZNet_RPC_PeerInfo_Patch { private static void Postfix(ZNet __instance, ZRpc rpc) { if (!Plugin.Enabled.Value || (Object)(object)__instance == (Object)null || !__instance.IsServer()) { return; } ZNetPeer val = RpcHandlers.FindPeer(rpc); if (val == null) { return; } ServerModSet.Build(); Plugin.Log.LogInfo((object)(string.Format("Starting mod validation for peer {0}/{1}: ", val.m_uid, val.m_playerName ?? "<noname>") + $"server requires {ServerModSet.Count} detected mod(s).")); ModEnforcer.RegisterPending(val); try { rpc.Invoke("CatosAC_Request", Array.Empty<object>()); } catch (Exception ex) { Plugin.Log.LogError((object)$"Failed to send CatosAC_Request to {val.m_uid}: {ex.Message}"); ModEnforcer.ClearPending(val.m_uid); } } } [HarmonyPatch(typeof(ZNet), "Update")] internal static class ZNet_Update_Patch { private static void Postfix(ZNet __instance) { if (Plugin.Enabled.Value && !((Object)(object)__instance == (Object)null) && __instance.IsServer()) { ModEnforcer.TickTimeouts(); } } } internal static class RpcHandlers { public static void OnRequest(ZRpc rpc) { //IL_0013: Unknown result type (might be due to invalid IL or missing references) //IL_0019: Expected O, but got Unknown if (!Plugin.Enabled.Value) { return; } try { ServerModSet.Build(); ZPackage val = new ZPackage(); ServerModSet.WriteTo(val); rpc.Invoke("CatosAC_Reply", new object[1] { val }); } catch (Exception ex) { Plugin.Log.LogError((object)("OnRequest reply failed: " + ex.Message)); } } public static void OnReply(ZRpc rpc, ZPackage pkg) { if (!Plugin.Enabled.Value || (Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer()) { return; } ZNetPeer val = FindPeer(rpc); if (val == null) { Plugin.Log.LogWarning((object)"CatosAC_Reply received from unknown peer."); return; } Dictionary<string, string> dictionary; try { dictionary = ServerModSet.ReadFrom(pkg); } catch (Exception ex) { Plugin.Log.LogError((object)$"Failed to deserialize client mod list from {val.m_uid}: {ex.Message}"); ModEnforcer.Reject(val, "incompatible or malformed mod-list handshake. Update CatosAntiCheat to the server's version."); return; } Plugin.Log.LogInfo((object)(string.Format("Received mod list from peer {0}/{1}: ", val.m_uid, val.m_playerName ?? "<noname>") + $"client reported {dictionary.Count} detected mod(s); validating now.")); ModEnforcer.OnReceiveClientList(val, dictionary); } public static ZNetPeer FindPeer(ZRpc rpc) { if (rpc == null || (Object)(object)ZNet.instance == (Object)null) { return null; } foreach (ZNetPeer peer in ZNet.instance.GetPeers()) { if (peer != null && peer.m_rpc == rpc) { return peer; } } return null; } } [BepInPlugin("com.catosvalheim.anticheat", "CatosAntiCheat", "1.0.4")] public class Plugin : BaseUnityPlugin { public const string ModGuid = "com.catosvalheim.anticheat"; public const string ModName = "CatosAntiCheat"; public const string ModVersion = "1.0.4"; internal static ManualLogSource Log; internal static ConfigEntry<bool> Enabled; internal static ConfigEntry<string> EnforcementMode; internal static ConfigEntry<float> KickTimeoutSeconds; internal static ConfigEntry<int> HandshakeRetries; internal static ConfigEntry<string> VersionPolicy; internal static ConfigEntry<bool> RequireClientHasAllServerMods; internal static ConfigEntry<int> MaxDisplayedProblems; internal static ConfigEntry<bool> SendKickMessages; internal static ConfigEntry<string> KickMessagePrefix; internal static ConfigEntry<bool> LogAcceptedConnections; internal static ConfigEntry<string> DiscordWebhookUrl; internal static ConfigEntry<bool> DiscordPostMismatchKicks; internal static ConfigEntry<bool> DiscordPostTimeoutKicks; internal static ConfigEntry<string> DiscordServerLabel; public void Awake() { //IL_0065: Unknown result type (might be due to invalid IL or missing references) //IL_006f: Expected O, but got Unknown //IL_00fa: Unknown result type (might be due to invalid IL or missing references) //IL_0104: Expected O, but got Unknown //IL_0249: Unknown result type (might be due to invalid IL or missing references) Log = ((BaseUnityPlugin)this).Logger; Enabled = ((BaseUnityPlugin)this).Config.Bind<bool>("1 - General", "Enabled", true, "If false, no checks run and no peers are kicked."); EnforcementMode = ((BaseUnityPlugin)this).Config.Bind<string>("1 - General", "EnforcementMode", "Enforce", new ConfigDescription("Enforce kicks mismatched clients. AuditOnly logs mismatches but allows connections.", (AcceptableValueBase)(object)new AcceptableValueList<string>(new string[2] { "Enforce", "AuditOnly" }), Array.Empty<object>())); KickTimeoutSeconds = ((BaseUnityPlugin)this).Config.Bind<float>("1 - General", "KickTimeoutSeconds", 15f, "Seconds to wait for a connecting client's mod-list reply before kicking. Vanilla / non-CatosAntiCheat clients will hit this and be kicked."); HandshakeRetries = ((BaseUnityPlugin)this).Config.Bind<int>("1 - General", "HandshakeRetries", 1, "Number of additional mod-list requests after a timeout. Default 1 gives slow clients one retry."); VersionPolicy = ((BaseUnityPlugin)this).Config.Bind<string>("2 - Strictness", "VersionPolicy", "ClientAtLeastServer", new ConfigDescription("How mod versions are compared. Exact: client must match server exactly. ClientAtLeastServer: client version must be >= server (allows clients to update before the server). Ignore: only mod GUIDs are compared, versions are not.", (AcceptableValueBase)(object)new AcceptableValueList<string>(new string[3] { "Exact", "ClientAtLeastServer", "Ignore" }), Array.Empty<object>())); RequireClientHasAllServerMods = ((BaseUnityPlugin)this).Config.Bind<bool>("2 - Strictness", "RequireClientHasAllServerMods", true, "If true, kick clients missing any mod that is installed on the server."); MaxDisplayedProblems = ((BaseUnityPlugin)this).Config.Bind<int>("2 - Strictness", "MaxDisplayedProblems", 6, "Maximum mismatch reasons included in logs, Discord notifications, and player messages."); SendKickMessages = ((BaseUnityPlugin)this).Config.Bind<bool>("3 - Feedback", "SendKickMessages", true, "If true, send a readable rejection reason to the player's in-game chat before disconnecting."); KickMessagePrefix = ((BaseUnityPlugin)this).Config.Bind<string>("3 - Feedback", "KickMessagePrefix", "CatosAntiCheat", "Prefix used for player-facing rejection messages."); LogAcceptedConnections = ((BaseUnityPlugin)this).Config.Bind<bool>("3 - Feedback", "LogAcceptedConnections", false, "If true, log every client that passes mod validation."); DiscordWebhookUrl = ((BaseUnityPlugin)this).Config.Bind<string>("3 - Discord", "WebhookUrl", "", "Discord webhook URL for kick notifications. Leave empty to disable Discord posting."); DiscordPostMismatchKicks = ((BaseUnityPlugin)this).Config.Bind<bool>("3 - Discord", "PostMismatchKicks", true, "Post a Discord notification when a peer is kicked for a mod-list mismatch."); DiscordPostTimeoutKicks = ((BaseUnityPlugin)this).Config.Bind<bool>("3 - Discord", "PostTimeoutKicks", true, "Post a Discord notification when a peer is kicked for not replying in time (usually means they don't have CatosAntiCheat installed)."); DiscordServerLabel = ((BaseUnityPlugin)this).Config.Bind<string>("3 - Discord", "ServerLabel", "TEST_SERVER", "A label for this server, included in every Discord post."); NormalizeConfig(); Whitelists.InitAll(); ServerModSet.Build(); new Harmony("com.catosvalheim.anticheat").PatchAll(); Log.LogInfo((object)(string.Format("{0} v{1} loaded. Enabled={2}; mode={3}; ", "CatosAntiCheat", "1.0.4", Enabled.Value, EnforcementMode.Value) + $"timeout={KickTimeoutSeconds.Value:0.#}s; retries={HandshakeRetries.Value}; " + $"Initial mod snapshot has {ServerModSet.Count} mod(s) and will refresh at handshake; " + $"extra-whitelist={Whitelists.Extra.Count}; admin-whitelist={Whitelists.Admin.Count}; " + $"server-only={Whitelists.ServerOnly.Count}; max-problems={MaxDisplayedProblems.Value}; " + $"kick-messages={SendKickMessages.Value}; accepted-logging={LogAcceptedConnections.Value}.")); } private static void NormalizeConfig() { if (float.IsNaN(KickTimeoutSeconds.Value) || float.IsInfinity(KickTimeoutSeconds.Value) || KickTimeoutSeconds.Value < 1f) { Log.LogWarning((object)"KickTimeoutSeconds must be a finite value of at least 1 second; using 15."); KickTimeoutSeconds.Value = 15f; } if (HandshakeRetries.Value < 0 || HandshakeRetries.Value > 5) { Log.LogWarning((object)"HandshakeRetries must be between 0 and 5; using 1."); HandshakeRetries.Value = 1; } if (MaxDisplayedProblems.Value < 1 || MaxDisplayedProblems.Value > 50) { Log.LogWarning((object)"MaxDisplayedProblems must be between 1 and 50; using 6."); MaxDisplayedProblems.Value = 6; } if (!string.Equals(EnforcementMode.Value, "Enforce", StringComparison.OrdinalIgnoreCase) && !string.Equals(EnforcementMode.Value, "AuditOnly", StringComparison.OrdinalIgnoreCase)) { Log.LogWarning((object)("Unknown EnforcementMode '" + EnforcementMode.Value + "'. Using Enforce.")); EnforcementMode.Value = "Enforce"; } if (string.IsNullOrWhiteSpace(KickMessagePrefix.Value)) { Log.LogWarning((object)"KickMessagePrefix cannot be empty; using CatosAntiCheat."); KickMessagePrefix.Value = "CatosAntiCheat"; } else if (KickMessagePrefix.Value.Length > 80) { Log.LogWarning((object)"KickMessagePrefix is too long; truncating to 80 characters."); KickMessagePrefix.Value = KickMessagePrefix.Value.Substring(0, 80); } string value = VersionPolicy.Value; if (!string.Equals(value, "Exact", StringComparison.OrdinalIgnoreCase) && !string.Equals(value, "ClientAtLeastServer", StringComparison.OrdinalIgnoreCase) && !string.Equals(value, "Ignore", StringComparison.OrdinalIgnoreCase)) { Log.LogWarning((object)("Unknown VersionPolicy '" + value + "'. Using ClientAtLeastServer.")); VersionPolicy.Value = "ClientAtLeastServer"; } } } internal static class ServerModSet { public const int ProtocolVersion = 1; public static Dictionary<string, string> Mods { get; private set; } = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); public static int Count => Mods.Count; public static void Build() { Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); foreach (KeyValuePair<string, PluginInfo> pluginInfo in Chainloader.PluginInfos) { PluginInfo value = pluginInfo.Value; BepInPlugin val = ((value != null) ? value.Metadata : null); if (val != null) { dictionary[val.GUID] = val.Version?.ToString() ?? "0.0.0"; } } Mods = dictionary; } public static void WriteTo(ZPackage pkg) { pkg.Write(1); pkg.Write(Mods.Count); foreach (KeyValuePair<string, string> mod in Mods) { pkg.Write(mod.Key); pkg.Write(mod.Value); } } public static Dictionary<string, string> ReadFrom(ZPackage pkg) { int num = pkg.ReadInt(); if (num != 1) { throw new InvalidDataException($"Unsupported CatosAntiCheat protocol {num}; expected {1}."); } int num2 = pkg.ReadInt(); if (num2 < 0 || num2 > 10000) { throw new InvalidDataException($"Invalid client mod count {num2}."); } Dictionary<string, string> dictionary = new Dictionary<string, string>(num2, StringComparer.OrdinalIgnoreCase); for (int i = 0; i < num2; i++) { string text = pkg.ReadString(); string value = pkg.ReadString(); if (string.IsNullOrWhiteSpace(text)) { throw new InvalidDataException("Client mod list contained an empty GUID."); } if (dictionary.ContainsKey(text)) { throw new InvalidDataException("Client mod list contained duplicate GUID '" + text + "'."); } dictionary[text] = value; } return dictionary; } } internal sealed class WhitelistFile { private readonly string _filename; private readonly string _defaultContent; private readonly object _lock = new object(); private Dictionary<string, string> _entries = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); private FileSystemWatcher _watcher; private string _path; public string Filename => _filename; public int Count { get { lock (_lock) { return _entries.Count; } } } public WhitelistFile(string filename, string defaultContent) { _filename = filename; _defaultContent = defaultContent; } public void Init() { _path = Path.Combine(Paths.ConfigPath, _filename); if (!File.Exists(_path)) { File.WriteAllText(_path, _defaultContent); } Reload(); try { _watcher = new FileSystemWatcher(Path.GetDirectoryName(_path), _filename) { NotifyFilter = (NotifyFilters.Size | NotifyFilters.LastWrite | NotifyFilters.CreationTime), EnableRaisingEvents = true }; _watcher.Changed += delegate { Reload(); }; _watcher.Created += delegate { Reload(); }; } catch (Exception ex) { Plugin.Log.LogWarning((object)(_filename + " watcher failed to start: " + ex.Message)); } } public bool Allows(string guid, string version) { lock (_lock) { if (!_entries.TryGetValue(guid, out var value)) { return false; } if (value == null) { return true; } return string.Equals(value, version, StringComparison.OrdinalIgnoreCase); } } private void Reload() { try { Dictionary<string, string> dictionary = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); int num = 0; string[] array = File.ReadAllLines(_path); foreach (string obj in array) { num++; string text = obj.Trim(); if (text.Length == 0 || text.StartsWith("#")) { continue; } string text2 = null; int num2 = text.IndexOf('='); string text3; if (num2 >= 0) { text3 = text.Substring(0, num2).Trim(); text2 = text.Substring(num2 + 1).Trim(); if (text2.Length == 0) { Plugin.Log.LogWarning((object)$"{_filename}:{num} has an empty version; ignoring the entry."); continue; } } else { text3 = text; } if (text3.Length == 0) { Plugin.Log.LogWarning((object)$"{_filename}:{num} has an empty GUID; ignoring the entry."); continue; } if (text3.IndexOfAny(new char[4] { '\t', '\n', '\r', ' ' }) >= 0) { Plugin.Log.LogWarning((object)$"{_filename}:{num} contains whitespace in GUID '{text3}'; ignoring the entry."); continue; } if (dictionary.ContainsKey(text3)) { Plugin.Log.LogWarning((object)$"{_filename}:{num} duplicates GUID '{text3}'; latest entry wins."); } dictionary[text3] = text2; } lock (_lock) { _entries = dictionary; } Plugin.Log.LogInfo((object)$"{_filename} reloaded: {dictionary.Count} entry(ies)."); } catch (Exception ex) { Plugin.Log.LogError((object)(_filename + " reload failed: " + ex.Message)); } } } internal static class Whitelists { private const string ExtraDefault = "# CatosAntiCheat extra-whitelist\n# Mods listed here are ALLOWED on EVERY client in addition to the mods installed on the server.\n# Use this for client-side QoL mods that don't need to be on the server.\n#\n# Format: one BepInEx plugin GUID per line. Optionally pin a specific version.\n# org.example.qolmod -> any version allowed\n# org.example.qolmod=1.2.3 -> only version 1.2.3 allowed\n#\n# This file hot-reloads on save. No server restart needed.\n"; private const string AdminDefault = "# CatosAntiCheat admin-only whitelist\n# Mods listed here are ONLY allowed on admins (Steam IDs in adminlist.txt).\n# Non-admin players with these mods installed will still be kicked.\n#\n# Use this for admin/staff tools (e.g. Upgrade_World, Server Devcommands client-side, etc.).\n#\n# Format: one BepInEx plugin GUID per line. Optionally pin a specific version.\n# JereKuusela.valheim_upgrade_world\n# JereKuusela.valheim_upgrade_world=1.50.0\n#\n# This file hot-reloads on save. No server restart needed.\n"; private const string ServerOnlyDefault = "# CatosAntiCheat server-only mods\n# Server plugin GUIDs listed here do NOT need to be installed on clients.\n# Use this for server administration/tools mods that do not run client-side.\n#\n# Format: one BepInEx plugin GUID per line. Optionally pin a specific version.\n# org.example.servermod -> clients do not need this mod\n# org.example.servermod=1.2.3 -> same, with an exact server version pin\n#\n# This file only affects server -> client requirements. A client that has a\n# listed mod is still accepted because it is a server-installed mod.\n# This file hot-reloads on save. No server restart needed.\n"; public static readonly WhitelistFile Extra = new WhitelistFile("CatosAntiCheat_ExtraWhitelist.txt", "# CatosAntiCheat extra-whitelist\n# Mods listed here are ALLOWED on EVERY client in addition to the mods installed on the server.\n# Use this for client-side QoL mods that don't need to be on the server.\n#\n# Format: one BepInEx plugin GUID per line. Optionally pin a specific version.\n# org.example.qolmod -> any version allowed\n# org.example.qolmod=1.2.3 -> only version 1.2.3 allowed\n#\n# This file hot-reloads on save. No server restart needed.\n"); public static readonly WhitelistFile Admin = new WhitelistFile("CatosAntiCheat_AdminWhitelist.txt", "# CatosAntiCheat admin-only whitelist\n# Mods listed here are ONLY allowed on admins (Steam IDs in adminlist.txt).\n# Non-admin players with these mods installed will still be kicked.\n#\n# Use this for admin/staff tools (e.g. Upgrade_World, Server Devcommands client-side, etc.).\n#\n# Format: one BepInEx plugin GUID per line. Optionally pin a specific version.\n# JereKuusela.valheim_upgrade_world\n# JereKuusela.valheim_upgrade_world=1.50.0\n#\n# This file hot-reloads on save. No server restart needed.\n"); public static readonly WhitelistFile ServerOnly = new WhitelistFile("CatosAntiCheat_ServerOnly.txt", "# CatosAntiCheat server-only mods\n# Server plugin GUIDs listed here do NOT need to be installed on clients.\n# Use this for server administration/tools mods that do not run client-side.\n#\n# Format: one BepInEx plugin GUID per line. Optionally pin a specific version.\n# org.example.servermod -> clients do not need this mod\n# org.example.servermod=1.2.3 -> same, with an exact server version pin\n#\n# This file only affects server -> client requirements. A client that has a\n# listed mod is still accepted because it is a server-installed mod.\n# This file hot-reloads on save. No server restart needed.\n"); public static void InitAll() { Extra.Init(); Admin.Init(); ServerOnly.Init(); } } }