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 Valheim ServerGuard v1.1.3
Valheim-ServerGuard.dll
Decompiled a week agousing System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.IO; using System.Linq; using System.Net.Http; using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.Versioning; using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using System.Timers; using BepInEx; using BepInEx.Logging; using HarmonyLib; using Microsoft.CodeAnalysis; using Newtonsoft.Json; using UnityEngine; using ValheimServerGuard.Shared; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; [assembly: CompilationRelaxations(8)] [assembly: RuntimeCompatibility(WrapNonExceptionThrows = true)] [assembly: Debuggable(DebuggableAttribute.DebuggingModes.IgnoreSymbolStoreSequencePoints)] [assembly: TargetFramework(".NETFramework,Version=v4.6.2", FrameworkDisplayName = ".NET Framework 4.6.2")] [assembly: AssemblyCompany("yesu0725")] [assembly: AssemblyConfiguration("Release")] [assembly: AssemblyDescription("Valheim Server Guard - Anti-cheat and security mod for Valheim servers")] [assembly: AssemblyFileVersion("1.3.0.0")] [assembly: AssemblyInformationalVersion("1.3.0+03aabb958fe128c55a02aa6089b1ef028d6a578f")] [assembly: AssemblyProduct("Valheim-ServerGuard")] [assembly: AssemblyTitle("Valheim-ServerGuard")] [assembly: AssemblyVersion("1.3.0.0")] [module: RefSafetyRules(11)] namespace Microsoft.CodeAnalysis { [CompilerGenerated] [Microsoft.CodeAnalysis.Embedded] internal sealed class EmbeddedAttribute : Attribute { } } namespace System.Runtime.CompilerServices { [CompilerGenerated] [Microsoft.CodeAnalysis.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; } } } [BepInPlugin("com.taeguk.valheim.serverguard", "Valheim ServerGuard", "1.3.0")] public class Plugin : BaseUnityPlugin { private class Settings { public int ViolationThreshold { get; set; } = 3; public bool Enforce { get; set; } = true; public string KickMessage { get; set; } = "You cannot join: server security policy violation. Contact an administrator."; public string BanReason { get; set; } = "Auto-banned due to repeated security violations."; public int CharacterLimit { get; set; } = 1; public bool RequireCompanion { get; set; } = true; public int CompanionTimeoutSeconds { get; set; } = 10; public bool RequireHmac { get; set; } = true; public string SharedSecret { get; set; } = ""; public bool AllowUnlisted { get; set; } public int MaxClockSkewSeconds { get; set; } = 120; public bool LogPeerManifest { get; set; } public bool EnableMetrics { get; set; } = true; public string discordWebhookUrl { get; set; } = ""; public string discordChannelLink { get; set; } = ""; public bool AggressiveNoModCheck { get; set; } public bool EnableAssemblyScanning { get; set; } public bool UseWhitelistMode { get; set; } public bool RequireAttestation { get; set; } } private class AdminsDoc { public List<string> admins { get; set; } = new List<string>(); } private class AllowedModsDoc { [YamlMember(Alias = "required_mods", ApplyNamingConventions = false)] public List<string> required_mods { get; set; } = new List<string>(); [YamlMember(Alias = "allowed_mods", ApplyNamingConventions = false)] public List<string> allowed_mods { get; set; } = new List<string>(); [YamlMember(Alias = "banned_mods", ApplyNamingConventions = false)] public List<string> banned_mods { get; set; } = new List<string>(); } private class AllowedModEntry { public string Key; public string Sha256; } private class PendingAttestation { public string Challenge; public DateTime SentAt; public string SteamId; public ZNetPeer Peer; } private class DetectionMetrics { public long total_players_checked { get; set; } public long total_mods_detected { get; set; } public long phase1_rpc_detections { get; set; } public long phase2_assembly_detections { get; set; } public long version_keyword_detections { get; set; } public long allowlist_bypasses { get; set; } public long admin_bypasses { get; set; } public long violations_issued { get; set; } public long players_banned { get; set; } public Dictionary<string, long> top_detected_mods { get; set; } = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase); public DateTime last_updated { get; set; } = DateTime.UtcNow; } private class RegistrationsDoc { public Dictionary<string, List<string>> registrations { get; set; } = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); } private class ViolationsDoc { public Dictionary<string, Dictionary<string, int>> violations { get; set; } = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase); } [HarmonyPatch(typeof(ZNet), "OnNewConnection")] public static class Patch_OnNewConnection { public static void Postfix(ZNetPeer peer) { try { if (peer == null || peer.m_rpc == null || !Object.op_Implicit((Object)(object)ZNet.instance) || !ZNet.instance.IsServer()) { return; } string peerPlatformId = GetPeerPlatformId(peer); string peerPlayerName = GetPeerPlayerName(peer); LogS.LogInfo((object)("[ServerGuard] Incoming connection: " + peerPlayerName + " (" + peerPlatformId + ")")); if (Instance.IsAdmin(peerPlatformId)) { LogS.LogInfo((object)("[ServerGuard] " + peerPlatformId + " is admin - skipping attestation.")); if (Instance._settings.EnableMetrics) { Instance._metrics.admin_bypasses++; Instance.SaveMetrics(); } return; } if (Instance._settings.EnableMetrics) { Instance._metrics.total_players_checked++; Instance.SaveMetrics(); } peer.m_rpc.Register<string>("ServerGuard_Manifest", (Action<ZRpc, string>)delegate(ZRpc rpc, string json) { Instance.OnManifestReceived(peer, json); }); string text = Instance.GenerateChallenge(); Instance.RegisterPending(peer, peerPlatformId, text); peer.m_rpc.Invoke("ServerGuard_RequestManifest", new object[1] { text }); ((MonoBehaviour)Instance).StartCoroutine(Instance.AttestationTimeoutCoroutine(peer, peerPlatformId)); } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] OnNewConnection error: {arg}"); } } } [HarmonyPatch(typeof(ZNet), "RPC_PeerInfo")] public static class Patch_RPC_PeerInfo { public static void Postfix(ZNet __instance, ZRpc rpc) { try { if (!Object.op_Implicit((Object)(object)ZNet.instance) || !ZNet.instance.IsServer()) { return; } ZNetPeer val = ResolvePeerFromRpc(__instance, rpc); if (val == null) { return; } string peerPlatformId = GetPeerPlatformId(val); string charName = GetPeerPlayerName(val)?.Trim(); if (!IsValidSteamId(peerPlatformId)) { LogS.LogWarning((object)"[ServerGuard] PeerInfo without valid SteamID; deferring."); } else { if (string.IsNullOrWhiteSpace(charName) || string.Equals(charName, "Unknown", StringComparison.OrdinalIgnoreCase) || Instance.IsAdmin(peerPlatformId)) { return; } if (!Instance._registrations.TryGetValue(peerPlatformId, out var value) || value == null) { value = new List<string>(); Instance._registrations[peerPlatformId] = value; } if (value.Any((string n) => string.Equals(n, charName, StringComparison.Ordinal))) { return; } int num = Math.Max(1, Instance._settings.CharacterLimit); if (value.Count < num) { value.Add(charName); Instance.SaveRegistrations(); LogS.LogInfo((object)$"[ServerGuard] Registered character #{value.Count}/{num} for {peerPlatformId} -> '{charName}'"); return; } Instance.AddViolation(peerPlatformId, "CharacterNameLimitExceeded"); if (Instance._settings.Enforce) { Instance.TryKick(val, string.Format("{0} (Character limit {1} reached: {2})", Instance._settings.KickMessage, num, string.Join(", ", value))); return; } LogS.LogWarning((object)string.Format("[ServerGuard] {0} exceeded character limit ({1}). Tried '{2}'. Allowed: {3}", peerPlatformId, num, charName, string.Join(", ", value))); } } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] RPC_PeerInfo error: {arg}"); } } } private struct PolicyVerdict { public bool Allowed; public string Rule; public string Reason; } private sealed class DiscordLogListener : ILogListener, IDisposable { private readonly string _webhook; private readonly string _prefix; private readonly string _allowedSourceName; private readonly Timer _flushTimer; private readonly Queue<string> _buffer = new Queue<string>(); private static readonly HttpClient _http = new HttpClient(); private bool _isFlushing; private const int MaxDiscordLength = 2000; private const int MaxPostLength = 1800; public DiscordLogListener(string webhook, string prefixTag, string allowedSourceName) { _webhook = webhook?.Trim(); _prefix = (string.IsNullOrWhiteSpace(prefixTag) ? "[ServerGuard]" : prefixTag.Trim()); _allowedSourceName = allowedSourceName ?? string.Empty; _flushTimer = new Timer(2000.0); _flushTimer.AutoReset = true; _flushTimer.Elapsed += delegate { FlushIfNeeded(); }; _flushTimer.Start(); } public void LogEvent(object sender, LogEventArgs eventArgs) { //IL_0041: Unknown result type (might be due to invalid IL or missing references) //IL_0046: Unknown result type (might be due to invalid IL or missing references) try { if (string.IsNullOrWhiteSpace(_webhook)) { return; } ILogSource source = eventArgs.Source; if (!string.Equals(((source != null) ? source.SourceName : null) ?? string.Empty, _allowedSourceName, StringComparison.Ordinal)) { return; } LogLevel level = eventArgs.Level; string text = ((object)(LogLevel)(ref level)).ToString().ToUpperInvariant(); string text2 = eventArgs.Data?.ToString() ?? ""; string item = (_prefix + " [" + text + "] " + text2).Trim(); lock (_buffer) { _buffer.Enqueue(item); if (_buffer.Count > 1000) { _buffer.Dequeue(); } } } catch { } } private async void FlushIfNeeded() { if (string.IsNullOrWhiteSpace(_webhook) || _isFlushing) { return; } List<string> list = null; lock (_buffer) { if (_buffer.Count == 0) { return; } list = new List<string>(_buffer); _buffer.Clear(); } _isFlushing = true; try { StringBuilder chunk = new StringBuilder(); foreach (string line in list) { int num = line.Length + 1; if (chunk.Length + num > 1800) { await PostAsync(chunk.ToString()); chunk.Clear(); } chunk.AppendLine((line.Length > 2000) ? line.Substring(0, 2000) : line); } if (chunk.Length > 0) { await PostAsync(chunk.ToString()); } } catch { } finally { _isFlushing = false; } } private async Task PostAsync(string content) { if (!string.IsNullOrWhiteSpace(content)) { string text = JsonConvert.SerializeObject((object)new { content }); StringContent req = new StringContent(text, Encoding.UTF8, "application/json"); try { await _http.PostAsync(_webhook, (HttpContent)(object)req); } finally { ((IDisposable)req)?.Dispose(); } } } public void Dispose() { try { _flushTimer?.Stop(); _flushTimer?.Dispose(); } catch { } } } [CompilerGenerated] private sealed class <AttestationTimeoutCoroutine>d__80 : IEnumerator<object>, IDisposable, IEnumerator { private int <>1__state; private object <>2__current; public Plugin <>4__this; public ZNetPeer peer; public string steamId; private int <seconds>5__2; object IEnumerator<object>.Current { [DebuggerHidden] get { return <>2__current; } } object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } [DebuggerHidden] public <AttestationTimeoutCoroutine>d__80(int <>1__state) { this.<>1__state = <>1__state; } [DebuggerHidden] void IDisposable.Dispose() { <>1__state = -2; } private bool MoveNext() { //IL_003d: Unknown result type (might be due to invalid IL or missing references) //IL_0047: Expected O, but got Unknown int num = <>1__state; Plugin plugin = <>4__this; switch (num) { default: return false; case 0: <>1__state = -1; <seconds>5__2 = Mathf.Max(1, plugin._settings.CompanionTimeoutSeconds); <>2__current = (object)new WaitForSeconds((float)<seconds>5__2); <>1__state = 1; return true; case 1: <>1__state = -1; lock (plugin._pendingLock) { if (!plugin._pending.TryGetValue(peer.m_uid, out var value) || value == null) { return false; } plugin._pending.Remove(peer.m_uid); } LogS.LogWarning((object)$"[ServerGuard] {steamId} did not deliver a manifest within {<seconds>5__2}s. Treating as no-companion."); plugin.SendDiscordNow($":hourglass: No manifest from {steamId} in {<seconds>5__2}s. Companion plugin missing or unreachable."); if (plugin._settings.RequireCompanion) { plugin.AddViolation(steamId, "CompanionMissing"); if (plugin._settings.Enforce) { plugin.TryKick(peer, plugin._settings.KickMessage + " (Missing required companion plugin: ServerGuard.Client)"); } } return false; } } bool IEnumerator.MoveNext() { //ILSpy generated this explicit interface implementation from .override directive in MoveNext return this.MoveNext(); } [DebuggerHidden] void IEnumerator.Reset() { throw new NotSupportedException(); } } internal static Plugin Instance; internal static ManualLogSource LogS; private Harmony _harmony; private DiscordLogListener _discordListener; private static readonly string RootDir = Path.Combine(Paths.ConfigPath, "ServerGuard"); private static readonly string ConfDir = Path.Combine(RootDir, "conf"); private static readonly string ReadmeMD = Path.Combine(RootDir, "README.md"); private static readonly string SettingsYaml = Path.Combine(ConfDir, "settings.yaml"); private static readonly string AdminsYaml = Path.Combine(ConfDir, "admins.yaml"); private static readonly string AllowedModsYaml = Path.Combine(ConfDir, "allowed_mods.yaml"); private static readonly string RegistrationsYaml = Path.Combine(ConfDir, "registrations.yaml"); private static readonly string ViolationsYaml = Path.Combine(ConfDir, "violations.yaml"); private static readonly string MetricsYaml = Path.Combine(ConfDir, "metrics.yaml"); private static readonly string LegacyIgnoreModsYaml = Path.Combine(ConfDir, "ignore_mods.yaml"); private static readonly string LegacyModPatternsYaml = Path.Combine(ConfDir, "mod_patterns.yaml"); private static IDeserializer _yamlIn; private static ISerializer _yamlOut; private Settings _settings; private HashSet<string> _admins = new HashSet<string>(StringComparer.OrdinalIgnoreCase); private DetectionMetrics _metrics; private List<AllowedModEntry> _requiredMods = new List<AllowedModEntry>(); private List<AllowedModEntry> _allowedMods = new List<AllowedModEntry>(); private List<AllowedModEntry> _bannedMods = new List<AllowedModEntry>(); private Dictionary<long, PendingAttestation> _pending = new Dictionary<long, PendingAttestation>(); private readonly object _pendingLock = new object(); private Dictionary<string, List<string>> _registrations = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); private Dictionary<string, Dictionary<string, int>> _violations = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase); private const string RULE_COMPANION_MISSING = "CompanionMissing"; private const string RULE_HMAC_INVALID = "HmacInvalid"; private const string RULE_CHALLENGE_MISMATCH = "ChallengeMismatch"; private const string RULE_REQUIRED_MOD_MISSING = "RequiredModMissing"; private const string RULE_DISALLOWED_MOD = "DisallowedMod"; private const string RULE_BANNED_MOD = "BannedMod"; private const string RULE_CHAR_NAME_LIMIT = "CharacterNameLimitExceeded"; private FileSystemWatcher _watchSettings; private FileSystemWatcher _watchAdmins; private FileSystemWatcher _watchAllowed; private readonly Dictionary<string, DateTime> _lastSeenWrite = new Dictionary<string, DateTime>(); private void Awake() { //IL_0011: Unknown result type (might be due to invalid IL or missing references) //IL_0020: Expected O, but got Unknown //IL_002f: Unknown result type (might be due to invalid IL or missing references) //IL_003e: Expected O, but got Unknown //IL_0084: Unknown result type (might be due to invalid IL or missing references) //IL_008e: Expected O, but got Unknown Instance = this; LogS = ((BaseUnityPlugin)this).Logger; _yamlIn = ((BuilderSkeleton<DeserializerBuilder>)new DeserializerBuilder()).WithNamingConvention(CamelCaseNamingConvention.Instance).IgnoreUnmatchedProperties().Build(); _yamlOut = ((BuilderSkeleton<SerializerBuilder>)new SerializerBuilder()).WithNamingConvention(CamelCaseNamingConvention.Instance).ConfigureDefaultValuesHandling((DefaultValuesHandling)2).Build(); EnsureFoldersAndFiles(); LoadSettings(); LoadAdmins(); LoadAllowedMods(); LoadRegistrations(); LoadViolations(); LoadMetrics(); StartWatchers(); _harmony = new Harmony("com.taeguk.valheim.serverguard"); _harmony.PatchAll(); LogS.LogInfo((object)("[ServerGuard] Loaded (v1.3.0). Enforcement: " + (_settings.Enforce ? "ON" : "LOG-ONLY") + ". RequireCompanion: " + (_settings.RequireCompanion ? "ON" : "OFF") + ". RequireHmac: " + (_settings.RequireHmac ? "ON" : "OFF") + ". AllowUnlisted: " + (_settings.AllowUnlisted ? "ON" : "OFF") + ". " + $"Required: {_requiredMods.Count}, Allowed: {_allowedMods.Count}, Banned: {_bannedMods.Count}. " + "Metrics: " + (_settings.EnableMetrics ? "ON" : "OFF"))); if (_settings.RequireHmac && !string.IsNullOrEmpty(_settings.SharedSecret)) { LogS.LogInfo((object)("[ServerGuard] sharedSecret in use (copy to every client.yaml): " + _settings.SharedSecret)); } if (!string.IsNullOrWhiteSpace(_settings.discordWebhookUrl)) { try { ManualLogSource logS = LogS; string text = ((logS != null) ? logS.SourceName : null) ?? "Valheim ServerGuard"; Logger.Listeners.Add((ILogListener)(object)(_discordListener = new DiscordLogListener(_settings.discordWebhookUrl, "[ServerGuard]", text))); LogS.LogInfo((object)("[ServerGuard] Discord logging enabled for source '" + text + "'.")); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Failed to enable Discord logging: " + ex.Message)); } } } private void OnDestroy() { try { Harmony harmony = _harmony; if (harmony != null) { harmony.UnpatchSelf(); } } catch (Exception ex) { ManualLogSource logS = LogS; if (logS != null) { logS.LogWarning((object)("[ServerGuard] UnpatchSelf failed: " + ex.Message)); } } try { if (_discordListener != null) { try { Logger.Listeners.Remove((ILogListener)(object)_discordListener); } catch (Exception ex2) { ManualLogSource logS2 = LogS; if (logS2 != null) { logS2.LogWarning((object)("[ServerGuard] Removing Discord listener failed: " + ex2.Message)); } } try { _discordListener.Dispose(); } catch (Exception ex3) { ManualLogSource logS3 = LogS; if (logS3 != null) { logS3.LogWarning((object)("[ServerGuard] Disposing Discord listener failed: " + ex3.Message)); } } _discordListener = null; } } catch (Exception ex4) { ManualLogSource logS4 = LogS; if (logS4 != null) { logS4.LogWarning((object)("[ServerGuard] Discord listener cleanup failed: " + ex4.Message)); } } try { StopWatchers(); } catch (Exception ex5) { ManualLogSource logS5 = LogS; if (logS5 != null) { logS5.LogWarning((object)("[ServerGuard] StopWatchers failed: " + ex5.Message)); } } try { SaveAll(); } catch (Exception ex6) { ManualLogSource logS6 = LogS; if (logS6 != null) { logS6.LogWarning((object)("[ServerGuard] SaveAll failed: " + ex6.Message)); } } } private async Task SendDiscordNow(string text) { try { string text2 = _settings?.discordWebhookUrl; if (string.IsNullOrWhiteSpace(text2)) { return; } HttpClient http = new HttpClient(); try { string text3 = JsonConvert.SerializeObject((object)new { content = text }); StringContent req = new StringContent(text3, Encoding.UTF8, "application/json"); try { await http.PostAsync(text2, (HttpContent)(object)req); } finally { ((IDisposable)req)?.Dispose(); } } finally { ((IDisposable)http)?.Dispose(); } } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] SendDiscordNow failed: " + ex.Message)); } } private void EnsureFoldersAndFiles() { Directory.CreateDirectory(RootDir); Directory.CreateDirectory(ConfDir); if (!File.Exists(SettingsYaml)) { Settings settings = new Settings { SharedSecret = GenerateSharedSecret() }; StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("# ServerGuard settings (v1.3.0)"); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Client-attestation handshake:"); stringBuilder.AppendLine("# requireCompanion - if true, peers without the ServerGuard.Client plugin are kicked."); stringBuilder.AppendLine("# companionTimeoutSeconds - how long to wait for the manifest before declaring 'no companion'."); stringBuilder.AppendLine("# requireHmac - if true, manifests must carry a valid HMAC signature."); stringBuilder.AppendLine("# sharedSecret - secret string. Must match every client's client.yaml `sharedSecret`."); stringBuilder.AppendLine("# Generate something long and random (e.g. `openssl rand -hex 32`)."); stringBuilder.AppendLine("# allowUnlisted - if true, mods absent from allowed_mods.yaml are tolerated."); stringBuilder.AppendLine("# Default false = strict allowlist."); stringBuilder.AppendLine("# maxClockSkewSeconds - reject manifests whose timestamp is more than this off from server time."); stringBuilder.AppendLine("# logPeerManifest - if true, log every connecting peer's full manifest (verbose)."); stringBuilder.AppendLine("# Useful for harvesting plugin GUIDs to populate allowed_mods.yaml."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Identity / character limits:"); stringBuilder.AppendLine("# characterLimit - max distinct character names a SteamID may use on this server."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Discord:"); stringBuilder.AppendLine("# discordWebhookUrl - full Discord Webhook URL for live event forwarding."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine(_yamlOut.Serialize((object)settings)); File.WriteAllText(SettingsYaml, stringBuilder.ToString()); } if (!File.Exists(AdminsYaml)) { AdminsDoc adminsDoc = new AdminsDoc { admins = new List<string>() }; StringBuilder stringBuilder2 = new StringBuilder(); stringBuilder2.AppendLine("# Admin whitelist: one SteamID per entry"); stringBuilder2.AppendLine(_yamlOut.Serialize((object)adminsDoc)); File.WriteAllText(AdminsYaml, stringBuilder2.ToString()); } TryRenameLegacy(LegacyIgnoreModsYaml, LegacyIgnoreModsYaml + ".legacy"); TryRenameLegacy(LegacyModPatternsYaml, LegacyModPatternsYaml + ".legacy"); if (!File.Exists(AllowedModsYaml)) { StringBuilder stringBuilder3 = new StringBuilder(); stringBuilder3.AppendLine("# ServerGuard allowed_mods.yaml (v1.3+)"); stringBuilder3.AppendLine("#"); stringBuilder3.AppendLine("# Each entry references a mod by its BepInEx plugin GUID (preferred) or display Name."); stringBuilder3.AppendLine("# Optional `|<sha256_hex>` suffix pins the DLL hash; mismatch -> kick."); stringBuilder3.AppendLine("#"); stringBuilder3.AppendLine("# required_mods: every connecting client MUST report all of these in its manifest."); stringBuilder3.AppendLine("# allowed_mods : extra mods the client may run beyond the required set."); stringBuilder3.AppendLine("# banned_mods : if any of these appear in the client manifest, the client is kicked."); stringBuilder3.AppendLine("#"); stringBuilder3.AppendLine("# To harvest GUIDs from a real client connection, set logPeerManifest: true in settings.yaml."); stringBuilder3.AppendLine("# The names below were bootstrapped from your modpack's BepInEx LogOutput.log; replace them"); stringBuilder3.AppendLine("# with GUIDs over time for stricter matching."); stringBuilder3.AppendLine(); stringBuilder3.AppendLine("required_mods:"); stringBuilder3.AppendLine(" - com.taeguk.valheim.serverguard.client # the ServerGuard companion plugin"); stringBuilder3.AppendLine(); stringBuilder3.AppendLine("allowed_mods:"); string[] array = new string[29] { "Armoire", "AzuAntiCheat", "FastLink", "Recycle_N_Reclaim", "BalrondShipyard", "ComfyQuickSlots", "Trader Overhaul", "Haldor Bounties", "Jotunn", "Offline Companions", "Newtonsoft.Json Detector", "YamlDotNet Detector", "Wandering Companions", "Better Networking", "SimpleMarket", "Quick Stack - Store - Sort - Trash - Restock", "PlanBuild", "ImpactfulSkills", "SlayerSkills", "DiscordConnectorClient", "Creature Level & Loot Control", "Groups", "Player Activity", "Protective Wards", "ValkyrieDeathMessages", "WackysDatabase", "More_World_Locations_AIO", "Zen.ModLib", "ZenBossStone" }; foreach (string text in array) { string text2 = ((text.IndexOfAny(new char[9] { ':', '|', '#', '&', '*', '!', '%', '@', '`' }) >= 0) ? ("\"" + text.Replace("\"", "\\\"") + "\"") : text); stringBuilder3.AppendLine(" - " + text2); } stringBuilder3.AppendLine(); stringBuilder3.AppendLine("banned_mods: []"); stringBuilder3.AppendLine(); File.WriteAllText(AllowedModsYaml, stringBuilder3.ToString()); } if (!File.Exists(RegistrationsYaml)) { RegistrationsDoc registrationsDoc = new RegistrationsDoc(); File.WriteAllText(RegistrationsYaml, _yamlOut.Serialize((object)registrationsDoc)); } if (!File.Exists(ViolationsYaml)) { ViolationsDoc violationsDoc = new ViolationsDoc(); File.WriteAllText(ViolationsYaml, _yamlOut.Serialize((object)violationsDoc)); } if (!File.Exists(MetricsYaml)) { DetectionMetrics detectionMetrics = new DetectionMetrics(); StringBuilder stringBuilder4 = new StringBuilder(); stringBuilder4.AppendLine("# ServerGuard Detection Metrics (auto-updated)"); stringBuilder4.AppendLine(_yamlOut.Serialize((object)detectionMetrics)); File.WriteAllText(MetricsYaml, stringBuilder4.ToString()); } } private static void TryRenameLegacy(string from, string to) { try { if (File.Exists(from)) { if (File.Exists(to)) { File.Delete(to); } File.Move(from, to); ManualLogSource logS = LogS; if (logS != null) { logS.LogWarning((object)("[ServerGuard] Renamed legacy config '" + Path.GetFileName(from) + "' -> '" + Path.GetFileName(to) + "'. The new client-attestation flow uses allowed_mods.yaml.")); } } } catch (Exception ex) { ManualLogSource logS2 = LogS; if (logS2 != null) { logS2.LogWarning((object)("[ServerGuard] Could not rename legacy file '" + from + "': " + ex.Message)); } } } private void LoadSettings() { try { _settings = _yamlIn.Deserialize<Settings>(File.ReadAllText(SettingsYaml)) ?? new Settings(); if (_settings.RequireHmac && string.IsNullOrWhiteSpace(_settings.SharedSecret)) { _settings.SharedSecret = GenerateSharedSecret(); try { PersistSharedSecret(_settings.SharedSecret); LogS.LogWarning((object)"[ServerGuard] sharedSecret was empty - generated a new one and wrote it back to settings.yaml. Copy this value into every client's client.yaml:"); LogS.LogWarning((object)("[ServerGuard] sharedSecret: " + _settings.SharedSecret)); } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to persist generated sharedSecret: " + ex.Message + ". Generated value (use this in client.yaml): " + _settings.SharedSecret)); } } LogS.LogInfo((object)"[ServerGuard] settings.yaml loaded"); } catch (Exception ex2) { LogS.LogError((object)("[ServerGuard] Failed to load settings.yaml: " + ex2.Message)); _settings = new Settings(); } } private void LoadAdmins() { try { string text = File.ReadAllText(AdminsYaml); AdminsDoc adminsDoc = _yamlIn.Deserialize<AdminsDoc>(text) ?? new AdminsDoc(); _admins = new HashSet<string>(from s in adminsDoc.admins ?? new List<string>() select s.Trim() into s where !string.IsNullOrWhiteSpace(s) select s, StringComparer.OrdinalIgnoreCase); LogS.LogInfo((object)$"[ServerGuard] admins.yaml loaded ({_admins.Count} admins)"); } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load admins.yaml: " + ex.Message)); _admins = new HashSet<string>(StringComparer.OrdinalIgnoreCase); } } private void LoadAllowedMods() { try { string text = File.ReadAllText(AllowedModsYaml); AllowedModsDoc allowedModsDoc = _yamlIn.Deserialize<AllowedModsDoc>(text) ?? new AllowedModsDoc(); _requiredMods = ParseAllowedList(allowedModsDoc.required_mods); _allowedMods = ParseAllowedList(allowedModsDoc.allowed_mods); _bannedMods = ParseAllowedList(allowedModsDoc.banned_mods); LogS.LogInfo((object)$"[ServerGuard] allowed_mods.yaml loaded (required={_requiredMods.Count}, allowed={_allowedMods.Count}, banned={_bannedMods.Count})"); } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load allowed_mods.yaml: " + ex.Message)); _requiredMods = new List<AllowedModEntry>(); _allowedMods = new List<AllowedModEntry>(); _bannedMods = new List<AllowedModEntry>(); } } private static List<AllowedModEntry> ParseAllowedList(List<string> raw) { List<AllowedModEntry> list = new List<AllowedModEntry>(); if (raw == null) { return list; } foreach (string item in raw) { if (!string.IsNullOrWhiteSpace(item)) { string[] array = item.Split(new char[1] { '|' }); string text = array[0].Trim(); string sha = ((array.Length > 1) ? array[1].Trim().ToLowerInvariant() : null); if (!string.IsNullOrEmpty(text)) { list.Add(new AllowedModEntry { Key = text.ToLowerInvariant(), Sha256 = sha }); } } } return list; } private void LoadRegistrations() { try { string text = File.ReadAllText(RegistrationsYaml); RegistrationsDoc registrationsDoc = _yamlIn.Deserialize<RegistrationsDoc>(text); if (registrationsDoc?.registrations != null && registrationsDoc.registrations.Count > 0) { _registrations = registrationsDoc.registrations; } else { Dictionary<string, Dictionary<string, string>> dictionary = _yamlIn.Deserialize<Dictionary<string, Dictionary<string, string>>>(text); if (dictionary != null && dictionary.TryGetValue("registrations", out var value) && value != null) { Dictionary<string, List<string>> dictionary2 = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); foreach (KeyValuePair<string, string> item in value) { if (!string.IsNullOrWhiteSpace(item.Key) && !string.IsNullOrWhiteSpace(item.Value)) { dictionary2[item.Key] = new List<string> { item.Value.Trim() }; } } _registrations = dictionary2; SaveRegistrations(); } else { _registrations = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); } } LogS.LogInfo((object)$"[ServerGuard] registrations.yaml loaded ({_registrations.Count} SteamIDs)"); } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load registrations.yaml: " + ex.Message)); _registrations = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); } } private void LoadViolations() { try { string text = File.ReadAllText(ViolationsYaml); ViolationsDoc violationsDoc = _yamlIn.Deserialize<ViolationsDoc>(text) ?? new ViolationsDoc(); _violations = violationsDoc.violations ?? new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase); LogS.LogInfo((object)$"[ServerGuard] violations.yaml loaded ({_violations.Count} players)"); } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load violations.yaml: " + ex.Message)); _violations = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase); } } private void LoadMetrics() { try { string text = File.ReadAllText(MetricsYaml); _metrics = _yamlIn.Deserialize<DetectionMetrics>(text) ?? new DetectionMetrics(); _metrics.last_updated = DateTime.UtcNow; LogS.LogInfo((object)$"[ServerGuard] metrics.yaml loaded (Checked: {_metrics.total_players_checked}, Detected: {_metrics.total_mods_detected})"); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Failed to load metrics.yaml: " + ex.Message)); _metrics = new DetectionMetrics(); } } private void SaveRegistrations() { RegistrationsDoc registrationsDoc = new RegistrationsDoc { registrations = _registrations }; File.WriteAllText(RegistrationsYaml, _yamlOut.Serialize((object)registrationsDoc)); } private void SaveViolations() { ViolationsDoc violationsDoc = new ViolationsDoc { violations = _violations }; File.WriteAllText(ViolationsYaml, _yamlOut.Serialize((object)violationsDoc)); } private void SaveMetrics() { try { if (_settings.EnableMetrics) { _metrics.last_updated = DateTime.UtcNow; DetectionMetrics metrics = _metrics; File.WriteAllText(MetricsYaml, _yamlOut.Serialize((object)metrics)); } } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Failed to save metrics.yaml: " + ex.Message)); } } private void SaveAll() { SaveRegistrations(); SaveViolations(); SaveMetrics(); } private static string GetPeerPlatformId(object znetPeer) { try { FieldInfo field = znetPeer.GetType().GetField("m_platformUserID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field != null && TryNormalizeSteamId(field.GetValue(znetPeer), out var normalized) && IsValidSteamId(normalized)) { return normalized; } MethodInfo method = znetPeer.GetType().GetMethod("GetPlatformUserID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (method != null && TryNormalizeSteamId(method.Invoke(znetPeer, null), out var normalized2) && IsValidSteamId(normalized2)) { return normalized2; } object obj = znetPeer.GetType().GetField("m_socket", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(znetPeer); if (obj != null) { FieldInfo field2 = obj.GetType().GetField("m_peerID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field2 != null && TryNormalizeSteamId(field2.GetValue(obj), out var normalized3) && IsValidSteamId(normalized3)) { return normalized3; } MethodInfo method2 = obj.GetType().GetMethod("GetPeerID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (method2 != null && TryNormalizeSteamId(method2.Invoke(obj, null), out var normalized4) && IsValidSteamId(normalized4)) { return normalized4; } MethodInfo method3 = obj.GetType().GetMethod("GetSteamID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (method3 != null && TryNormalizeSteamId(method3.Invoke(obj, null), out var normalized5) && IsValidSteamId(normalized5)) { return normalized5; } MethodInfo method4 = obj.GetType().GetMethod("GetSteamID64", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (method4 != null && TryNormalizeSteamId(method4.Invoke(obj, null), out var normalized6) && IsValidSteamId(normalized6)) { return normalized6; } PropertyInfo property = obj.GetType().GetProperty("SteamID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (property != null && TryNormalizeSteamId(property.GetValue(obj, null), out var normalized7) && IsValidSteamId(normalized7)) { return normalized7; } FieldInfo field3 = obj.GetType().GetField("m_SteamID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field3 != null && TryNormalizeSteamId(field3.GetValue(obj), out var normalized8) && IsValidSteamId(normalized8)) { return normalized8; } FieldInfo field4 = obj.GetType().GetField("m_steamID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field4 != null && TryNormalizeSteamId(field4.GetValue(obj), out var normalized9) && IsValidSteamId(normalized9)) { return normalized9; } MethodInfo method5 = obj.GetType().GetMethod("GetHostName", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (method5 != null) { string text = ExtractSteamIdFromString(Convert.ToString(method5.Invoke(obj, null))); if (IsValidSteamId(text)) { return text; } } string text2 = ExtractSteamIdFromString(obj.ToString()); if (IsValidSteamId(text2)) { return text2; } } string text3 = ExtractSteamIdFromString(znetPeer.ToString()); if (IsValidSteamId(text3)) { return text3; } } catch { } return "UNKNOWN"; } private static bool TryNormalizeSteamId(object raw, out string normalized) { normalized = null; if (raw == null) { return false; } if (!(raw is ulong num)) { if (!(raw is long num2)) { if (raw is string text && IsValidSteamId(text)) { normalized = text; return true; } } else if (num2 > 0) { normalized = num2.ToString(); return true; } } else if (num != 0L) { normalized = num.ToString(); return true; } Type type = raw.GetType(); FieldInfo field = type.GetField("m_SteamID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (field != null) { object value = field.GetValue(raw); if (value != null && ulong.TryParse(value.ToString(), out var result) && result != 0L) { normalized = result.ToString(); return true; } } PropertyInfo property = type.GetProperty("Value", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (property != null) { object value2 = property.GetValue(raw, null); if (value2 != null && ulong.TryParse(value2.ToString(), out var result2) && result2 != 0L) { normalized = result2.ToString(); return true; } } string text2 = ExtractSteamIdFromString(raw.ToString()); if (IsValidSteamId(text2)) { normalized = text2; return true; } return false; } private static string ExtractSteamIdFromString(string s) { if (string.IsNullOrEmpty(s)) { return null; } int num = 0; int startIndex = -1; for (int i = 0; i < s.Length; i++) { if (char.IsDigit(s[i])) { if (num == 0) { startIndex = i; } num++; if (num == 17) { return s.Substring(startIndex, 17); } } else { num = 0; startIndex = -1; } } return null; } private static bool IsValidSteamId(string candidate) { if (string.IsNullOrWhiteSpace(candidate)) { return false; } if (candidate.Length != 17) { return false; } for (int i = 0; i < candidate.Length; i++) { if (candidate[i] < '0' || candidate[i] > '9') { return false; } } return candidate != "00000000000000000"; } private static string GetPeerPlayerName(object znetPeer) { return znetPeer.GetType().GetField("m_playerName", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(znetPeer)?.ToString() ?? "Unknown"; } private static string GetPeerCharacterId(object znetPeer) { return (znetPeer.GetType().GetField("m_characterID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(znetPeer))?.ToString() ?? "CHAR_UNKNOWN"; } private bool IsAdmin(string platformId) { return _admins.Contains(platformId); } private void RecordMetricDetection(string modToken, string detectionMethod) { if (_settings.EnableMetrics && _metrics != null) { _metrics.total_mods_detected++; switch (detectionMethod) { case "RPC": _metrics.phase1_rpc_detections++; break; case "Assembly": _metrics.phase2_assembly_detections++; break; case "Version": _metrics.version_keyword_detections++; break; } if (!_metrics.top_detected_mods.ContainsKey(modToken)) { _metrics.top_detected_mods[modToken] = 0L; } _metrics.top_detected_mods[modToken]++; SaveMetrics(); } } private void AddViolation(string platformId, string rule) { if (!_violations.TryGetValue(platformId, out var value)) { value = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); _violations[platformId] = value; } value.TryGetValue(rule, out var value2); value[rule] = value2 + 1; SaveViolations(); if (_settings.EnableMetrics) { _metrics.violations_issued++; SaveMetrics(); } LogS.LogWarning((object)$"[ServerGuard] {platformId} violated {rule}. Count={value[rule]}/{_settings.ViolationThreshold}"); SendDiscordNow($":warning: Violation by {platformId} — **{rule}** ({value[rule]}/{_settings.ViolationThreshold})"); if (_settings.Enforce && value[rule] >= _settings.ViolationThreshold) { TryBan(platformId, _settings.BanReason); if (_settings.EnableMetrics) { _metrics.players_banned++; SaveMetrics(); } SendDiscordNow(":no_entry: Auto-banned " + platformId + ". Reason: " + _settings.BanReason); } } private void TryKick(object znetPeer, string reason) { try { ZNetPeer val = (ZNetPeer)((znetPeer is ZNetPeer) ? znetPeer : null); if (val == null || val == null || (Object)(object)ZNet.instance == (Object)null) { return; } string peerPlatformId = GetPeerPlatformId(val); try { ZRpc rpc = val.m_rpc; if (rpc != null) { rpc.Invoke("Error", new object[1] { 3 }); } } catch { } try { ZNet.instance.Disconnect(val); LogS.LogWarning((object)("[ServerGuard] Disconnected " + peerPlatformId + ". Reason: " + reason)); SendDiscordNow(":boot: Disconnected " + peerPlatformId + ". Reason: " + reason); return; } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] ZNet.Disconnect threw (" + ex.Message + "); falling back to reflection.")); } object obj2 = typeof(ZNet).GetProperty("instance", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(null); if (obj2 == null) { return; } MethodInfo method = obj2.GetType().GetMethod("Disconnect", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[1] { typeof(ZNetPeer) }, null); if (method != null) { method.Invoke(obj2, new object[1] { val }); LogS.LogWarning((object)("[ServerGuard] Disconnected " + peerPlatformId + " (reflection). Reason: " + reason)); SendDiscordNow(":boot: Disconnected " + peerPlatformId + ". Reason: " + reason); return; } MethodInfo method2 = obj2.GetType().GetMethod("InternalKick", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[1] { typeof(ZNetPeer) }, null); if (method2 != null) { method2.Invoke(obj2, new object[1] { val }); LogS.LogWarning((object)("[ServerGuard] InternalKick'd " + peerPlatformId + ". Reason: " + reason)); SendDiscordNow(":boot: Kicked " + peerPlatformId + ". Reason: " + reason); } } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] Kick failed: {arg}"); } } private void TryBan(string platformId, string reason) { try { object obj = typeof(ZNet).GetProperty("instance", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(null); if (obj != null) { MethodInfo method = obj.GetType().GetMethod("Ban", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[1] { typeof(string) }, null); if (method != null) { method.Invoke(obj, new object[1] { platformId }); LogS.LogWarning((object)("[ServerGuard] Auto-banned " + platformId + ". Reason: " + reason)); SendDiscordNow(":no_entry: Auto-banned " + platformId + ". Reason: " + reason); } } } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] Ban failed: {arg}"); } } private static ZNetPeer ResolvePeerFromRpc(ZNet znet, ZRpc rpc) { //IL_0054: Unknown result type (might be due to invalid IL or missing references) //IL_005a: Expected O, but got Unknown //IL_00dc: Unknown result type (might be due to invalid IL or missing references) //IL_00e2: Expected O, but got Unknown if ((Object)(object)znet == (Object)null || rpc == null) { return null; } MethodInfo method = typeof(ZNet).GetMethod("GetPeer", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[1] { typeof(ZRpc) }, null); if (method != null) { return (ZNetPeer)method.Invoke(znet, new object[1] { rpc }); } MethodInfo method2 = ((object)rpc).GetType().GetMethod("GetUID", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (method2 != null && method2.Invoke(rpc, null) is long num) { MethodInfo method3 = typeof(ZNet).GetMethod("GetPeer", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[1] { typeof(long) }, null); if (method3 != null) { return (ZNetPeer)method3.Invoke(znet, new object[1] { num }); } } LogS.LogWarning((object)"[ServerGuard] ResolvePeerFromRpc: unable to resolve peer from ZRpc."); return null; } private string GenerateChallenge() { byte[] array = new byte[24]; using (RandomNumberGenerator randomNumberGenerator = RandomNumberGenerator.Create()) { randomNumberGenerator.GetBytes(array); } return Convert.ToBase64String(array); } private static string GenerateSharedSecret() { byte[] array = new byte[32]; using (RandomNumberGenerator randomNumberGenerator = RandomNumberGenerator.Create()) { randomNumberGenerator.GetBytes(array); } return Convert.ToBase64String(array); } private static void PersistSharedSecret(string value) { List<string> list = (File.Exists(SettingsYaml) ? File.ReadAllLines(SettingsYaml).ToList() : new List<string>()); Regex regex = new Regex("^\\s*sharedSecret\\s*:.*$", RegexOptions.IgnoreCase); bool flag = false; for (int i = 0; i < list.Count; i++) { if (regex.IsMatch(list[i])) { list[i] = "sharedSecret: '" + value + "'"; flag = true; break; } } if (!flag) { list.Add("sharedSecret: '" + value + "'"); } File.WriteAllLines(SettingsYaml, list); } private void RegisterPending(ZNetPeer peer, string steamId, string challenge) { lock (_pendingLock) { _pending[peer.m_uid] = new PendingAttestation { Challenge = challenge, SentAt = DateTime.UtcNow, SteamId = steamId, Peer = peer }; } } [IteratorStateMachine(typeof(<AttestationTimeoutCoroutine>d__80))] public IEnumerator AttestationTimeoutCoroutine(ZNetPeer peer, string steamId) { //yield-return decompiler failed: Unexpected instruction in Iterator.Dispose() return new <AttestationTimeoutCoroutine>d__80(0) { <>4__this = this, peer = peer, steamId = steamId }; } public void OnManifestReceived(ZNetPeer peer, string json) { string text = "UNKNOWN"; try { text = GetPeerPlatformId(peer); PendingAttestation value; lock (_pendingLock) { if (!_pending.TryGetValue(peer.m_uid, out value) || value == null) { LogS.LogWarning((object)("[ServerGuard] Manifest from " + text + " arrived with no pending challenge (timed out or duplicate). Ignoring.")); return; } _pending.Remove(peer.m_uid); } ModManifest modManifest; try { modManifest = JsonConvert.DeserializeObject<ModManifest>(json); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Failed to parse manifest from " + text + ": " + ex.Message)); AddViolation(text, "HmacInvalid"); if (_settings.Enforce) { TryKick(peer, _settings.KickMessage + " (Malformed manifest)"); } return; } if (modManifest == null) { LogS.LogWarning((object)("[ServerGuard] Empty manifest from " + text + ".")); AddViolation(text, "HmacInvalid"); if (_settings.Enforce) { TryKick(peer, _settings.KickMessage + " (Empty manifest)"); } return; } if (!ModManifest.ConstantTimeEquals(modManifest.Challenge ?? "", value.Challenge ?? "")) { LogS.LogWarning((object)("[ServerGuard] Challenge mismatch from " + text + ".")); AddViolation(text, "ChallengeMismatch"); if (_settings.Enforce) { TryKick(peer, _settings.KickMessage + " (Challenge mismatch)"); } return; } long num = DateTimeOffset.UtcNow.ToUnixTimeSeconds(); if (Math.Abs(num - modManifest.TimestampUtc) > Math.Max(10, _settings.MaxClockSkewSeconds)) { LogS.LogWarning((object)$"[ServerGuard] Timestamp out of window for {text} (client={modManifest.TimestampUtc} server={num})."); AddViolation(text, "HmacInvalid"); if (_settings.Enforce) { TryKick(peer, _settings.KickMessage + " (Clock skew exceeds policy)"); } return; } if (_settings.RequireHmac) { if (string.IsNullOrEmpty(_settings.SharedSecret)) { LogS.LogError((object)("[ServerGuard] Cannot validate manifest from " + text + ": requireHmac=true but sharedSecret is empty on server.")); if (_settings.Enforce) { TryKick(peer, _settings.KickMessage + " (Server misconfiguration: missing sharedSecret)"); } return; } if (!ModManifest.ConstantTimeEquals(ModManifest.ComputeHmac(modManifest.CanonicalForHmac(), _settings.SharedSecret), modManifest.Hmac ?? "")) { LogS.LogWarning((object)("[ServerGuard] HMAC mismatch for " + text + ". Either bad sharedSecret on client, or tampered manifest.")); AddViolation(text, "HmacInvalid"); if (_settings.Enforce) { TryKick(peer, _settings.KickMessage + " (Invalid signature)"); } return; } } if (_settings.LogPeerManifest) { IEnumerable<string> values = (modManifest.Mods ?? new List<ModManifestEntry>()).Select((ModManifestEntry m) => " - " + m.Guid + "|" + m.Name + "|" + m.Version + "|" + m.Sha256); LogS.LogInfo((object)($"[ServerGuard] Manifest from {text} ({modManifest.Mods?.Count ?? 0} mods):\n" + string.Join("\n", values))); } PolicyVerdict policyVerdict = ValidateAgainstPolicy(modManifest); if (!policyVerdict.Allowed) { LogS.LogWarning((object)("[ServerGuard] " + text + " REJECTED: " + policyVerdict.Rule + " - " + policyVerdict.Reason)); SendDiscordNow(":no_entry_sign: Rejected " + text + " - " + policyVerdict.Rule + ": " + policyVerdict.Reason); AddViolation(text, policyVerdict.Rule); if (_settings.Enforce) { TryKick(peer, _settings.KickMessage + " (" + policyVerdict.Reason + ")"); } } else { LogS.LogInfo((object)$"[ServerGuard] {text} attested OK ({modManifest.Mods?.Count ?? 0} mods)."); } } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] OnManifestReceived error for {text}: {arg}"); } } private PolicyVerdict ValidateAgainstPolicy(ModManifest manifest) { List<ModManifestEntry> list = manifest.Mods ?? new List<ModManifestEntry>(); Dictionary<string, ModManifestEntry> dictionary = new Dictionary<string, ModManifestEntry>(StringComparer.OrdinalIgnoreCase); foreach (ModManifestEntry item in list) { if (!string.IsNullOrEmpty(item?.Guid)) { dictionary[item.Guid.ToLowerInvariant()] = item; } if (!string.IsNullOrEmpty(item?.Name)) { dictionary[item.Name.ToLowerInvariant()] = item; } } foreach (AllowedModEntry bannedMod in _bannedMods) { if (dictionary.TryGetValue(bannedMod.Key, out var value)) { PolicyVerdict result = default(PolicyVerdict); result.Allowed = false; result.Rule = "BannedMod"; result.Reason = "Disallowed mod present: " + (value.Name ?? value.Guid); return result; } } PolicyVerdict result2; foreach (AllowedModEntry requiredMod in _requiredMods) { if (!dictionary.TryGetValue(requiredMod.Key, out var value2)) { result2 = default(PolicyVerdict); result2.Allowed = false; result2.Rule = "RequiredModMissing"; result2.Reason = "Required mod missing: " + requiredMod.Key; return result2; } if (!string.IsNullOrEmpty(requiredMod.Sha256) && !string.Equals(requiredMod.Sha256, value2.Sha256 ?? "", StringComparison.OrdinalIgnoreCase)) { result2 = default(PolicyVerdict); result2.Allowed = false; result2.Rule = "DisallowedMod"; result2.Reason = "Required mod hash mismatch: " + requiredMod.Key; return result2; } } if (!_settings.AllowUnlisted) { Dictionary<string, AllowedModEntry> dictionary2 = new Dictionary<string, AllowedModEntry>(StringComparer.OrdinalIgnoreCase); foreach (AllowedModEntry requiredMod2 in _requiredMods) { dictionary2[requiredMod2.Key] = requiredMod2; } foreach (AllowedModEntry allowedMod in _allowedMods) { dictionary2[allowedMod.Key] = allowedMod; } foreach (ModManifestEntry item2 in list) { AllowedModEntry allowedModEntry = null; AllowedModEntry value4; if (!string.IsNullOrEmpty(item2.Guid) && dictionary2.TryGetValue(item2.Guid.ToLowerInvariant(), out var value3)) { allowedModEntry = value3; } else if (!string.IsNullOrEmpty(item2.Name) && dictionary2.TryGetValue(item2.Name.ToLowerInvariant(), out value4)) { allowedModEntry = value4; } if (allowedModEntry == null) { string text = ((!string.IsNullOrEmpty(item2.Guid)) ? item2.Guid : item2.Name); result2 = default(PolicyVerdict); result2.Allowed = false; result2.Rule = "DisallowedMod"; result2.Reason = "Unapproved mod: " + text; return result2; } if (!string.IsNullOrEmpty(allowedModEntry.Sha256) && !string.Equals(allowedModEntry.Sha256, item2.Sha256 ?? "", StringComparison.OrdinalIgnoreCase)) { result2 = default(PolicyVerdict); result2.Allowed = false; result2.Rule = "DisallowedMod"; result2.Reason = "Hash pin mismatch: " + (item2.Name ?? item2.Guid); return result2; } } } result2 = default(PolicyVerdict); result2.Allowed = true; return result2; } private void StartWatchers() { _watchSettings = MakeWatcher(SettingsYaml, delegate { LoadSettings(); }); _watchAdmins = MakeWatcher(AdminsYaml, delegate { LoadAdmins(); }); _watchAllowed = MakeWatcher(AllowedModsYaml, delegate { LoadAllowedMods(); }); } private void StopWatchers() { try { _watchSettings?.Dispose(); } catch { } try { _watchAdmins?.Dispose(); } catch { } try { _watchAllowed?.Dispose(); } catch { } } private FileSystemWatcher MakeWatcher(string filePath, Action reloadAction) { FileSystemWatcher fileSystemWatcher = new FileSystemWatcher(Path.GetDirectoryName(filePath), Path.GetFileName(filePath)); fileSystemWatcher.NotifyFilter = NotifyFilters.FileName | NotifyFilters.Size | NotifyFilters.LastWrite; fileSystemWatcher.Changed += delegate(object s, FileSystemEventArgs e) { DebouncedReload(e.FullPath, reloadAction); }; fileSystemWatcher.Created += delegate(object s, FileSystemEventArgs e) { DebouncedReload(e.FullPath, reloadAction); }; fileSystemWatcher.Renamed += delegate(object s, RenamedEventArgs e) { DebouncedReload(e.FullPath, reloadAction); }; fileSystemWatcher.EnableRaisingEvents = true; return fileSystemWatcher; } private void DebouncedReload(string path, Action reloadAction, int debounceMs = 200) { DateTime utcNow = DateTime.UtcNow; if (_lastSeenWrite.TryGetValue(path, out var value) && (utcNow - value).TotalMilliseconds < (double)debounceMs) { return; } _lastSeenWrite[path] = utcNow; Timer t = new Timer(debounceMs); t.AutoReset = false; t.Elapsed += delegate { try { reloadAction(); LogS.LogInfo((object)("[ServerGuard] Reloaded: " + Path.GetFileName(path))); } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Reload failed for " + Path.GetFileName(path) + ": " + ex.Message)); } finally { t.Dispose(); } }; t.Start(); } } namespace ValheimServerGuard.Shared { [Serializable] public class ModManifestEntry { public string Guid; public string Name; public string Version; public string Sha256; } [Serializable] public class ModManifest { public string SchemaVersion = "1"; public string Challenge; public long TimestampUtc; public List<ModManifestEntry> Mods = new List<ModManifestEntry>(); public string Hmac; public string CanonicalForHmac() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append(SchemaVersion ?? "").Append('|'); stringBuilder.Append(Challenge ?? "").Append('|'); stringBuilder.Append(TimestampUtc).Append('|'); List<ModManifestEntry> list = new List<ModManifestEntry>(Mods ?? new List<ModManifestEntry>()); list.Sort(delegate(ModManifestEntry a, ModManifestEntry b) { string strA = ((!string.IsNullOrEmpty(a?.Guid)) ? a.Guid : (a?.Name ?? "")); string strB = ((!string.IsNullOrEmpty(b?.Guid)) ? b.Guid : (b?.Name ?? "")); return string.CompareOrdinal(strA, strB); }); foreach (ModManifestEntry item in list) { stringBuilder.Append(item?.Guid ?? "").Append(':'); stringBuilder.Append(item?.Name ?? "").Append(':'); stringBuilder.Append(item?.Version ?? "").Append(':'); stringBuilder.Append(item?.Sha256 ?? "").Append(';'); } return stringBuilder.ToString(); } public static string ComputeHmac(string canonical, string secret) { if (string.IsNullOrEmpty(secret)) { return ""; } using HMACSHA256 hMACSHA = new HMACSHA256(Encoding.UTF8.GetBytes(secret)); return Convert.ToBase64String(hMACSHA.ComputeHash(Encoding.UTF8.GetBytes(canonical ?? ""))); } public static bool ConstantTimeEquals(string a, string b) { if (a == null || b == null) { return false; } if (a.Length != b.Length) { return false; } int num = 0; for (int i = 0; i < a.Length; i++) { num |= a[i] ^ b[i]; } return num == 0; } } }