using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.InteropServices; using System.Runtime.Versioning; using System.Security.Cryptography; using BepInEx; using BepInEx.Bootstrap; using BepInEx.Logging; using HarmonyLib; using Landoria.SharedLib; using Mono.Cecil; using TMPro; using UnityEngine; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: AssemblyTitle("Landoria.ModSentry")] [assembly: AssemblyDescription("Strict client mod inventory validation for Valheim servers")] [assembly: AssemblyCompany("Landoria Gaming")] [assembly: AssemblyProduct("Landoria.ModSentry")] [assembly: AssemblyCopyright("Copyright © Landoria Gaming 2026")] [assembly: ComVisible(false)] [assembly: Guid("dc95b791-79c8-4c9c-9d44-e33e87ad806e")] [assembly: AssemblyFileVersion("1.0.1")] [assembly: TargetFramework(".NETFramework,Version=v4.8", FrameworkDisplayName = "")] [assembly: AssemblyVersion("1.0.1.22720")] namespace Landoria.ModSentry { internal static class ClientMessage { private static string _pending; internal static void Receive(ZRpc rpc, string message) { _pending = message; rpc.Invoke("Landoria_ModSentry_RejectionAck", Array.Empty()); } internal static bool TryTake(out string message) { message = _pending; _pending = null; return !string.IsNullOrWhiteSpace(message); } internal static void Clear() { _pending = null; } } [HarmonyPatch(typeof(ZNet), "OnNewConnection")] internal static class RegisterHandshakePatch { private static void Postfix(ZNet __instance, ZNetPeer peer) { ModSentryHandshake.Register(__instance, peer); } } [HarmonyPatch(typeof(ZNet), "SendPeerInfo")] internal static class SendInventoryPatch { private static void Prefix(ZRpc rpc) { if ((Object)(object)ZNet.instance != (Object)null && !ZNet.instance.IsServer()) { ModSentryHandshake.SendInventory(rpc); } } } [HarmonyPatch(typeof(ZNet), "RPC_PeerInfo")] internal static class ValidatePeerPatch { private static bool Prefix(ZRpc rpc) { if (!((Object)(object)ZNet.instance == (Object)null) && ZNet.instance.IsServer()) { return ModSentryHandshake.Admit(rpc); } return true; } } [HarmonyPatch(typeof(ZNet), "Disconnect")] internal static class ClearHandshakePatch { private static void Prefix(ZNetPeer peer) { if (peer?.m_rpc != null) { HandshakeState.Remove(peer.m_rpc); PendingDisconnects.Remove(peer.m_rpc); } } } [HarmonyPatch(typeof(FejdStartup), "ShowConnectError")] internal static class ShowRejectionPatch { private static void Postfix(TMP_Text ___m_connectionFailedError) { if (ClientMessage.TryTake(out var message)) { ___m_connectionFailedError.text = message; } } } internal static class HandshakeState { private static readonly HashSet Accepted = new HashSet(); private static readonly Dictionary Rejected = new Dictionary(); internal static void Accept(ZRpc rpc) { Rejected.Remove(rpc); Accepted.Add(rpc); } internal static void Reject(ZRpc rpc, ValidationResult result) { Accepted.Remove(rpc); Rejected[rpc] = result; } internal static bool IsAccepted(ZRpc rpc) { return Accepted.Contains(rpc); } internal static ValidationResult RejectionFor(ZRpc rpc) { if (!Rejected.TryGetValue(rpc, out var value)) { return null; } return value; } internal static void Remove(ZRpc rpc) { Accepted.Remove(rpc); Rejected.Remove(rpc); } internal static void Clear() { Accepted.Clear(); Rejected.Clear(); } } internal static class ModSentryHandshake { internal static void Register(ZNet network, ZNetPeer peer) { //IL_0036: Unknown result type (might be due to invalid IL or missing references) //IL_0040: Expected O, but got Unknown if (network.IsServer()) { peer.m_rpc.Register("Landoria_ModSentry_Inventory", (Action)ReceiveInventory); peer.m_rpc.Register("Landoria_ModSentry_RejectionAck", new Method(ReceiveRejectionAck)); } else { ClientMessage.Clear(); peer.m_rpc.Register("Landoria_ModSentry_Rejection", (Action)ClientMessage.Receive); } } internal static void SendInventory(ZRpc serverRpc) { serverRpc.Invoke("Landoria_ModSentry_Inventory", new object[1] { PluginInventory.Serialize() }); } internal static void ReceiveInventory(ZRpc rpc, ZPackage package) { try { IReadOnlyList actual = PluginInventory.Deserialize(package); ValidationResult result = PolicyValidator.Validate(ModSentryPlugin.EnsurePolicy(), actual); Record(rpc, result); } catch (Exception arg) { ValidationResult result2 = ValidationResult.Reject("The installed mods could not be verified.", $"Client inventory parsing failed: {arg}"); Record(rpc, result2); } } internal static bool Admit(ZRpc rpc) { if (HandshakeState.IsAccepted(rpc)) { return true; } ValidationResult validationResult = HandshakeState.RejectionFor(rpc) ?? ValidationResult.Reject("Mod verification did not complete. Please try again.", "PeerInfo arrived before an accepted ModSentry inventory."); rpc.Invoke("Landoria_ModSentry_Rejection", new object[1] { validationResult.PlayerMessage }); ModSentryPlugin.Log.LogWarning(validationResult.TechnicalMessage); PendingDisconnects.Schedule(rpc); return false; } internal static void Disconnect(ZRpc rpc) { ZNetPeer val = ((IEnumerable)ZNet.instance.GetPeers()).FirstOrDefault((Func)((ZNetPeer candidate) => candidate.m_rpc == rpc)); if (val != null) { ZNet.instance.Kick(val.m_socket.GetHostName()); } } private static void ReceiveRejectionAck(ZRpc rpc) { if (PendingDisconnects.Acknowledge(rpc)) { Disconnect(rpc); } } private static void Record(ZRpc rpc, ValidationResult result) { if (result.Accepted) { HandshakeState.Accept(rpc); ModSentryPlugin.Log.LogInfo(result.TechnicalMessage); } else { HandshakeState.Reject(rpc, result); ModSentryPlugin.Log.LogWarning(result.TechnicalMessage); } } } [BepInPlugin("Landoria.ModSentry", "Landoria.ModSentry", "1.0.1")] public sealed class ModSentryPlugin : LandoriaPlugin { internal const string InventoryRpc = "Landoria_ModSentry_Inventory"; internal const string RejectionRpc = "Landoria_ModSentry_Rejection"; internal const string RejectionAckRpc = "Landoria_ModSentry_RejectionAck"; internal const int ProtocolVersion = 1; private const string PluginGuid = "Landoria.ModSentry"; private const string PluginName = "Landoria.ModSentry"; private const string PluginVersion = "1.0.1"; internal static ModLog Log { get; private set; } internal static PluginPolicy Policy { get; private set; } private void Awake() { Log = InitializePlugin("Landoria.ModSentry"); Log.LogInfo("Landoria.ModSentry 1.0.1 is loaded."); } internal static PluginPolicy EnsurePolicy() { if (Policy == null) { Policy = PluginPolicy.Load(); Log.LogInfo($"Loaded {Policy.Required.Count} required and " + $"{Policy.Optional.Count} optional client mod policies."); } return Policy; } private void Update() { PendingDisconnects.Tick(); } private void OnDestroy() { Log?.LogInfo("Landoria.ModSentry 1.0.1 is unloaded."); HandshakeState.Clear(); PendingDisconnects.Clear(); ClientMessage.Clear(); Policy = null; ShutdownPlugin(); Log = null; } } internal sealed class PluginDescriptor { internal string Guid { get; } internal string Name { get; } internal string Version { get; } internal string Hash { get; } internal PluginDescriptor(string guid, string name, string version, string hash) { Guid = guid; Name = name; Version = version; Hash = hash; } } internal static class PendingDisconnects { private const float FallbackSeconds = 2f; private static readonly Dictionary Deadlines = new Dictionary(); internal static void Schedule(ZRpc rpc) { Deadlines[rpc] = Time.unscaledTime + 2f; } internal static bool Acknowledge(ZRpc rpc) { return Deadlines.Remove(rpc); } internal static void Remove(ZRpc rpc) { Deadlines.Remove(rpc); } internal static void Tick() { List list = new List(); foreach (KeyValuePair deadline in Deadlines) { if (Time.unscaledTime >= deadline.Value) { list.Add(deadline.Key); } } foreach (ZRpc item in list) { Deadlines.Remove(item); ModSentryHandshake.Disconnect(item); } } internal static void Clear() { Deadlines.Clear(); } } internal static class PluginInventory { internal static IReadOnlyList Capture() { return Chainloader.PluginInfos.Values.Select((PluginInfo info) => Create(info.Metadata.GUID, info.Metadata.Name, info.Metadata.Version.ToString(), info.Location)).OrderBy((PluginDescriptor plugin) => plugin.Guid, StringComparer.Ordinal).ToList(); } internal static ZPackage Serialize() { //IL_0006: Unknown result type (might be due to invalid IL or missing references) //IL_000c: Expected O, but got Unknown IReadOnlyList readOnlyList = Capture(); ZPackage val = new ZPackage(); val.Write(1); val.Write(readOnlyList.Count); foreach (PluginDescriptor item in readOnlyList) { Write(val, item); } return val; } internal static List Deserialize(ZPackage package) { int num = package.ReadInt(); if (num != 1) { throw new InvalidDataException($"Unsupported ModSentry protocol {num}."); } int num2 = package.ReadInt(); if (num2 < 0 || num2 > 1024) { throw new InvalidDataException("The client plugin count is invalid."); } List list = new List(num2); for (int i = 0; i < num2; i++) { list.Add(Read(package)); } return list; } internal static string Sha256(string path) { using SHA256 sHA = SHA256.Create(); using FileStream inputStream = File.OpenRead(path); return BitConverter.ToString(sHA.ComputeHash(inputStream)).Replace("-", ""); } private static PluginDescriptor Create(string guid, string name, string version, string path) { return new PluginDescriptor(guid, name, version, Sha256(path)); } private static void Write(ZPackage package, PluginDescriptor plugin) { package.Write(plugin.Guid); package.Write(plugin.Name); package.Write(plugin.Version); package.Write(plugin.Hash); } private static PluginDescriptor Read(ZPackage package) { return new PluginDescriptor(package.ReadString(), package.ReadString(), package.ReadString(), package.ReadString()); } } internal sealed class PluginPolicy { internal IReadOnlyList Required { get; } internal IReadOnlyList Optional { get; } internal PluginPolicy(IReadOnlyList required, IReadOnlyList optional) { Required = required; Optional = optional; } internal static PluginPolicy Load() { return new PluginPolicy(LoadDirectory(Path.Combine(Paths.ConfigPath, "ModSentry_Required")), LoadDirectory(Path.Combine(Paths.ConfigPath, "ModSentry_Optional"))); } private static IReadOnlyList LoadDirectory(string directory) { if (!Directory.Exists(directory)) { throw new DirectoryNotFoundException("ModSentry policy directory is missing: " + directory); } return Directory.GetFiles(directory, "*.dll", SearchOption.TopDirectoryOnly).Select(ReadDescriptor).OrderBy((PluginDescriptor plugin) => plugin.Guid, StringComparer.Ordinal) .ToList(); } private static PluginDescriptor ReadDescriptor(string path) { AssemblyDefinition val = AssemblyDefinition.ReadAssembly(path); try { CustomAttribute attribute = ((IEnumerable)val.MainModule.Types).SelectMany((TypeDefinition type) => (IEnumerable)type.CustomAttributes).SingleOrDefault((Func)((CustomAttribute item) => ((MemberReference)item.AttributeType).FullName == typeof(BepInPlugin).FullName)); return CreateDescriptor(path, attribute); } finally { ((IDisposable)val)?.Dispose(); } } private static PluginDescriptor CreateDescriptor(string path, CustomAttribute attribute) { //IL_0033: Unknown result type (might be due to invalid IL or missing references) //IL_0038: Unknown result type (might be due to invalid IL or missing references) //IL_004c: Unknown result type (might be due to invalid IL or missing references) //IL_0051: Unknown result type (might be due to invalid IL or missing references) //IL_0066: Unknown result type (might be due to invalid IL or missing references) //IL_006b: Unknown result type (might be due to invalid IL or missing references) if (attribute == null || attribute.ConstructorArguments.Count < 3) { throw new InvalidDataException("No BepInPlugin metadata was found in " + Path.GetFileName(path) + "."); } CustomAttributeArgument val = attribute.ConstructorArguments[0]; string guid = (string)((CustomAttributeArgument)(ref val)).Value; val = attribute.ConstructorArguments[1]; string name = (string)((CustomAttributeArgument)(ref val)).Value; val = attribute.ConstructorArguments[2]; string version = (string)((CustomAttributeArgument)(ref val)).Value; return new PluginDescriptor(guid, name, version, PluginInventory.Sha256(path)); } } internal static class PolicyValidator { internal static ValidationResult Validate(PluginPolicy policy, IReadOnlyList actual) { Dictionary dictionary = ToDictionary(actual, "client"); Dictionary dictionary2 = ToDictionary(policy.Required, "required"); Dictionary dictionary3 = ToDictionary(policy.Optional, "optional"); foreach (PluginDescriptor item in policy.Required) { if (!dictionary.TryGetValue(item.Guid, out var value)) { return Missing(item); } ValidationResult validationResult = Compare(item, value, optional: false); if (validationResult != null) { return validationResult; } } foreach (PluginDescriptor item2 in actual.OrderBy((PluginDescriptor item) => item.Guid, StringComparer.Ordinal)) { if (!dictionary2.ContainsKey(item2.Guid)) { if (!dictionary3.TryGetValue(item2.Guid, out var value2)) { return Unexpected(item2); } ValidationResult validationResult2 = Compare(value2, item2, optional: true); if (validationResult2 != null) { return validationResult2; } } } return ValidationResult.Accept(); } private static Dictionary ToDictionary(IEnumerable plugins, string source) { try { return plugins.ToDictionary((PluginDescriptor item) => item.Guid, StringComparer.Ordinal); } catch (ArgumentException innerException) { throw new InvalidOperationException("Duplicate plugin GUID in " + source + " inventory.", innerException); } } private static ValidationResult Compare(PluginDescriptor expected, PluginDescriptor actual, bool optional) { string text = (optional ? "optional" : "required"); if (!string.Equals(expected.Version, actual.Version, StringComparison.Ordinal)) { return ValidationResult.Reject(optional ? UpdateMessage("Optional mod mismatch", expected) : UpdateMessage("Mod update required", expected), text + " plugin " + expected.Guid + " version mismatch: expected " + expected.Version + ", received " + actual.Version + "."); } if (!string.Equals(expected.Hash, actual.Hash, StringComparison.OrdinalIgnoreCase)) { return ValidationResult.Reject(UpdateMessage("Mod mismatch", expected), text + " plugin " + expected.Guid + " SHA-256 mismatch: expected " + expected.Hash + ", received " + actual.Hash + "."); } return null; } private static string UpdateMessage(string reason, PluginDescriptor expected) { return reason + ": " + expected.Name + " " + expected.Version + "."; } private static ValidationResult Missing(PluginDescriptor expected) { return ValidationResult.Reject(UpdateMessage("Required mod missing", expected), "Required plugin " + expected.Guid + " " + expected.Version + " is missing."); } private static ValidationResult Unexpected(PluginDescriptor actual) { return ValidationResult.Reject("Unsupported mod detected: " + actual.Name + ". Remove it before reconnecting.", "Unexpected plugin " + actual.Guid + " " + actual.Version + " with SHA-256 " + actual.Hash + "."); } } internal sealed class ValidationResult { internal bool Accepted { get; } internal string PlayerMessage { get; } internal string TechnicalMessage { get; } private ValidationResult(bool accepted, string playerMessage, string technicalMessage) { Accepted = accepted; PlayerMessage = playerMessage; TechnicalMessage = technicalMessage; } internal static ValidationResult Accept() { return new ValidationResult(accepted: true, string.Empty, "Client plugin inventory accepted."); } internal static ValidationResult Reject(string playerMessage, string technicalMessage) { return new ValidationResult(accepted: false, playerMessage, technicalMessage); } } } namespace Landoria.SharedLib { public abstract class LandoriaPlugin : BaseUnityPlugin { private Harmony _harmony; private bool _patchesApplied; protected ModLog InitializePlugin(string pluginGuid) { //IL_0035: Unknown result type (might be due to invalid IL or missing references) //IL_003f: Expected O, but got Unknown ModLog modLog = new ModLog(((BaseUnityPlugin)this).Logger); Version version = ((object)this).GetType().Assembly.GetName().Version; modLog.LogInfo($"AssemblyVersion: {version}."); _harmony = new Harmony(pluginGuid); PatchOwnNamespace(modLog); return modLog; } protected void PatchOwnNamespace(ModLog log) { if (_patchesApplied) { log.LogDebug("Harmony patches are already active; skipping registration."); return; } string text = ((object)this).GetType().Namespace; Type[] types = Assembly.GetExecutingAssembly().GetTypes(); foreach (Type type in types) { if (type.Namespace == text) { _harmony.CreateClassProcessor(type).Patch(); } } _patchesApplied = true; log.LogDebug("Harmony patches were applied for the plugin namespace."); } protected void ShutdownPlugin() { if (_patchesApplied) { Harmony harmony = _harmony; if (harmony != null) { harmony.UnpatchSelf(); } _patchesApplied = false; } } } public sealed class ModLog { private readonly ManualLogSource _logger; public ModLog(ManualLogSource logger) { _logger = logger; } public void LogFatal(object message) { Write((LogLevel)1, message); } public void LogError(object message) { Write((LogLevel)2, message); } public void LogWarning(object message) { Write((LogLevel)4, message); } public void LogMessage(object message) { Write((LogLevel)8, message); } public void LogInfo(object message) { Write((LogLevel)16, message); } public void LogDebug(object message) { Write((LogLevel)32, message); } public void Log(LogLevel level, object message) { //IL_0001: Unknown result type (might be due to invalid IL or missing references) Write(level, message); } private void Write(LogLevel level, object message) { //IL_0019: Unknown result type (might be due to invalid IL or missing references) string arg = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss.fff"); _logger.Log(level, (object)$"[{arg}] {message}"); } } }