using 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 value2 = Traverse.Create(value).Method("GetList", Array.Empty()).GetValue>(); 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()).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()).GetValue(); 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 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 ""; } 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 _pending = new Dictionary(); 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 list = null; foreach (KeyValuePair item in _pending) { if (!(utcNow - item.Value.SentAt <= timeSpan)) { if (list == null) { list = new List(); } 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()); } } 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 clientMods) { if (peer == null) { return; } _pending.Remove(peer.m_uid); bool flag = AdminCheck.IsAdmin(peer); List 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 { reason }); SendKickReason(peer, "Connection rejected: " + reason); Kick(peer); } } private static List Validate(Dictionary client, bool isAdmin) { List list = new List(); Dictionary 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 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 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()); } } 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 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 GetDisplayedProblems(List 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 ?? ""); } return ""; } } [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("CatosAC_Reply", (Action)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 ?? "") + $"server requires {ServerModSet.Count} detected mod(s).")); ModEnforcer.RegisterPending(val); try { rpc.Invoke("CatosAC_Request", Array.Empty()); } 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 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 ?? "") + $"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 Enabled; internal static ConfigEntry EnforcementMode; internal static ConfigEntry KickTimeoutSeconds; internal static ConfigEntry HandshakeRetries; internal static ConfigEntry VersionPolicy; internal static ConfigEntry RequireClientHasAllServerMods; internal static ConfigEntry MaxDisplayedProblems; internal static ConfigEntry SendKickMessages; internal static ConfigEntry KickMessagePrefix; internal static ConfigEntry LogAcceptedConnections; internal static ConfigEntry DiscordWebhookUrl; internal static ConfigEntry DiscordPostMismatchKicks; internal static ConfigEntry DiscordPostTimeoutKicks; internal static ConfigEntry 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("1 - General", "Enabled", true, "If false, no checks run and no peers are kicked."); EnforcementMode = ((BaseUnityPlugin)this).Config.Bind("1 - General", "EnforcementMode", "Enforce", new ConfigDescription("Enforce kicks mismatched clients. AuditOnly logs mismatches but allows connections.", (AcceptableValueBase)(object)new AcceptableValueList(new string[2] { "Enforce", "AuditOnly" }), Array.Empty())); KickTimeoutSeconds = ((BaseUnityPlugin)this).Config.Bind("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("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("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(new string[3] { "Exact", "ClientAtLeastServer", "Ignore" }), Array.Empty())); RequireClientHasAllServerMods = ((BaseUnityPlugin)this).Config.Bind("2 - Strictness", "RequireClientHasAllServerMods", true, "If true, kick clients missing any mod that is installed on the server."); MaxDisplayedProblems = ((BaseUnityPlugin)this).Config.Bind("2 - Strictness", "MaxDisplayedProblems", 6, "Maximum mismatch reasons included in logs, Discord notifications, and player messages."); SendKickMessages = ((BaseUnityPlugin)this).Config.Bind("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("3 - Feedback", "KickMessagePrefix", "CatosAntiCheat", "Prefix used for player-facing rejection messages."); LogAcceptedConnections = ((BaseUnityPlugin)this).Config.Bind("3 - Feedback", "LogAcceptedConnections", false, "If true, log every client that passes mod validation."); DiscordWebhookUrl = ((BaseUnityPlugin)this).Config.Bind("3 - Discord", "WebhookUrl", "", "Discord webhook URL for kick notifications. Leave empty to disable Discord posting."); DiscordPostMismatchKicks = ((BaseUnityPlugin)this).Config.Bind("3 - Discord", "PostMismatchKicks", true, "Post a Discord notification when a peer is kicked for a mod-list mismatch."); DiscordPostTimeoutKicks = ((BaseUnityPlugin)this).Config.Bind("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("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 Mods { get; private set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public static int Count => Mods.Count; public static void Build() { Dictionary dictionary = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (KeyValuePair 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 mod in Mods) { pkg.Write(mod.Key); pkg.Write(mod.Value); } } public static Dictionary 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 dictionary = new Dictionary(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 _entries = new Dictionary(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 dictionary = new Dictionary(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(); } } }