Decompiled source of CatosAntiCheat v1.0.4

CatosAntiCheat.dll

Decompiled 5 hours ago
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<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();
		}
	}
}