using System; using System.Collections; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; 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+aec5ff4e1c5c57d0d11bef9eaeebb37e9dabfb4d")] [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.4.0")] public class Plugin : BaseUnityPlugin { private class PingState { public bool FirstPosted; public List Samples = new List(); } private class SpeedState { public Vector3 LastPos; public bool HasLastPos; public float LastSampleTime; public int OverThresholdCount; } 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 discordWebhookUrlAdmin { get; set; } = ""; public bool DiscordVerboseMirror { get; set; } public string discordChannelLink { get; set; } = ""; public bool DailySummaryEnabled { get; set; } = true; public int DailySummaryHourUtc { get; set; } public string DailySummaryChannel { get; set; } = "admin"; [YamlMember(Alias = "countAsViolation", ApplyNamingConventions = false)] public Dictionary CountAsViolation { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["CompanionMissing"] = false, ["HmacInvalid"] = false, ["ChallengeMismatch"] = false, ["RequiredModMissing"] = false, ["DisallowedMod"] = false, ["BannedMod"] = false, ["HashMismatch"] = false, ["CharacterNameLimitExceeded"] = true, ["DevcommandAttempt"] = true, ["SpeedHack"] = true, ["IllegalItem"] = false, ["StackOverflow"] = false, ["AnimationCancel"] = false, ["SkillOverflow"] = false }; public bool EnableDevcommandGate { get; set; } = true; public bool EnableSpeedCheck { get; set; } = true; public double SpeedCheckMaxMetersPerSecond { get; set; } = 15.0; public double SpeedCheckSampleSeconds { get; set; } = 1.0; public int SpeedCheckConsecutiveStrikes { get; set; } = 3; public double SpeedCheckTeleportToleranceMeters { get; set; } = 60.0; public bool EnableInventoryCheck { get; set; } = true; public bool InventoryCheckLogOnly { get; set; } = true; public double InventoryCheckStackTolerance { get; set; } = 1.0; public bool EnableAnimationCancelGate { get; set; } = true; public bool EnableSkillCap { get; set; } = true; public double SkillCapMaxLevel { get; set; } = 100.0; public double SkillCapTolerance { get; set; } = 5.0; public bool EnableDeathLog { get; set; } = true; public bool EnableBuildLog { get; set; } = true; public int BuildLogRetentionDays { get; set; } = 30; public bool EnableSelfTest { get; set; } = true; public bool SelfTestPostOnPass { get; set; } public bool EnablePingLog { get; set; } public int PingLogSampleSeconds { get; set; } = 5; public bool DiscordPublicMode { get; set; } = true; 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 admins { get; set; } = new List(); } private class AllowedModsDoc { [YamlMember(Alias = "required_mods", ApplyNamingConventions = false)] public List required_mods { get; set; } = new List(); [YamlMember(Alias = "allowed_mods", ApplyNamingConventions = false)] public List allowed_mods { get; set; } = new List(); [YamlMember(Alias = "banned_mods", ApplyNamingConventions = false)] public List banned_mods { get; set; } = new List(); } 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 top_detected_mods { get; set; } = new Dictionary(StringComparer.OrdinalIgnoreCase); public DateTime last_updated { get; set; } = DateTime.UtcNow; } private class RegistrationsDoc { public Dictionary> registrations { get; set; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); } private class ViolationsDoc { public Dictionary> violations { get; set; } = new Dictionary>(StringComparer.OrdinalIgnoreCase); } private enum DiscordChannel { Public, Admin, Both } private sealed class SelfTestResult { public string Name; public bool Pass; public string Detail; } [HarmonyPatch(typeof(Inventory), "AddItem", new Type[] { typeof(ItemData) })] public static class Patch_Inventory_AddItem { public static bool Prefix(Inventory __instance, ItemData item) { try { if ((Object)(object)Instance == (Object)null) { return true; } Settings settings = Instance._settings; if (settings == null || !settings.EnableInventoryCheck) { return true; } if ((Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer()) { return true; } if (item == null || item.m_shared == null) { return true; } List list = Instance.ValidateInventoryItem(item); if (list == null || list.Count == 0) { return true; } foreach (string item2 in list) { LogS.LogWarning((object)$"[ServerGuard] Inventory check: {item2} (logOnly={settings.InventoryCheckLogOnly})"); } string text = (list[0].StartsWith("unknown item") ? "IllegalItem" : "StackOverflow"); string text2 = list[0]; LogS.LogWarning((object)("[ServerGuard] " + text + " - " + text2)); if (!settings.InventoryCheckLogOnly) { return false; } } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Inventory check error: " + ex.Message)); } return true; } } [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); LogS.LogInfo((object)("[ServerGuard] Incoming connection: " + Instance.FormatPlayer(peerPlatformId))); peer.m_rpc.Register("ServerGuard_Manifest", (Action)delegate(ZRpc rpc, string json) { Instance.OnManifestReceived(peer, json); }); peer.m_rpc.Register("ServerGuard_DevcommandAttempt", (Action)delegate(ZRpc rpc, string command) { Instance.OnDevcommandAttemptReceived(peer, command); }); peer.m_rpc.Register("ServerGuard_AnimationCancelAttempt", (Action)delegate(ZRpc rpc, string source) { Instance.OnAnimationCancelReceived(peer, source); }); peer.m_rpc.Register("ServerGuard_SkillReport", (Action)delegate(ZRpc rpc, string payload) { Instance.OnSkillReportReceived(peer, payload); }); peer.m_rpc.Register("ServerGuard_PlayerDeath", (Action)delegate(ZRpc rpc, string payload) { Instance.OnPlayerDeathReceived(peer, payload); }); peer.m_rpc.Register("ServerGuard_BuildPlace", (Action)delegate(ZRpc rpc, string payload) { Instance.OnBuildPlaceReceived(peer, payload); }); peer.m_rpc.Register("ServerGuard_BuildDestroy", (Action)delegate(ZRpc rpc, string payload) { Instance.OnBuildDestroyReceived(peer, payload); }); peer.m_rpc.Register("ServerGuard_AdminCommand", (Action)delegate(ZRpc rpc, string command) { Instance.OnAdminCommandReceived(peer, command); }); if (Instance.IsAdmin(peerPlatformId)) { LogS.LogInfo((object)("[ServerGuard] " + Instance.FormatPlayer(peerPlatformId) + " is admin - skipping attestation challenge.")); if (Instance._settings.EnableMetrics) { Instance._metrics.admin_bypasses++; Instance.SaveMetrics(); } Instance.PostPlayerEvent(":shield:", peerPlatformId, "joined as admin"); return; } if (Instance._settings.EnableMetrics) { Instance._metrics.total_players_checked++; Instance.SaveMetrics(); } 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), "Disconnect")] public static class Patch_Disconnect { public static void Prefix(ZNetPeer peer) { try { if (peer == null || !Object.op_Implicit((Object)(object)ZNet.instance) || !ZNet.instance.IsServer() || (Object)(object)Instance == (Object)null) { return; } bool flag; lock (Instance._suppressLogoutFor) { flag = Instance._suppressLogoutFor.Remove(peer.m_uid); } if (!flag) { lock (Instance._pendingLock) { Instance._pending.Remove(peer.m_uid); } Instance._speedState.Remove(peer.m_uid); Instance._skillOverflowState.Remove(peer.m_uid); string peerPlatformId = GetPeerPlatformId(peer); if (!string.IsNullOrWhiteSpace(peerPlatformId)) { string text = Instance.FormatPlayer(peerPlatformId); LogS.LogInfo((object)("[ServerGuard] " + text + " left the server.")); Instance.PostPlayerEvent(":wave:", peerPlatformId, "left"); Instance.FlushPingOnDisconnect(peer.m_uid, peerPlatformId); } } } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Disconnect hook error: " + ex.Message)); } } } [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(); 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 {Instance.FormatPlayer(peerPlatformId)} -> '{charName}'"); return; } Instance.AddViolation(peerPlatformId, "CharacterNameLimitExceeded", charName); if (Instance._settings.Enforce) { Instance.PostPlayerEvent(":door:", peerPlatformId, "was kicked", FriendlyReason("CharacterNameLimitExceeded")); 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}", Instance.FormatPlayer(peerPlatformId), num, charName, string.Join(", ", value))); } } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] RPC_PeerInfo error: {arg}"); } } } private class SkillOverflowState { public Dictionary LastReportedLevel = new Dictionary(StringComparer.OrdinalIgnoreCase); } private sealed class LastHitBox { public ZDOID Attacker; public string AttackerKind; public string AttackerName; public DateTime At; } [HarmonyPatch(typeof(WearNTear), "Damage")] public static class Patch_WearNTear_Damage_Track { public static void Prefix(WearNTear __instance, HitData hit) { //IL_00d3: Unknown result type (might be due to invalid IL or missing references) //IL_00d4: Unknown result type (might be due to invalid IL or missing references) //IL_007d: Unknown result type (might be due to invalid IL or missing references) //IL_0074: Unknown result type (might be due to invalid IL or missing references) //IL_0082: Unknown result type (might be due to invalid IL or missing references) try { if ((Object)(object)Instance == (Object)null || Instance._settings == null || !Instance._settings.EnableBuildLog || (Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer() || (Object)(object)__instance == (Object)null || hit == null) { return; } ZDOID attacker = (ZDOID)(((??)GetHitAttacker(hit)) ?? ZDOID.None); string attackerKind = ""; string attackerName = ""; try { Character attacker2 = hit.GetAttacker(); if ((Object)(object)attacker2 != (Object)null) { if (attacker2 is Player) { attackerKind = "player"; attackerName = GetCharacterDisplayName(attacker2); } else { attackerKind = "creature"; attackerName = GetCharacterDisplayName(attacker2); } } } catch { } LastHitBox value = new LastHitBox { Attacker = attacker, AttackerKind = attackerKind, AttackerName = attackerName, At = DateTime.UtcNow }; Instance._lastHitOnPiece.Remove(__instance); Instance._lastHitOnPiece.Add(__instance, value); } catch { } } } [HarmonyPatch(typeof(WearNTear), "Destroy")] public static class Patch_WearNTear_Destroy_Log { public static void Prefix(WearNTear __instance) { //IL_00ab: Unknown result type (might be due to invalid IL or missing references) //IL_00b0: Unknown result type (might be due to invalid IL or missing references) //IL_01d6: Unknown result type (might be due to invalid IL or missing references) //IL_00f1: Unknown result type (might be due to invalid IL or missing references) //IL_00f6: Unknown result type (might be due to invalid IL or missing references) //IL_0132: Unknown result type (might be due to invalid IL or missing references) //IL_0139: Unknown result type (might be due to invalid IL or missing references) //IL_00a2: Unknown result type (might be due to invalid IL or missing references) //IL_00a7: Unknown result type (might be due to invalid IL or missing references) try { if ((Object)(object)Instance == (Object)null || Instance._settings == null || !Instance._settings.EnableBuildLog || (Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer() || (Object)(object)__instance == (Object)null) { return; } GameObject gameObject = ((Component)__instance).gameObject; string text = ((gameObject != null) ? ((Object)gameObject).name : null) ?? "unknown"; int num = text.IndexOf("(Clone)", StringComparison.Ordinal); if (num > 0) { text = text.Substring(0, num).Trim(); } Vector3 pos; try { pos = ((Component)__instance).transform.position; } catch { pos = Vector3.zero; } string text2 = ""; string text3 = ""; if (Instance._lastHitOnPiece.TryGetValue(__instance, out var value) && value != null) { Instance._lastHitOnPiece.Remove(__instance); if (value.Attacker != ZDOID.None) { List peers = ZNet.instance.GetPeers(); if (peers != null) { foreach (ZNetPeer item in peers) { if (item != null && item.m_characterID == value.Attacker) { text2 = GetPeerPlatformId(item); if (Instance._registrations != null && Instance._registrations.TryGetValue(text2, out var value2) && value2 != null && value2.Count > 0) { text3 = value2[0]; } break; } } } } if (string.IsNullOrEmpty(text2) && string.IsNullOrEmpty(text3) && !string.IsNullOrEmpty(value.AttackerName)) { text3 = value.AttackerName; } } Instance.LogBuildEvent("destroy", text2, text3, text, pos); } catch (Exception ex) { ManualLogSource logS = LogS; if (logS != null) { logS.LogWarning((object)("[ServerGuard] WearNTear.Destroy hook error: " + ex.Message)); } } } } private struct PolicyVerdict { public bool Allowed; public string Rule; public string Reason; public string Detail; } 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 _buffer = new Queue(); 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 list = null; lock (_buffer) { if (_buffer.Count == 0) { return; } list = new List(_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 d__134 : IEnumerator, IDisposable, IEnumerator { private int <>1__state; private object <>2__current; public Plugin <>4__this; public ZNetPeer peer; public string steamId; private int 5__2; object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } [DebuggerHidden] public d__134(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; 5__2 = Mathf.Max(1, plugin._settings.CompanionTimeoutSeconds); <>2__current = (object)new WaitForSeconds((float)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); } string arg = plugin.FormatPlayer(steamId); LogS.LogWarning((object)$"[ServerGuard] {arg} did not deliver a manifest within {5__2}s. Treating as no-companion."); if (plugin._settings.RequireCompanion) { plugin.AddViolation(steamId, "CompanionMissing"); if (plugin._settings.Enforce) { plugin.PostPlayerEvent(":door:", steamId, "was kicked", FriendlyReason("CompanionMissing")); 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(); } } [CompilerGenerated] private sealed class d__146 : IEnumerator, IDisposable, IEnumerator { private int <>1__state; private object <>2__current; public Plugin <>4__this; object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } [DebuggerHidden] public d__146(int <>1__state) { this.<>1__state = <>1__state; } [DebuggerHidden] void IDisposable.Dispose() { <>1__state = -2; } private bool MoveNext() { //IL_002f: Unknown result type (might be due to invalid IL or missing references) //IL_0039: Expected O, but got Unknown //IL_0075: Unknown result type (might be due to invalid IL or missing references) //IL_007f: Expected O, but got Unknown int num = <>1__state; Plugin plugin = <>4__this; switch (num) { default: return false; case 0: <>1__state = -1; <>2__current = (object)new WaitForSeconds(300f); <>1__state = 1; return true; case 1: <>1__state = -1; break; case 2: <>1__state = -1; break; } try { plugin.PruneOldBuildLogs(); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] PruneOldBuildLogs failed: " + ex.Message)); } <>2__current = (object)new WaitForSeconds(3600f); <>1__state = 2; return true; } bool IEnumerator.MoveNext() { //ILSpy generated this explicit interface implementation from .override directive in MoveNext return this.MoveNext(); } [DebuggerHidden] void IEnumerator.Reset() { throw new NotSupportedException(); } } [CompilerGenerated] private sealed class d__120 : IEnumerator, IDisposable, IEnumerator { private int <>1__state; private object <>2__current; public Plugin <>4__this; object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } [DebuggerHidden] public d__120(int <>1__state) { this.<>1__state = <>1__state; } [DebuggerHidden] void IDisposable.Dispose() { <>1__state = -2; } private bool MoveNext() { //IL_0033: Unknown result type (might be due to invalid IL or missing references) //IL_003d: Expected O, but got Unknown //IL_008f: Unknown result type (might be due to invalid IL or missing references) //IL_0099: Expected O, but got Unknown int num = <>1__state; Plugin plugin = <>4__this; switch (num) { default: return false; case 0: { <>1__state = -1; float num2 = (float)plugin.SecondsUntilNextFire(); <>2__current = (object)new WaitForSeconds(num2); <>1__state = 1; return true; } case 1: <>1__state = -1; break; case 2: <>1__state = -1; break; } try { plugin.PostDailySummary(scheduled: true); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Daily summary post failed: " + ex.Message)); } float num3 = (float)plugin.SecondsUntilNextFire(); if (num3 <= 60f) { num3 = 86400f; } <>2__current = (object)new WaitForSeconds(num3); <>1__state = 2; return true; } bool IEnumerator.MoveNext() { //ILSpy generated this explicit interface implementation from .override directive in MoveNext return this.MoveNext(); } [DebuggerHidden] void IEnumerator.Reset() { throw new NotSupportedException(); } } [CompilerGenerated] private sealed class d__112 : IEnumerator, IDisposable, IEnumerator { private int <>1__state; private object <>2__current; public Plugin <>4__this; object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } [DebuggerHidden] public d__112(int <>1__state) { this.<>1__state = <>1__state; } [DebuggerHidden] void IDisposable.Dispose() { <>1__state = -2; } private bool MoveNext() { //IL_002f: Unknown result type (might be due to invalid IL or missing references) //IL_0039: Expected O, but got Unknown //IL_0065: Unknown result type (might be due to invalid IL or missing references) //IL_006f: Expected O, but got Unknown int num = <>1__state; Plugin plugin = <>4__this; switch (num) { default: return false; case 0: <>1__state = -1; <>2__current = (object)new WaitForSeconds(15f); <>1__state = 1; return true; case 1: <>1__state = -1; break; case 2: <>1__state = -1; try { if (plugin._settings != null && plugin._settings.EnablePingLog && !((Object)(object)ZNet.instance == (Object)null) && ZNet.instance.IsServer()) { plugin.TickPingLog(); } } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Ping log tick error: " + ex.Message)); } break; } int num2 = Math.Max(2, plugin._settings?.PingLogSampleSeconds ?? 5); <>2__current = (object)new WaitForSeconds((float)num2); <>1__state = 2; return true; } bool IEnumerator.MoveNext() { //ILSpy generated this explicit interface implementation from .override directive in MoveNext return this.MoveNext(); } [DebuggerHidden] void IEnumerator.Reset() { throw new NotSupportedException(); } } [CompilerGenerated] private sealed class d__116 : IEnumerator, IDisposable, IEnumerator { private int <>1__state; private object <>2__current; public Plugin <>4__this; object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } object IEnumerator.Current { [DebuggerHidden] get { return <>2__current; } } [DebuggerHidden] public d__116(int <>1__state) { this.<>1__state = <>1__state; } [DebuggerHidden] void IDisposable.Dispose() { <>1__state = -2; } private bool MoveNext() { //IL_002f: Unknown result type (might be due to invalid IL or missing references) //IL_0039: Expected O, but got Unknown //IL_0062: Unknown result type (might be due to invalid IL or missing references) //IL_006c: Expected O, but got Unknown int num = <>1__state; Plugin plugin = <>4__this; switch (num) { default: return false; case 0: <>1__state = -1; <>2__current = (object)new WaitForSeconds(5f); <>1__state = 1; return true; case 1: <>1__state = -1; break; case 2: <>1__state = -1; try { if (plugin._settings.EnableSpeedCheck && !((Object)(object)ZNet.instance == (Object)null) && ZNet.instance.IsServer()) { plugin.TickSpeedCheck(); } } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Speed check tick error: " + ex.Message)); } break; } float num2 = Mathf.Max(0.25f, (float)plugin._settings.SpeedCheckSampleSeconds); <>2__current = (object)new WaitForSeconds(num2); <>1__state = 2; return true; } 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 BuildLogDir = Path.Combine(RootDir, "build_log"); 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 _admins = new HashSet(StringComparer.OrdinalIgnoreCase); private DetectionMetrics _metrics; private List _requiredMods = new List(); private List _allowedMods = new List(); private List _bannedMods = new List(); private string _modsetFingerprintStrict = ""; private string _modsetFingerprintLoose = ""; private Dictionary _pending = new Dictionary(); private readonly object _pendingLock = new object(); private readonly HashSet _suppressLogoutFor = new HashSet(); private readonly Dictionary _pingState = new Dictionary(); private static FieldInfo _rpcPingField; private readonly Dictionary _speedState = new Dictionary(); private Dictionary> _registrations = new Dictionary>(StringComparer.OrdinalIgnoreCase); private Dictionary> _violations = new Dictionary>(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_HASH_MISMATCH = "HashMismatch"; private const string RULE_CHAR_NAME_LIMIT = "CharacterNameLimitExceeded"; private const string RULE_DEVCOMMAND_ATTEMPT = "DevcommandAttempt"; private const string RULE_SPEED_HACK = "SpeedHack"; private const string RULE_ILLEGAL_ITEM = "IllegalItem"; private const string RULE_STACK_OVERFLOW = "StackOverflow"; private const string RULE_ANIMATION_CANCEL = "AnimationCancel"; private const string RULE_SKILL_OVERFLOW = "SkillOverflow"; private static readonly string[] ALL_RULES = new string[14] { "CompanionMissing", "HmacInvalid", "ChallengeMismatch", "RequiredModMissing", "DisallowedMod", "BannedMod", "HashMismatch", "CharacterNameLimitExceeded", "DevcommandAttempt", "SpeedHack", "IllegalItem", "StackOverflow", "AnimationCancel", "SkillOverflow" }; private FileSystemWatcher _watchSettings; private FileSystemWatcher _watchAdmins; private FileSystemWatcher _watchAllowed; private readonly Dictionary _lastSeenWrite = new Dictionary(); private string _attachedAdminWebhookUrl = ""; private bool _bootCompleted; private bool _dailySummaryStarted; private readonly object _summaryLock = new object(); private DateTime _summarySince = DateTime.UtcNow; private int _summaryJoins; private int _summaryKicks; private int _summaryBans; private readonly Dictionary _summaryKickReasons = new Dictionary(StringComparer.OrdinalIgnoreCase); private int _summaryLeaves; private readonly Dictionary _skillOverflowState = new Dictionary(); private readonly ConditionalWeakTable _lastHitOnPiece = new ConditionalWeakTable(); private static FieldInfo _hitAttackerField; private const int AdminReplyMaxLines = 25; private const int BuildQueryDefaultDays = 7; private const int BuildQueryMaxResults = 20; 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)new DeserializerBuilder()).WithNamingConvention(CamelCaseNamingConvention.Instance).IgnoreUnmatchedProperties().Build(); _yamlOut = ((BuilderSkeleton)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.4.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)); } ReconfigureDiscordAndSummary(); ((MonoBehaviour)this).StartCoroutine(SpeedCheckLoop()); ((MonoBehaviour)this).StartCoroutine(BuildLogCleanupLoop()); ((MonoBehaviour)this).StartCoroutine(PingLogLoop()); if (_settings.EnableSelfTest) { try { List list = RunSelfTest(); LogS.LogInfo((object)FormatSelfTestReport(list)); if (list.Any((SelfTestResult r) => !r.Pass) || _settings.SelfTestPostOnPass) { PostAdminEvent(FormatSelfTestForDiscord(list)); } } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] Self-test failed to run: {arg}"); } } PostAdminEvent(":rocket: **ServerGuard online** v1.4.0 enforce=" + (_settings.Enforce ? "ON" : "off") + " requireHmac=" + (_settings.RequireHmac ? "ON" : "off") + " " + $"req/allow/ban={_requiredMods.Count}/{_allowedMods.Count}/{_bannedMods.Count} " + "modset=`" + ModsetFingerprint.Short(_modsetFingerprintLoose) + "`"); _bootCompleted = true; if (_settings.EnableSpeedCheck) { LogS.LogInfo((object)($"[ServerGuard] Speed check enabled threshold={_settings.SpeedCheckMaxMetersPerSecond:F1}m/s " + $"sample={_settings.SpeedCheckSampleSeconds:F1}s " + $"strikes={_settings.SpeedCheckConsecutiveStrikes} " + $"teleport-tol={_settings.SpeedCheckTeleportToleranceMeters:F1}m")); } } 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 PostToWebhook(string url, string text) { if (string.IsNullOrWhiteSpace(url) || string.IsNullOrWhiteSpace(text)) { return; } try { HttpClient http = new HttpClient(); try { string text2 = JsonConvert.SerializeObject((object)new { content = text }); StringContent req = new StringContent(text2, Encoding.UTF8, "application/json"); try { await http.PostAsync(url, (HttpContent)(object)req); } finally { ((IDisposable)req)?.Dispose(); } } finally { ((IDisposable)http)?.Dispose(); } } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] PostToWebhook failed: " + ex.Message)); } } private async Task SendDiscordNow(string text, DiscordChannel target = DiscordChannel.Public) { if (_settings == null) { return; } string pub = _settings.discordWebhookUrl; string adm = _settings.discordWebhookUrlAdmin; switch (target) { case DiscordChannel.Public: await PostToWebhook(pub, text); break; case DiscordChannel.Admin: await PostToWebhook(string.IsNullOrWhiteSpace(adm) ? pub : adm, text); break; case DiscordChannel.Both: await PostToWebhook(pub, text); if (!string.IsNullOrWhiteSpace(adm) && !string.Equals(adm, pub, StringComparison.Ordinal)) { await PostToWebhook(adm, text); } break; } } 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.4.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("# Devcommands gate (anti-cheat):"); stringBuilder.AppendLine("# enableDevcommandGate - if true, devcommand attempts reported by the companion"); stringBuilder.AppendLine("# plugin are logged + posted + counted. The companion"); stringBuilder.AppendLine("# ALWAYS blocks `devcommands` and forces"); stringBuilder.AppendLine("# Console.IsCheatsEnabled=false on multiplayer clients;"); stringBuilder.AppendLine("# this toggle only affects server-side accounting."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Movement-speed sanity check (anti-cheat):"); stringBuilder.AppendLine("# enableSpeedCheck - master toggle."); stringBuilder.AppendLine("# speedCheckMaxMetersPerSecond - horizontal speed cap. Vanilla sprint ~5 m/s,"); stringBuilder.AppendLine("# longship sail ~9-10 m/s. 15 m/s is a generous"); stringBuilder.AppendLine("# default; raise for modded mounts/skills."); stringBuilder.AppendLine("# speedCheckSampleSeconds - poll interval. Lower = faster detection,"); stringBuilder.AppendLine("# more sensitive to lag spikes. 1.0 is balanced."); stringBuilder.AppendLine("# speedCheckConsecutiveStrikes - over-threshold samples needed to fire SpeedHack."); stringBuilder.AppendLine("# speedCheckTeleportToleranceMeters - single-sample displacements larger than this"); stringBuilder.AppendLine("# are treated as legitimate teleports (portals,"); stringBuilder.AppendLine("# stones) and reset the strike counter rather"); stringBuilder.AppendLine("# than incrementing it."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Inventory item validation (anti-cheat):"); stringBuilder.AppendLine("# enableInventoryCheck - master toggle for Inventory.AddItem validation."); stringBuilder.AppendLine("# inventoryCheckLogOnly - if true (default), invalid items are logged but"); stringBuilder.AppendLine("# still added. Flip to false to actively block them."); stringBuilder.AppendLine("# Start in log-only mode to audit false positives,"); stringBuilder.AppendLine("# then tighten."); stringBuilder.AppendLine("# inventoryCheckStackTolerance - multiplier on each item's m_maxStackSize. 1.0 is"); stringBuilder.AppendLine("# strict; 2.0 allows up to 2x the vanilla cap for"); stringBuilder.AppendLine("# modpacks that legitimately raise limits."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Animation-cancel gate (anti-cheat):"); stringBuilder.AppendLine("# enableAnimationCancelGate - if true, attempts to cancel attack-recovery"); stringBuilder.AppendLine("# animations (emote, sheathe) reported by the"); stringBuilder.AppendLine("# companion are logged + posted + counted."); stringBuilder.AppendLine("# The companion ALWAYS blocks the cancel client-side;"); stringBuilder.AppendLine("# this toggle only controls server-side accounting."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Skill-level cap (anti-cheat):"); stringBuilder.AppendLine("# enableSkillCap - master toggle. Companion plugin sends a snapshot of"); stringBuilder.AppendLine("# each player's m_skills every ~60s; server flags any"); stringBuilder.AppendLine("# skill above the cap."); stringBuilder.AppendLine("# skillCapMaxLevel - max allowed level. Vanilla is 100."); stringBuilder.AppendLine("# skillCapTolerance - added to skillCapMaxLevel to form the actual flag"); stringBuilder.AppendLine("# threshold. Use to absorb float rounding / minor over-shoot."); stringBuilder.AppendLine("# Raise both for modpacks that legitimately allow higher."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Death log (public Discord):"); stringBuilder.AppendLine("# enableDeathLog - if true, posts a public-channel message every time a"); stringBuilder.AppendLine("# player dies. Includes position and killer (player name"); stringBuilder.AppendLine("# + SteamID for PvP, creature name for mobs, cause for"); stringBuilder.AppendLine("# environmental). Pure forensic log - no violation rule."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Build / destroy heatmap:"); stringBuilder.AppendLine("# enableBuildLog - if true, every piece placement and destruction is"); stringBuilder.AppendLine("# appended to a daily CSV file at"); stringBuilder.AppendLine("# BepInEx/config/ServerGuard/build_log/YYYY-MM-DD.csv."); stringBuilder.AppendLine("# Useful for investigating grief reports. No Discord"); stringBuilder.AppendLine("# output, no violation rule - pure forensic log."); stringBuilder.AppendLine("# buildLogRetentionDays - delete CSV files older than this. Default 30."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Self-test (boot-time smoke checks):"); stringBuilder.AppendLine("# enableSelfTest - run a suite of smoke tests (HMAC, fingerprint,"); stringBuilder.AppendLine("# build-log dir, webhook syntax, ...) at startup."); stringBuilder.AppendLine("# Result is logged and posted to admin Discord on FAIL."); stringBuilder.AppendLine("# Re-run on demand via the `sg selftest` console cmd."); stringBuilder.AppendLine("# selfTestPostOnPass - if true, also post a green-checkmark line to admin"); stringBuilder.AppendLine("# even when all tests pass. Default false (only FAILs)."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Ping / latency log:"); stringBuilder.AppendLine("# enablePingLog - if true, sample each peer's RTT and post the first"); stringBuilder.AppendLine("# measurement after join + session avg on disconnect"); stringBuilder.AppendLine("# to the admin channel. Useful for proxy / VPN spotting."); stringBuilder.AppendLine("# Default false."); stringBuilder.AppendLine("# pingLogSampleSeconds - sampling interval. Default 5."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Discord (two independent channels - either, both, or neither):"); stringBuilder.AppendLine("# discordWebhookUrl - PUBLIC channel. Receives only player-facing"); stringBuilder.AppendLine("# events (joined / kicked / banned / died) in plain"); stringBuilder.AppendLine("# language. Safe for community-visible channels."); stringBuilder.AppendLine("# discordWebhookUrlAdmin - ADMIN channel. Receives CURATED admin-relevant"); stringBuilder.AppendLine("# events: violation strikes, config reloads, admin"); stringBuilder.AppendLine("# command audit, kicks/bans, daily summary. Clean"); stringBuilder.AppendLine("# enough to scan; use a moderator-only channel."); stringBuilder.AppendLine("# discordVerboseMirror - if true, ALSO mirror every ServerGuard log line"); stringBuilder.AppendLine("# to the admin channel (noisy). Default false."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Daily summary:"); stringBuilder.AppendLine("# dailySummaryEnabled - if true, post a one-paragraph digest each day."); stringBuilder.AppendLine("# dailySummaryHourUtc - 0..23, UTC hour at which the post fires."); stringBuilder.AppendLine("# dailySummaryChannel - 'public' | 'admin' | 'both'."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Per-rule violation accounting (countAsViolation):"); stringBuilder.AppendLine("# Each rule can independently decide whether a failure increments the"); stringBuilder.AppendLine("# player's violation count toward auto-ban. A 'false' rule still kicks the"); stringBuilder.AppendLine("# player (when enforce: true) but doesn't add a strike. Tune to match how"); stringBuilder.AppendLine("# strict you want your server to be. Defaults shown below."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine(_yamlOut.Serialize((object)settings)); File.WriteAllText(SettingsYaml, stringBuilder.ToString()); } if (!File.Exists(AdminsYaml)) { AdminsDoc adminsDoc = new AdminsDoc { admins = new List() }; 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 `|` 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("# Recommended workflow:"); stringBuilder3.AppendLine("# 1. Install the ServerGuard companion plugin on a client that has your full modpack."); stringBuilder3.AppendLine("# 2. Launch Valheim once. The client writes a snippet to:"); stringBuilder3.AppendLine("# /BepInEx/config/ServerGuard/mods_for_allowed_mods.yaml"); stringBuilder3.AppendLine("# 3. Paste that snippet's `allowed_mods:` block into this file."); stringBuilder3.AppendLine("#"); stringBuilder3.AppendLine("# Or for ad-hoc harvesting: set logPeerManifest: true in settings.yaml and connect a"); stringBuilder3.AppendLine("# real client - every GUID will appear in BepInEx/LogOutput.log."); stringBuilder3.AppendLine(); stringBuilder3.AppendLine("required_mods:"); stringBuilder3.AppendLine(" - com.taeguk.valheim.serverguard.client # the ServerGuard companion plugin"); stringBuilder3.AppendLine(); stringBuilder3.AppendLine("allowed_mods: []"); 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(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"); if (_bootCompleted) { PostAdminEvent(":arrows_counterclockwise: settings.yaml reloaded"); } } catch (Exception ex2) { LogS.LogError((object)("[ServerGuard] Failed to load settings.yaml: " + ex2.Message)); _settings = new Settings(); } try { ReconfigureDiscordAndSummary(); } catch (Exception ex3) { ManualLogSource logS = LogS; if (logS != null) { logS.LogWarning((object)("[ServerGuard] Discord/summary reconfigure failed: " + ex3.Message)); } } } private void ReconfigureDiscordAndSummary() { if (_settings == null) { return; } string value = _settings.discordWebhookUrl ?? ""; string text = _settings.discordWebhookUrlAdmin ?? ""; string.IsNullOrWhiteSpace(value); bool flag = !string.IsNullOrWhiteSpace(text) && _settings.DiscordVerboseMirror; bool flag2 = _discordListener != null; bool flag3 = !string.Equals(text, _attachedAdminWebhookUrl, StringComparison.Ordinal); if (flag2 && (!flag || flag3)) { try { Logger.Listeners.Remove((ILogListener)(object)_discordListener); } catch { } try { _discordListener.Dispose(); } catch { } _discordListener = null; flag2 = false; } if (flag && !flag2) { try { ManualLogSource logS = LogS; string text2 = ((logS != null) ? logS.SourceName : null) ?? "Valheim ServerGuard"; _discordListener = new DiscordLogListener(text, "[ServerGuard]", text2); Logger.Listeners.Add((ILogListener)(object)_discordListener); LogS.LogInfo((object)("[ServerGuard] Admin Discord verbose mirror enabled for source '" + text2 + "'.")); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Failed to enable admin Discord verbose mirror: " + ex.Message)); } } if (flag3) { if (!string.IsNullOrWhiteSpace(text)) { LogS.LogInfo((object)("[ServerGuard] Admin Discord channel armed (curated events; verbose mirror: " + (_settings.DiscordVerboseMirror ? "ON" : "OFF") + ").")); } else { LogS.LogInfo((object)"[ServerGuard] Admin Discord channel disabled (URL not set)."); } _attachedAdminWebhookUrl = text; } if (!_dailySummaryStarted && _settings.DailySummaryEnabled && (!string.IsNullOrWhiteSpace(value) || !string.IsNullOrWhiteSpace(text))) { ((MonoBehaviour)this).StartCoroutine(DailySummaryLoop()); _dailySummaryStarted = true; LogS.LogInfo((object)$"[ServerGuard] Daily summary enabled (fires at {_settings.DailySummaryHourUtc:D2}:00 UTC, channel: {_settings.DailySummaryChannel})."); } } private void LoadAdmins() { try { string text = File.ReadAllText(AdminsYaml); AdminsDoc adminsDoc = _yamlIn.Deserialize(text) ?? new AdminsDoc(); _admins = new HashSet(from s in adminsDoc.admins ?? new List() select s.Trim() into s where !string.IsNullOrWhiteSpace(s) select s, StringComparer.OrdinalIgnoreCase); LogS.LogInfo((object)$"[ServerGuard] admins.yaml loaded ({_admins.Count} admins)"); if (_bootCompleted) { PostAdminEvent($":arrows_counterclockwise: admins.yaml reloaded ({_admins.Count} admins)"); } } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load admins.yaml: " + ex.Message)); _admins = new HashSet(StringComparer.OrdinalIgnoreCase); } } private void LoadAllowedMods() { try { string text = File.ReadAllText(AllowedModsYaml); AllowedModsDoc allowedModsDoc = _yamlIn.Deserialize(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})"); if (_bootCompleted) { PostAdminEvent($":arrows_counterclockwise: allowed_mods.yaml reloaded — req={_requiredMods.Count} allow={_allowedMods.Count} ban={_bannedMods.Count}"); } } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load allowed_mods.yaml: " + ex.Message)); _requiredMods = new List(); _allowedMods = new List(); _bannedMods = new List(); } RecomputeModsetFingerprint(); } private void RecomputeModsetFingerprint() { try { List> entries = (from e in _requiredMods.Concat(_allowedMods) select new KeyValuePair(e?.Key ?? "", e?.Sha256 ?? "")).ToList(); _modsetFingerprintStrict = ModsetFingerprint.ComputeStrict(entries); _modsetFingerprintLoose = ModsetFingerprint.ComputeLoose(entries); string path = Path.Combine(ConfDir, "modset_fingerprint.txt"); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("# Modset fingerprint for this server."); stringBuilder.AppendLine("# Re-generated on every hot reload of allowed_mods.yaml."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# LOOSE - matches across version bumps. Useful for 'are we on the same modpack?'"); stringBuilder.AppendLine("# STRICT - also pins each mod's DLL hash. Matches only on identical binaries."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Players can compare these against their client startup log line:"); stringBuilder.AppendLine("# [ServerGuard.Client] Modset fingerprint loose=XXXXXXXX strict=YYYYYYYY"); stringBuilder.AppendLine(); stringBuilder.AppendLine("loose: " + _modsetFingerprintLoose); stringBuilder.AppendLine("strict: " + _modsetFingerprintStrict); stringBuilder.AppendLine(); stringBuilder.AppendLine("short_loose: " + ModsetFingerprint.Short(_modsetFingerprintLoose)); stringBuilder.AppendLine("short_strict: " + ModsetFingerprint.Short(_modsetFingerprintStrict)); File.WriteAllText(path, stringBuilder.ToString()); LogS.LogInfo((object)("[ServerGuard] Modset fingerprint loose=" + ModsetFingerprint.Short(_modsetFingerprintLoose) + " strict=" + ModsetFingerprint.Short(_modsetFingerprintStrict) + " (full values in " + Path.GetFileName(path) + ")")); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Failed to compute modset fingerprint: " + ex.Message)); } } private static List ParseAllowedList(List raw) { List list = new List(); 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(text); if (registrationsDoc?.registrations != null && registrationsDoc.registrations.Count > 0) { _registrations = registrationsDoc.registrations; } else { Dictionary> dictionary = _yamlIn.Deserialize>>(text); if (dictionary != null && dictionary.TryGetValue("registrations", out var value) && value != null) { Dictionary> dictionary2 = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (KeyValuePair item in value) { if (!string.IsNullOrWhiteSpace(item.Key) && !string.IsNullOrWhiteSpace(item.Value)) { dictionary2[item.Key] = new List { item.Value.Trim() }; } } _registrations = dictionary2; SaveRegistrations(); } else { _registrations = new Dictionary>(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>(StringComparer.OrdinalIgnoreCase); } } private void LoadViolations() { try { string text = File.ReadAllText(ViolationsYaml); ViolationsDoc violationsDoc = _yamlIn.Deserialize(text) ?? new ViolationsDoc(); _violations = violationsDoc.violations ?? new Dictionary>(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>(StringComparer.OrdinalIgnoreCase); } } private void LoadMetrics() { try { string text = File.ReadAllText(MetricsYaml); _metrics = _yamlIn.Deserialize(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 string FormatPlayer(string steamId) { if (string.IsNullOrWhiteSpace(steamId)) { return "NewPlayer (UNKNOWN)"; } if (_registrations != null && _registrations.TryGetValue(steamId, out var value) && value != null && value.Count > 0) { return string.Join(", ", value) + " (" + steamId + ")"; } return "NewPlayer (" + steamId + ")"; } 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 static string FriendlyReason(string rule, string detail = null) { switch (rule) { case "CompanionMissing": return "missing the required companion mod"; case "HmacInvalid": return "wrong password"; case "ChallengeMismatch": return "wrong password"; case "RequiredModMissing": if (!string.IsNullOrEmpty(detail)) { return "missing a required mod (" + detail + ")"; } return "missing a required mod"; case "DisallowedMod": if (!string.IsNullOrEmpty(detail)) { return "had a mod that isn't allowed (" + detail + ")"; } return "had a mod that isn't allowed"; case "BannedMod": if (!string.IsNullOrEmpty(detail)) { return "had a banned mod (" + detail + ")"; } return "had a banned mod"; case "HashMismatch": if (!string.IsNullOrEmpty(detail)) { return "mod file doesn't match the server's copy (" + detail + ")"; } return "mod file doesn't match the server's copy"; case "CharacterNameLimitExceeded": return "tried to use too many characters"; case "DevcommandAttempt": if (!string.IsNullOrEmpty(detail)) { return "tried to use cheats (`" + detail + "`)"; } return "tried to use cheats"; case "SpeedHack": if (!string.IsNullOrEmpty(detail)) { return "moved suspiciously fast (~" + detail + ")"; } return "moved suspiciously fast"; case "IllegalItem": if (!string.IsNullOrEmpty(detail)) { return "had an unknown item (" + detail + ")"; } return "had an unknown item"; case "StackOverflow": if (!string.IsNullOrEmpty(detail)) { return "had an over-sized item stack (" + detail + ")"; } return "had an over-sized item stack"; case "AnimationCancel": if (!string.IsNullOrEmpty(detail)) { return "tried to cancel attack with " + detail; } return "tried to cancel attack animation"; case "SkillOverflow": if (!string.IsNullOrEmpty(detail)) { return "skill level above cap (" + detail + ")"; } return "skill level above cap"; default: return "policy violation"; } } private bool RuleCountsAsViolation(string rule) { if (_settings?.CountAsViolation == null) { return false; } bool value; return _settings.CountAsViolation.TryGetValue(rule, out value) && value; } private void PostAdminEvent(string text) { if (_settings != null && !string.IsNullOrWhiteSpace(_settings.discordWebhookUrlAdmin) && !string.IsNullOrWhiteSpace(text)) { SendDiscordNow(text, DiscordChannel.Admin); } } private void PostPlayerEvent(string emoji, string platformId, string action, string reason = null) { TrackEventForDailySummary(action, reason); string text = FormatPlayer(platformId); string text2 = (string.IsNullOrEmpty(reason) ? (emoji + " " + text + " " + action) : (emoji + " " + text + " " + action + " — " + reason)); if (!string.IsNullOrWhiteSpace(platformId) && IsAdmin(platformId)) { if (!string.IsNullOrWhiteSpace(_settings?.discordWebhookUrlAdmin)) { SendDiscordNow(text2, DiscordChannel.Admin); } } else if (!string.IsNullOrWhiteSpace(_settings?.discordWebhookUrl)) { SendDiscordNow(text2); } } private void TrackEventForDailySummary(string action, string reason) { if (string.IsNullOrEmpty(action)) { return; } lock (_summaryLock) { if (action.IndexOf("joined", StringComparison.OrdinalIgnoreCase) >= 0) { _summaryJoins++; } else if (action.IndexOf("auto-banned", StringComparison.OrdinalIgnoreCase) >= 0) { _summaryBans++; } else if (action.IndexOf("kicked", StringComparison.OrdinalIgnoreCase) >= 0) { _summaryKicks++; string key = (string.IsNullOrWhiteSpace(reason) ? "other" : reason.Trim()); _summaryKickReasons.TryGetValue(key, out var value); _summaryKickReasons[key] = value + 1; } else if (action.IndexOf("left", StringComparison.OrdinalIgnoreCase) >= 0) { _summaryLeaves++; } } } private List RunSelfTest() { List results = new List(); try { if (_settings.RequireHmac && string.IsNullOrEmpty(_settings.SharedSecret)) { Add("HMAC sharedSecret", pass: false, "requireHmac=true but sharedSecret is empty"); } else if (_settings.RequireHmac) { Add("HMAC sharedSecret", pass: true, $"len={_settings.SharedSecret.Length}"); } else { Add("HMAC sharedSecret", pass: true, "requireHmac disabled - skipped"); } } catch (Exception ex) { Add("HMAC sharedSecret", pass: false, ex.Message); } try { string canonical = new ModManifest { SchemaVersion = "1", Challenge = "selftest-challenge", TimestampUtc = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), Mods = new List() }.CanonicalForHmac(); bool flag = ModManifest.ConstantTimeEquals(ModManifest.ComputeHmac(canonical, _settings.SharedSecret ?? ""), ModManifest.ComputeHmac(canonical, _settings.SharedSecret ?? "")); Add("HMAC roundtrip", flag, flag ? "sign/verify match" : "sign/verify MISMATCH"); } catch (Exception ex2) { Add("HMAC roundtrip", pass: false, ex2.Message); } try { ModManifest manifest = new ModManifest { Mods = new List() }; PolicyVerdict policyVerdict = ValidateAgainstPolicy(manifest); bool num = _requiredMods.Count > 0; bool pass2 = (num ? (!policyVerdict.Allowed) : policyVerdict.Allowed); string detail2 = ((!num) ? (policyVerdict.Allowed ? "empty manifest allowed (no required mods)" : "empty manifest unexpectedly rejected") : (policyVerdict.Allowed ? "empty manifest passed but required mods exist!" : ("empty manifest rejected as expected (" + policyVerdict.Rule + ")"))); Add("Policy validator", pass2, detail2); } catch (Exception ex3) { Add("Policy validator", pass: false, ex3.Message); } try { Directory.CreateDirectory(BuildLogDir); string path = Path.Combine(BuildLogDir, ".selftest_probe"); File.WriteAllText(path, "ok"); File.Delete(path); Add("Build-log dir writable", pass: true, BuildLogDir); } catch (Exception ex4) { Add("Build-log dir writable", pass: false, ex4.Message); } try { bool flag2 = !string.IsNullOrEmpty(_modsetFingerprintLoose) && !string.IsNullOrEmpty(_modsetFingerprintStrict); Add("Modset fingerprint", flag2, flag2 ? ("loose=" + ModsetFingerprint.Short(_modsetFingerprintLoose) + " strict=" + ModsetFingerprint.Short(_modsetFingerprintStrict)) : "empty"); } catch (Exception ex5) { Add("Modset fingerprint", pass: false, ex5.Message); } Add("Public webhook URL", string.IsNullOrEmpty(_settings.discordWebhookUrl) || IsWebhookUrlSane(_settings.discordWebhookUrl), string.IsNullOrEmpty(_settings.discordWebhookUrl) ? "not configured (ok)" : "looks valid"); Add("Admin webhook URL", string.IsNullOrEmpty(_settings.discordWebhookUrlAdmin) || IsWebhookUrlSane(_settings.discordWebhookUrlAdmin), string.IsNullOrEmpty(_settings.discordWebhookUrlAdmin) ? "not configured (ok)" : "looks valid"); Add("Admins configured", _admins.Count > 0, (_admins.Count > 0) ? $"{_admins.Count} admin SteamID(s) in admins.yaml" : "admins.yaml is empty - sg commands will be unusable"); return results; SelfTestResult Add(string name, bool pass, string detail) { SelfTestResult selfTestResult = new SelfTestResult { Name = name, Pass = pass, Detail = (detail ?? "") }; results.Add(selfTestResult); return selfTestResult; } } private static bool IsWebhookUrlSane(string url) { if (string.IsNullOrWhiteSpace(url)) { return false; } if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) { return false; } if (url.IndexOf("/webhooks/", StringComparison.OrdinalIgnoreCase) < 0) { return false; } if (Uri.TryCreate(url, UriKind.Absolute, out Uri result)) { return result.Host.IndexOf('.') >= 0; } return false; } private string FormatSelfTestReport(List results) { int num = results.Count((SelfTestResult r) => r.Pass); int num2 = results.Count - num; StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine($"[ServerGuard] Self-test pass={num} fail={num2}"); foreach (SelfTestResult result in results) { string arg = (result.Pass ? "PASS" : "FAIL"); stringBuilder.AppendLine($" [{arg}] {result.Name,-26} {result.Detail}"); } return stringBuilder.ToString().TrimEnd(Array.Empty()); } private string FormatSelfTestForDiscord(List results) { int num = results.Count((SelfTestResult r) => r.Pass); int num2 = results.Count - num; StringBuilder stringBuilder = new StringBuilder(); string arg = ((num2 == 0) ? ":white_check_mark:" : ":rotating_light:"); stringBuilder.AppendLine($"{arg} **Self-test** — {num} pass / {num2} fail"); if (num2 > 0) { foreach (SelfTestResult item in results.Where((SelfTestResult r) => !r.Pass)) { stringBuilder.AppendLine(" • **" + item.Name + "**: " + item.Detail); } } return stringBuilder.ToString().TrimEnd(Array.Empty()); } [IteratorStateMachine(typeof(d__112))] public IEnumerator PingLogLoop() { //yield-return decompiler failed: Unexpected instruction in Iterator.Dispose() return new d__112(0) { <>4__this = this }; } private void TickPingLog() { List peers = ZNet.instance.GetPeers(); if (peers == null) { return; } foreach (ZNetPeer item in peers) { if (item == null || item.m_rpc == null) { continue; } string peerPlatformId = GetPeerPlatformId(item); if (string.IsNullOrWhiteSpace(peerPlatformId)) { continue; } float num = ReadPingMs(item.m_rpc); if (!(num <= 0f) && !(num > 10000f)) { if (!_pingState.TryGetValue(item.m_uid, out var value) || value == null) { value = new PingState(); _pingState[item.m_uid] = value; } value.Samples.Add(num); if (value.Samples.Count > 200) { value.Samples.RemoveAt(0); } if (!value.FirstPosted && value.Samples.Count >= 2) { value.FirstPosted = true; PostAdminEvent($":satellite: **{FormatPlayer(peerPlatformId)}** first ping: **{num:F0} ms**"); } } } } private static float ReadPingMs(object rpc) { if (rpc == null) { return 0f; } try { if (_rpcPingField == null) { _rpcPingField = rpc.GetType().GetField("m_ping", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); } if (_rpcPingField == null) { return 0f; } object value = _rpcPingField.GetValue(rpc); if (value is float num) { return num * 1000f; } if (value is double) { double num2 = (double)value; return (float)(num2 * 1000.0); } } catch { } return 0f; } internal void FlushPingOnDisconnect(long peerUid, string steamId) { if (_settings != null && _settings.EnablePingLog && _pingState.TryGetValue(peerUid, out var value) && value != null) { _pingState.Remove(peerUid); if (value.Samples.Count != 0) { float num = value.Samples.Sum() / (float)value.Samples.Count; PostAdminEvent($":satellite: **{FormatPlayer(steamId)}** session ping avg: **{num:F0} ms** ({value.Samples.Count} samples)"); } } } [IteratorStateMachine(typeof(d__116))] public IEnumerator SpeedCheckLoop() { //yield-return decompiler failed: Unexpected instruction in Iterator.Dispose() return new d__116(0) { <>4__this = this }; } private void TickSpeedCheck() { //IL_0084: Unknown result type (might be due to invalid IL or missing references) //IL_0089: Unknown result type (might be due to invalid IL or missing references) //IL_0093: Unknown result type (might be due to invalid IL or missing references) //IL_0095: Unknown result type (might be due to invalid IL or missing references) //IL_00b0: Unknown result type (might be due to invalid IL or missing references) //IL_00c3: Unknown result type (might be due to invalid IL or missing references) //IL_00c8: Unknown result type (might be due to invalid IL or missing references) //IL_024d: Unknown result type (might be due to invalid IL or missing references) //IL_024f: Unknown result type (might be due to invalid IL or missing references) //IL_012a: Unknown result type (might be due to invalid IL or missing references) //IL_013e: Unknown result type (might be due to invalid IL or missing references) List peers = ZNet.instance.GetPeers(); if (peers == null) { return; } float realtimeSinceStartup = Time.realtimeSinceStartup; float num = (float)_settings.SpeedCheckMaxMetersPerSecond; float num2 = (float)_settings.SpeedCheckTeleportToleranceMeters; int num3 = Math.Max(1, _settings.SpeedCheckConsecutiveStrikes); foreach (ZNetPeer item in peers) { if (item == null) { continue; } string peerPlatformId = GetPeerPlatformId(item); if (string.IsNullOrWhiteSpace(peerPlatformId) || IsAdmin(peerPlatformId)) { continue; } ZDOID characterID; try { characterID = item.m_characterID; } catch { continue; } if (characterID == ZDOID.None) { continue; } ZDOMan instance = ZDOMan.instance; ZDO val = ((instance != null) ? instance.GetZDO(characterID) : null); if (val == null) { continue; } Vector3 position; try { position = val.GetPosition(); } catch { continue; } if (!_speedState.TryGetValue(item.m_uid, out var value) || value == null) { value = new SpeedState(); _speedState[item.m_uid] = value; } if (value.HasLastPos) { float num4 = realtimeSinceStartup - value.LastSampleTime; if (num4 > 0.01f) { float num5 = position.x - value.LastPos.x; float num6 = position.z - value.LastPos.z; float num7 = (float)Math.Sqrt(num5 * num5 + num6 * num6); float num8 = num7 / num4; if (num7 > num2) { value.OverThresholdCount = 0; } else if (num8 > num) { value.OverThresholdCount++; LogS.LogInfo((object)("[ServerGuard] Speed alert " + FormatPlayer(peerPlatformId) + ": " + $"{num8:F1} m/s over {num4:F2}s ({value.OverThresholdCount}/{num3})")); if (value.OverThresholdCount >= num3) { string text = $"{num8:F1} m/s"; PostPlayerEvent(":runner:", peerPlatformId, "flagged for speed", "~" + text); AddViolation(peerPlatformId, "SpeedHack", text); value.OverThresholdCount = 0; } } else { value.OverThresholdCount = 0; } } } value.LastPos = position; value.LastSampleTime = realtimeSinceStartup; value.HasLastPos = true; } } internal List ValidateInventoryItem(ItemData item) { List list = new List(); if (item == null || item.m_shared == null) { return list; } string text = item.m_shared.m_name ?? ""; string text2 = text.TrimStart(new char[1] { '$' }); try { ObjectDB instance = ObjectDB.instance; if ((Object)(object)instance != (Object)null) { bool flag = false; if (instance.m_items != null) { foreach (GameObject item2 in instance.m_items) { if (!((Object)(object)item2 == (Object)null)) { ItemDrop component = item2.GetComponent(); if (!((Object)(object)component == (Object)null) && component.m_itemData?.m_shared != null && string.Equals(component.m_itemData.m_shared.m_name, text, StringComparison.Ordinal)) { flag = true; break; } } } } if (!flag) { list.Add("unknown item '" + text2 + "'"); } } } catch { } try { int num = Math.Max(1, item.m_shared.m_maxStackSize); double num2 = Math.Max(1.0, _settings.InventoryCheckStackTolerance); int num3 = (int)Math.Ceiling((double)num * num2); if (item.m_stack > num3) { list.Add($"stack {item.m_stack} > max {num} for '{text2}'"); } } catch { } return list; } [IteratorStateMachine(typeof(d__120))] private IEnumerator DailySummaryLoop() { //yield-return decompiler failed: Unexpected instruction in Iterator.Dispose() return new d__120(0) { <>4__this = this }; } private double SecondsUntilNextFire() { int hour = Math.Max(0, Math.Min(23, _settings.DailySummaryHourUtc)); DateTime utcNow = DateTime.UtcNow; DateTime dateTime = new DateTime(utcNow.Year, utcNow.Month, utcNow.Day, hour, 0, 0, DateTimeKind.Utc); DateTime dateTime2 = ((utcNow < dateTime) ? dateTime : dateTime.AddDays(1.0)); return Math.Max(60.0, (dateTime2 - utcNow).TotalSeconds); } private async Task PostDailySummary(bool scheduled) { int summaryJoins; int summaryLeaves; int summaryKicks; int summaryBans; DateTime summarySince; List> list; lock (_summaryLock) { summaryJoins = _summaryJoins; summaryLeaves = _summaryLeaves; summaryKicks = _summaryKicks; summaryBans = _summaryBans; summarySince = _summarySince; list = _summaryKickReasons.OrderByDescending((KeyValuePair kv) => kv.Value).Take(5).ToList(); _summaryJoins = 0; _summaryLeaves = 0; _summaryKicks = 0; _summaryBans = 0; _summaryKickReasons.Clear(); _summarySince = DateTime.UtcNow; } if (summaryJoins == 0 && summaryLeaves == 0 && summaryKicks == 0 && summaryBans == 0 && scheduled) { return; } string value = $"{summarySince:yyyy-MM-dd HH:mm} UTC → {DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC"; StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine(":bar_chart: **Daily summary**"); stringBuilder.AppendLine(value); stringBuilder.AppendLine($"• Joins: **{summaryJoins}**"); stringBuilder.AppendLine($"• Leaves: **{summaryLeaves}**"); stringBuilder.AppendLine($"• Kicks: **{summaryKicks}**"); stringBuilder.AppendLine($"• Auto-bans: **{summaryBans}**"); if (list.Count > 0) { stringBuilder.AppendLine("Top kick reasons:"); foreach (KeyValuePair item in list) { stringBuilder.AppendLine($" – {item.Key} ({item.Value})"); } } string text = (_settings.DailySummaryChannel ?? "admin").ToLowerInvariant(); DiscordChannel discordChannel = ((!(text == "public")) ? ((!(text == "both")) ? DiscordChannel.Admin : DiscordChannel.Both) : DiscordChannel.Public); DiscordChannel target = discordChannel; await SendDiscordNow(stringBuilder.ToString(), target); } private void AddViolation(string platformId, string rule, string detail = null) { bool num = RuleCountsAsViolation(rule); string text = FormatPlayer(platformId); if (num) { if (!_violations.TryGetValue(platformId, out var value)) { value = new Dictionary(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] {text} violated {rule}. Count={value[rule]}/{_settings.ViolationThreshold}"); PostAdminEvent($":warning: **{text}** violated **{rule}** ({value[rule]}/{_settings.ViolationThreshold})" + (string.IsNullOrEmpty(detail) ? "" : (" — " + detail))); if (_settings.Enforce && value[rule] >= _settings.ViolationThreshold) { TryBan(platformId, _settings.BanReason); if (_settings.EnableMetrics) { _metrics.players_banned++; SaveMetrics(); } PostPlayerEvent(":no_entry:", platformId, "was auto-banned", "too many strikes"); PostAdminEvent(":no_entry: Auto-banned **" + text + "** (threshold reached)"); } } else { LogS.LogWarning((object)("[ServerGuard] " + text + " hit rule '" + rule + "' (countAsViolation: false - no strike recorded).")); PostAdminEvent(":eye: **" + text + "** triggered **" + rule + "** (informational — not counted)" + (string.IsNullOrEmpty(detail) ? "" : (" — " + detail))); } } 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); string text = FormatPlayer(peerPlatformId); try { ZRpc rpc = val.m_rpc; if (rpc != null) { rpc.Invoke("Error", new object[1] { 3 }); } } catch { } try { lock (_suppressLogoutFor) { _suppressLogoutFor.Add(val.m_uid); } } catch { } try { ZNet.instance.Disconnect(val); LogS.LogWarning((object)("[ServerGuard] Disconnected " + text + ". Reason: " + reason)); PostAdminEvent(":door: Disconnected **" + text + "** — " + reason); return; } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] ZNet.Disconnect threw (" + ex.Message + "); falling back to reflection.")); } object obj3 = typeof(ZNet).GetProperty("instance", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(null); if (obj3 == null) { return; } MethodInfo method = obj3.GetType().GetMethod("Disconnect", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[1] { typeof(ZNetPeer) }, null); if (method != null) { method.Invoke(obj3, new object[1] { val }); LogS.LogWarning((object)("[ServerGuard] Disconnected " + text + " (reflection). Reason: " + reason)); return; } MethodInfo method2 = obj3.GetType().GetMethod("InternalKick", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[1] { typeof(ZNetPeer) }, null); if (method2 != null) { method2.Invoke(obj3, new object[1] { val }); LogS.LogWarning((object)("[ServerGuard] InternalKick'd " + text + ". 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 }); string text = FormatPlayer(platformId); LogS.LogWarning((object)("[ServerGuard] Auto-banned " + text + ". 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 list = (File.Exists(SettingsYaml) ? File.ReadAllLines(SettingsYaml).ToList() : new List()); 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(d__134))] public IEnumerator AttestationTimeoutCoroutine(ZNetPeer peer, string steamId) { //yield-return decompiler failed: Unexpected instruction in Iterator.Dispose() return new d__134(0) { <>4__this = this, peer = peer, steamId = steamId }; } public void OnManifestReceived(ZNetPeer peer, string json) { string text = "UNKNOWN"; try { text = GetPeerPlatformId(peer); string text2 = FormatPlayer(text); PendingAttestation value; lock (_pendingLock) { if (!_pending.TryGetValue(peer.m_uid, out value) || value == null) { LogS.LogWarning((object)("[ServerGuard] Manifest from " + text2 + " arrived with no pending challenge (timed out or duplicate). Ignoring.")); return; } _pending.Remove(peer.m_uid); } ModManifest modManifest; try { modManifest = JsonConvert.DeserializeObject(json); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Failed to parse manifest from " + text2 + ": " + ex.Message)); AddViolation(text, "HmacInvalid"); if (_settings.Enforce) { PostPlayerEvent(":door:", text, "was kicked", FriendlyReason("HmacInvalid")); TryKick(peer, _settings.KickMessage + " (Malformed manifest)"); } return; } if (modManifest == null) { LogS.LogWarning((object)("[ServerGuard] Empty manifest from " + text2 + ".")); AddViolation(text, "HmacInvalid"); if (_settings.Enforce) { PostPlayerEvent(":door:", text, "was kicked", FriendlyReason("HmacInvalid")); TryKick(peer, _settings.KickMessage + " (Empty manifest)"); } return; } if (!ModManifest.ConstantTimeEquals(modManifest.Challenge ?? "", value.Challenge ?? "")) { LogS.LogWarning((object)("[ServerGuard] Challenge mismatch from " + text2 + ".")); AddViolation(text, "ChallengeMismatch"); if (_settings.Enforce) { PostPlayerEvent(":door:", text, "was kicked", FriendlyReason("ChallengeMismatch")); 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 {text2} (client={modManifest.TimestampUtc} server={num})."); AddViolation(text, "HmacInvalid"); if (_settings.Enforce) { PostPlayerEvent(":door:", text, "was kicked", "system clock too far off"); 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 " + text2 + ": 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 " + text2 + ". Either bad sharedSecret on client, or tampered manifest.")); AddViolation(text, "HmacInvalid"); if (_settings.Enforce) { PostPlayerEvent(":door:", text, "was kicked", FriendlyReason("HmacInvalid")); TryKick(peer, _settings.KickMessage + " (Invalid signature)"); } return; } } if (_settings.LogPeerManifest) { IEnumerable values = (modManifest.Mods ?? new List()).Select((ModManifestEntry m) => " - " + m.Guid + "|" + m.Name + "|" + m.Version + "|" + m.Sha256); LogS.LogInfo((object)($"[ServerGuard] Manifest from {text2} ({modManifest.Mods?.Count ?? 0} mods):\n" + string.Join("\n", values))); } PolicyVerdict policyVerdict = ValidateAgainstPolicy(modManifest); if (!policyVerdict.Allowed) { LogS.LogWarning((object)("[ServerGuard] " + text2 + " REJECTED: " + policyVerdict.Rule + " - " + policyVerdict.Reason)); AddViolation(text, policyVerdict.Rule, policyVerdict.Detail); if (_settings.Enforce) { PostPlayerEvent(":door:", text, "was kicked", FriendlyReason(policyVerdict.Rule, policyVerdict.Detail)); TryKick(peer, _settings.KickMessage + " (" + policyVerdict.Reason + ")"); } return; } string arg; try { List> entries = (modManifest.Mods ?? new List()).Select((ModManifestEntry m) => new KeyValuePair((!string.IsNullOrEmpty(m?.Guid)) ? m.Guid : (m?.Name ?? ""), m?.Sha256 ?? "")).ToList(); string text3 = ModsetFingerprint.ComputeLoose(entries); string text4 = ModsetFingerprint.ComputeStrict(entries); bool flag = ModManifest.ConstantTimeEquals(text3, _modsetFingerprintLoose); arg = (ModManifest.ConstantTimeEquals(text4, _modsetFingerprintStrict) ? ("modset ✓ exact (" + ModsetFingerprint.Short(text4) + ")") : ((!flag) ? ("modset ⚠ differs from server (client " + ModsetFingerprint.Short(text3) + " vs server " + ModsetFingerprint.Short(_modsetFingerprintLoose) + ")") : ("modset ✓ same set, different versions (loose " + ModsetFingerprint.Short(text3) + ")"))); } catch (Exception ex2) { arg = "modset fingerprint check failed: " + ex2.Message; } LogS.LogInfo((object)$"[ServerGuard] {text2} attested OK ({modManifest.Mods?.Count ?? 0} mods, {arg})."); PostPlayerEvent(":white_check_mark:", text, "joined"); } catch (Exception arg2) { LogS.LogError((object)$"[ServerGuard] OnManifestReceived error for {FormatPlayer(text)}: {arg2}"); } } public void OnSkillReportReceived(ZNetPeer peer, string payload) { try { if (peer == null || _settings == null || !_settings.EnableSkillCap || string.IsNullOrWhiteSpace(payload)) { return; } string peerPlatformId = GetPeerPlatformId(peer); if (IsAdmin(peerPlatformId)) { return; } double num = _settings.SkillCapMaxLevel + _settings.SkillCapTolerance; string arg = FormatPlayer(peerPlatformId); if (!_skillOverflowState.TryGetValue(peer.m_uid, out var value) || value == null) { value = new SkillOverflowState(); _skillOverflowState[peer.m_uid] = value; } string[] array = payload.Split(new char[1] { '|' }); for (int i = 0; i < array.Length; i++) { string text = array[i]?.Trim(); if (string.IsNullOrEmpty(text) || text.Length > 64) { continue; } int num2 = text.IndexOf(':'); if (num2 <= 0 || num2 == text.Length - 1) { continue; } string text2 = text.Substring(0, num2).Trim(); string s = text.Substring(num2 + 1).Trim(); if (!string.IsNullOrEmpty(text2) && text2.Length <= 32 && double.TryParse(s, NumberStyles.Float, CultureInfo.InvariantCulture, out var result)) { double value2; if (result <= num) { value.LastReportedLevel.Remove(text2); } else if (!value.LastReportedLevel.TryGetValue(text2, out value2) || !(Math.Abs(value2 - result) < 0.05)) { value.LastReportedLevel[text2] = result; string text3 = $"{text2}={result:F1}"; LogS.LogWarning((object)$"[ServerGuard] {arg} skill cap exceeded: {text3} (cap {num:F1})"); PostPlayerEvent(":books:", peerPlatformId, "exceeded skill cap", text3); AddViolation(peerPlatformId, "SkillOverflow", text3); } } } } catch (Exception arg2) { LogS.LogError((object)$"[ServerGuard] OnSkillReportReceived error: {arg2}"); } } private static ZDOID? GetHitAttacker(HitData hit) { //IL_0058: Unknown result type (might be due to invalid IL or missing references) if (hit == null) { return null; } if (_hitAttackerField == null) { _hitAttackerField = typeof(HitData).GetField("m_attacker", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); } if (_hitAttackerField == null) { return null; } try { return (ZDOID)_hitAttackerField.GetValue(hit); } catch { return null; } } private void LogBuildEvent(string action, string steamId, string charName, string pieceName, Vector3 pos) { //IL_00e7: Unknown result type (might be due to invalid IL or missing references) //IL_00f6: Unknown result type (might be due to invalid IL or missing references) //IL_0105: Unknown result type (might be due to invalid IL or missing references) try { if (_settings == null || !_settings.EnableBuildLog) { return; } Directory.CreateDirectory(BuildLogDir); string text = DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture); string path = Path.Combine(BuildLogDir, text + ".csv"); bool flag = !File.Exists(path); using StreamWriter streamWriter = new StreamWriter(path, append: true, Encoding.UTF8); if (flag) { streamWriter.WriteLine("timestamp,action,steamId,charName,pieceName,x,y,z"); } CultureInfo invariantCulture = CultureInfo.InvariantCulture; string text2 = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ", invariantCulture); string text3 = (charName ?? "").Replace("\"", "\"\""); streamWriter.WriteLine(string.Format(invariantCulture, "{0},{1},{2},\"{3}\",{4},{5:F1},{6:F1},{7:F1}", text2, action, steamId ?? "", text3, pieceName ?? "", pos.x, pos.y, pos.z)); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] LogBuildEvent failed: " + ex.Message)); } } public void OnBuildDestroyReceived(ZNetPeer peer, string payload) { //IL_01b4: Unknown result type (might be due to invalid IL or missing references) try { if (peer == null || _settings == null || !_settings.EnableBuildLog || string.IsNullOrWhiteSpace(payload)) { return; } string[] array = payload.Split(new char[1] { '|' }); if (array.Length < 4) { return; } string text = (array[0] ?? "").Trim(); if (text.Length > 64) { text = text.Substring(0, 64); } CultureInfo invariantCulture = CultureInfo.InvariantCulture; if (!float.TryParse(array[1], NumberStyles.Float, invariantCulture, out var result) || !float.TryParse(array[2], NumberStyles.Float, invariantCulture, out var result2) || !float.TryParse(array[3], NumberStyles.Float, invariantCulture, out var result3)) { return; } string text2 = ((array.Length >= 5) ? (array[4] ?? "").Trim().ToLowerInvariant() : ""); string text3 = ((array.Length >= 6) ? (array[5] ?? "").Trim() : ""); if (text3.Length > 48) { text3 = text3.Substring(0, 48); } string text4; string charName; if (text2 == "creature") { text4 = ""; charName = text3; } else if (text2 == "player" && !string.IsNullOrEmpty(text3)) { text4 = LookupSteamIdByCharName(text3) ?? ""; charName = text3; } else { text4 = GetPeerPlatformId(peer); charName = ""; if (_registrations != null && _registrations.TryGetValue(text4, out var value) && value != null && value.Count > 0) { charName = value[0]; } } LogBuildEvent("destroy", text4, charName, text, new Vector3(result, result2, result3)); } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] OnBuildDestroyReceived error: {arg}"); } } public void OnBuildPlaceReceived(ZNetPeer peer, string payload) { //IL_010a: Unknown result type (might be due to invalid IL or missing references) try { if (peer == null || _settings == null || !_settings.EnableBuildLog || string.IsNullOrWhiteSpace(payload)) { return; } string[] array = payload.Split(new char[1] { '|' }); if (array.Length < 4) { return; } string text = (array[0] ?? "").Trim(); if (text.Length > 64) { text = text.Substring(0, 64); } CultureInfo invariantCulture = CultureInfo.InvariantCulture; if (float.TryParse(array[1], NumberStyles.Float, invariantCulture, out var result) && float.TryParse(array[2], NumberStyles.Float, invariantCulture, out var result2) && float.TryParse(array[3], NumberStyles.Float, invariantCulture, out var result3)) { string peerPlatformId = GetPeerPlatformId(peer); string charName = ""; if (_registrations != null && _registrations.TryGetValue(peerPlatformId, out var value) && value != null && value.Count > 0) { charName = value[0]; } LogBuildEvent("place", peerPlatformId, charName, text, new Vector3(result, result2, result3)); } } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] OnBuildPlaceReceived error: {arg}"); } } [IteratorStateMachine(typeof(d__146))] public IEnumerator BuildLogCleanupLoop() { //yield-return decompiler failed: Unexpected instruction in Iterator.Dispose() return new d__146(0) { <>4__this = this }; } private void PruneOldBuildLogs() { if (_settings == null || !_settings.EnableBuildLog || !Directory.Exists(BuildLogDir)) { return; } int num = Math.Max(1, _settings.BuildLogRetentionDays); DateTime dateTime = DateTime.UtcNow.AddDays(-num); string[] files = Directory.GetFiles(BuildLogDir, "*.csv"); foreach (string fileName in files) { try { FileInfo fileInfo = new FileInfo(fileName); if (fileInfo.LastWriteTimeUtc < dateTime) { fileInfo.Delete(); LogS.LogInfo((object)$"[ServerGuard] Pruned old build log {fileInfo.Name} (older than {num}d)."); } } catch { } } } private static string GetCharacterDisplayName(Character c) { if ((Object)(object)c == (Object)null) { return ""; } try { string hoverName = c.GetHoverName(); if (!string.IsNullOrWhiteSpace(hoverName)) { return hoverName; } } catch { } GameObject gameObject = ((Component)c).gameObject; string text = ((gameObject != null) ? ((Object)gameObject).name : null) ?? ""; int num = text.IndexOf("(Clone)", StringComparison.Ordinal); if (num > 0) { text = text.Substring(0, num).Trim(); } return text; } public void OnAdminCommandReceived(ZNetPeer peer, string command) { try { if (peer == null) { return; } string peerPlatformId = GetPeerPlatformId(peer); if (string.IsNullOrWhiteSpace(peerPlatformId)) { return; } if (!IsAdmin(peerPlatformId)) { LogS.LogWarning((object)("[ServerGuard] Non-admin " + FormatPlayer(peerPlatformId) + " attempted sg command - ignored.")); ReplyToAdmin(peer, "You are not an admin. Add your SteamID to admins.yaml on the server."); return; } LogS.LogInfo((object)("[ServerGuard] Admin command from " + FormatPlayer(peerPlatformId) + ": " + command)); string text = (command ?? "").TrimStart(Array.Empty()).Split(new char[1] { ' ' }).FirstOrDefault()?.ToLowerInvariant() ?? ""; if (text == "reload" || text == "pardon" || text == "kick") { PostAdminEvent(":hammer_and_wrench: **" + FormatPlayer(peerPlatformId) + "** ran `sg " + command + "`"); } string text2 = DispatchAdminCommand(command, peerPlatformId); ReplyToAdmin(peer, text2); } catch (Exception ex) { LogS.LogError((object)$"[ServerGuard] OnAdminCommandReceived error: {ex}"); try { ReplyToAdmin(peer, "Error: " + ex.Message); } catch { } } } private void ReplyToAdmin(ZNetPeer peer, string text) { if (peer?.m_rpc == null) { return; } try { peer.m_rpc.Invoke("ServerGuard_AdminCommandReply", new object[1] { text ?? "" }); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Admin reply send failed: " + ex.Message)); } } private string DispatchAdminCommand(string raw, string callerSteamId) { if (string.IsNullOrWhiteSpace(raw)) { return CmdHelp(); } string[] array = raw.Trim().Split(new char[1] { ' ' }, StringSplitOptions.RemoveEmptyEntries); if (array.Length == 0) { return CmdHelp(); } string text = array[0].ToLowerInvariant(); string[] args = array.Skip(1).ToArray(); return text switch { "help" => CmdHelp(), "status" => CmdStatus(), "reload" => CmdReload(), "modset" => CmdModset(), "whois" => CmdWhois(args), "violations" => CmdViolations(args), "pardon" => CmdPardon(args), "kick" => CmdKick(args, callerSteamId), "build" => CmdBuild(args, null, "event"), "destroyed" => CmdBuild(args, "destroy", "destroy"), "placed" => CmdBuild(args, "place", "placement"), "selftest" => CmdSelfTest(), _ => "Unknown command `" + text + "`. Try `sg help`.", }; } private string CmdHelp() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("[ServerGuard] commands (type in the F5 console):"); stringBuilder.AppendLine(" sg status - quick health check"); stringBuilder.AppendLine(" sg selftest - run the boot-time smoke tests on demand"); stringBuilder.AppendLine(" sg reload - reload settings/admins/allowed_mods"); stringBuilder.AppendLine(" sg modset - show modset fingerprint"); stringBuilder.AppendLine(" sg whois - player info + recent violations"); stringBuilder.AppendLine(" sg violations [] - top N players by violation count"); stringBuilder.AppendLine(" sg pardon - clear a player's violations"); stringBuilder.AppendLine(" sg kick [reason] - kick a player"); stringBuilder.AppendLine(" sg build at [radius] [days] - all events near coords (radius 50, days 7)"); stringBuilder.AppendLine(" sg build by [days] - all events by a player"); stringBuilder.AppendLine(" sg build today [] - last N events today (default 10)"); stringBuilder.AppendLine(" sg destroyed at [radius] [days] - DESTROYS only, near coords"); stringBuilder.AppendLine(" sg destroyed by [days] - DESTROYS only, by a player"); stringBuilder.AppendLine(" sg destroyed today [] - last N DESTROYS today"); return stringBuilder.ToString().TrimEnd(Array.Empty()); } private string CmdStatus() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine($"[ServerGuard] v1.4.0 enforce={_settings.Enforce} requireCompanion={_settings.RequireCompanion} requireHmac={_settings.RequireHmac}"); stringBuilder.AppendLine($" Allowlist required={_requiredMods.Count} allowed={_allowedMods.Count} banned={_bannedMods.Count}"); stringBuilder.AppendLine(" Modset loose=" + ModsetFingerprint.Short(_modsetFingerprintLoose) + " strict=" + ModsetFingerprint.Short(_modsetFingerprintStrict)); try { ZNet instance = ZNet.instance; stringBuilder.AppendLine($" Online {((instance != null) ? instance.GetPeers() : null)?.Count ?? 0} peer(s)"); } catch { } stringBuilder.AppendLine($" Violators {_violations.Count} player(s) with strikes recorded"); return stringBuilder.ToString().TrimEnd(Array.Empty()); } private string CmdSelfTest() { try { List list = RunSelfTest(); LogS.LogInfo((object)FormatSelfTestReport(list)); if (list.Any((SelfTestResult r) => !r.Pass) || _settings.SelfTestPostOnPass) { PostAdminEvent(FormatSelfTestForDiscord(list)); } return FormatSelfTestReport(list); } catch (Exception ex) { return "Self-test crashed: " + ex.Message; } } private string CmdReload() { try { LoadSettings(); LoadAdmins(); LoadAllowedMods(); return "[ServerGuard] reloaded settings.yaml, admins.yaml, allowed_mods.yaml."; } catch (Exception ex) { return "Reload failed: " + ex.Message; } } private string CmdModset() { return "[ServerGuard] modset fingerprint\n loose : " + _modsetFingerprintLoose + "\n strict : " + _modsetFingerprintStrict + "\n short : loose=" + ModsetFingerprint.Short(_modsetFingerprintLoose) + " strict=" + ModsetFingerprint.Short(_modsetFingerprintStrict); } private string CmdWhois(string[] args) { if (args.Length == 0) { return "Usage: sg whois "; } string text = args[0]; List list = ResolvePlayerQuery(text); if (list.Count == 0) { return "No SteamID matched `" + text + "`."; } StringBuilder stringBuilder = new StringBuilder(); foreach (string item in list) { stringBuilder.AppendLine(" " + FormatPlayer(item)); if (_violations.TryGetValue(item, out var value) && value != null && value.Count > 0) { foreach (KeyValuePair item2 in value.OrderByDescending((KeyValuePair kv) => kv.Value)) { string text2 = (RuleCountsAsViolation(item2.Key) ? "" : " (informational)"); stringBuilder.AppendLine($" {item2.Key,-28} {item2.Value}/{_settings.ViolationThreshold}{text2}"); } } else { stringBuilder.AppendLine(" (no recorded violations)"); } bool flag = false; try { ZNet instance = ZNet.instance; foreach (ZNetPeer item3 in ((instance != null) ? instance.GetPeers() : null) ?? new List()) { if (item3 != null && string.Equals(GetPeerPlatformId(item3), item, StringComparison.Ordinal)) { flag = true; break; } } } catch { } stringBuilder.AppendLine(" online=" + (flag ? "yes" : "no") + " admin=" + (IsAdmin(item) ? "yes" : "no")); } return stringBuilder.ToString().TrimEnd(Array.Empty()); } private List ResolvePlayerQuery(string query) { List list = new List(); if (string.IsNullOrWhiteSpace(query)) { return list; } query = query.Trim(); if (query.All(char.IsDigit) && query.Length >= 8) { list.Add(query); return list; } if (_registrations == null) { return list; } string value = query.ToLowerInvariant(); foreach (KeyValuePair> registration in _registrations) { if (registration.Value == null) { continue; } foreach (string item in registration.Value) { if (item != null && item.ToLowerInvariant().Contains(value)) { if (!list.Contains(registration.Key)) { list.Add(registration.Key); } break; } } } return list; } private string CmdViolations(string[] args) { int count = 10; if (args.Length != 0 && int.TryParse(args[0], out var result) && result > 0) { count = Math.Min(result, 25); } if (_violations.Count == 0) { return "No recorded violations."; } var list = (from kv in _violations select new { SteamId = kv.Key, Total = (kv.Value?.Values.Sum() ?? 0), Counted = (kv.Value?.Where((KeyValuePair x) => RuleCountsAsViolation(x.Key)).Sum((KeyValuePair x) => x.Value) ?? 0) } into x where x.Total > 0 orderby x.Counted descending, x.Total descending select x).Take(count).ToList(); if (list.Count == 0) { return "No recorded violations."; } StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine($"[ServerGuard] top {list.Count} violators:"); foreach (var item in list) { stringBuilder.AppendLine($" {FormatPlayer(item.SteamId),-50} counted={item.Counted} total={item.Total}"); } return stringBuilder.ToString().TrimEnd(Array.Empty()); } private string CmdPardon(string[] args) { if (args.Length == 0) { return "Usage: sg pardon "; } List list = ResolvePlayerQuery(args[0]); if (list.Count == 0) { return "No SteamID matched `" + args[0] + "`."; } if (list.Count > 1) { return $"Ambiguous - {list.Count} players match. Pass an exact SteamID."; } string text = list[0]; if (!_violations.TryGetValue(text, out var value) || value == null || value.Count == 0) { return FormatPlayer(text) + " had no recorded violations."; } int num = value.Values.Sum(); _violations.Remove(text); SaveViolations(); return $"Cleared {num} violation entries for {FormatPlayer(text)}."; } private string CmdKick(string[] args, string callerSteamId) { if (args.Length == 0) { return "Usage: sg kick [reason]"; } List list = ResolvePlayerQuery(args[0]); if (list.Count == 0) { return "No SteamID matched `" + args[0] + "`."; } if (list.Count > 1) { return $"Ambiguous - {list.Count} players match. Pass an exact SteamID."; } string text = list[0]; if (string.Equals(text, callerSteamId, StringComparison.Ordinal)) { return "Refusing to kick yourself."; } ZNetPeer val = null; try { ZNet instance = ZNet.instance; foreach (ZNetPeer item in ((instance != null) ? instance.GetPeers() : null) ?? new List()) { if (item != null && string.Equals(GetPeerPlatformId(item), text, StringComparison.Ordinal)) { val = item; break; } } } catch { } if (val == null) { return FormatPlayer(text) + " is not currently online."; } string text2 = ((args.Length > 1) ? string.Join(" ", args.Skip(1)) : "Kicked by admin."); TryKick(val, text2); return "Kicked " + FormatPlayer(text) + ". Reason: " + text2; } private string CmdBuild(string[] args, string actionFilter, string label) { if (args.Length == 0) { return "Usage: sg " + ((actionFilter == null) ? "build" : ((actionFilter == "destroy") ? "destroyed" : "placed")) + " at|by|today ... (see sg help)"; } string text = args[0].ToLowerInvariant(); string[] args2 = args.Skip(1).ToArray(); return text switch { "at" => CmdBuildAt(args2, actionFilter, label), "by" => CmdBuildBy(args2, actionFilter, label), "today" => CmdBuildToday(args2, actionFilter, label), _ => "Unknown subcommand `" + text + "`. Try `at`, `by`, or `today`.", }; } private string CmdBuildAt(string[] args, string actionFilter, string label) { if (args.Length < 2) { return "Usage: sg build at [radius=50] [days=7]"; } CultureInfo invariantCulture = CultureInfo.InvariantCulture; if (!float.TryParse(args[0], NumberStyles.Float, invariantCulture, out var result)) { return "Bad x: " + args[0]; } if (!float.TryParse(args[1], NumberStyles.Float, invariantCulture, out var result2)) { return "Bad z: " + args[1]; } float result3 = 50f; if (args.Length > 2 && !float.TryParse(args[2], NumberStyles.Float, invariantCulture, out result3)) { return "Bad radius: " + args[2]; } int result4 = 7; if (args.Length > 3 && !int.TryParse(args[3], out result4)) { return "Bad days: " + args[3]; } List list = LoadBuildLogRows(result4); List list2 = new List(); foreach (string[] item in list) { if (item.Length >= 8 && (actionFilter == null || string.Equals(item[1], actionFilter, StringComparison.OrdinalIgnoreCase)) && TryParseXZ(item[5], item[7], out var x, out var z) && !(Distance2D(x, z, result, result2) > result3)) { list2.Add(item); } } list2.Sort((string[] a, string[] b) => string.CompareOrdinal(b[0], a[0])); if (list2.Count > 20) { list2 = list2.GetRange(0, 20); } if (list2.Count == 0) { return $"No {label}(s) within {result3:F0}m of ({result:F0}, {result2:F0}) in the last {result4}d."; } StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine($"[ServerGuard] {list2.Count} {label}(s) within {result3:F0}m of ({result:F0}, {result2:F0}) in last {result4}d:"); foreach (string[] item2 in list2) { stringBuilder.AppendLine(FormatBuildRowShort(item2)); } return stringBuilder.ToString().TrimEnd(Array.Empty()); } private string CmdBuildBy(string[] args, string actionFilter, string label) { if (args.Length == 0) { return "Usage: sg " + ((actionFilter == null) ? "build" : ((actionFilter == "destroy") ? "destroyed" : "placed")) + " by [days=7]"; } string query = args[0]; int result = 7; if (args.Length > 1 && !int.TryParse(args[1], out result)) { return "Bad days: " + args[1]; } List resolved = ResolvePlayerQuery(query); List list = (from r in LoadBuildLogRows(result) where r.Length >= 8 && (resolved.Any((string s) => string.Equals(s, r[2], StringComparison.Ordinal)) || (r[3] ?? "").IndexOf(query, StringComparison.OrdinalIgnoreCase) >= 0) where actionFilter == null || string.Equals(r[1], actionFilter, StringComparison.OrdinalIgnoreCase) orderby r[0] descending select r).Take(20).ToList(); if (list.Count == 0) { return $"No {label}(s) by `{query}` in the last {result}d."; } StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine($"[ServerGuard] {list.Count} {label}(s) by `{query}` in last {result}d:"); foreach (string[] item in list) { stringBuilder.AppendLine(FormatBuildRowShort(item)); } return stringBuilder.ToString().TrimEnd(Array.Empty()); } private string CmdBuildToday(string[] args, string actionFilter, string label) { int num = 10; if (args.Length != 0 && int.TryParse(args[0], out var result) && result > 0) { num = Math.Min(result, 20); } List list = LoadBuildLogRows(1); if (actionFilter != null) { list = list.Where((string[] r) => r.Length >= 8 && string.Equals(r[1], actionFilter, StringComparison.OrdinalIgnoreCase)).ToList(); } if (list.Count == 0) { return "No " + label + "(s) today."; } List list2 = list.Skip(Math.Max(0, list.Count - num)).ToList(); StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine($"[ServerGuard] last {list2.Count} {label}(s) today:"); foreach (string[] item in list2) { stringBuilder.AppendLine(FormatBuildRowShort(item)); } return stringBuilder.ToString().TrimEnd(Array.Empty()); } private List LoadBuildLogRows(int days) { List list = new List(); if (!Directory.Exists(BuildLogDir)) { return list; } DateTime date = DateTime.UtcNow.Date; for (int num = days - 1; num >= 0; num--) { DateTime dateTime = date.AddDays(-num); string path = Path.Combine(BuildLogDir, $"{dateTime:yyyy-MM-dd}.csv"); if (!File.Exists(path)) { continue; } string[] array; try { array = File.ReadAllLines(path); } catch { continue; } for (int i = 1; i < array.Length; i++) { string[] array2 = ParseCsvLine(array[i]); if (array2.Length >= 8) { list.Add(array2); } } } return list; } private static string[] ParseCsvLine(string line) { List list = new List(); StringBuilder stringBuilder = new StringBuilder(); bool flag = false; for (int i = 0; i < line.Length; i++) { char c = line[i]; if (flag) { if (c == '"') { if (i + 1 < line.Length && line[i + 1] == '"') { stringBuilder.Append('"'); i++; } else { flag = false; } } else { stringBuilder.Append(c); } continue; } switch (c) { case '"': flag = true; break; case ',': list.Add(stringBuilder.ToString()); stringBuilder.Clear(); break; default: stringBuilder.Append(c); break; } } list.Add(stringBuilder.ToString()); return list.ToArray(); } private static bool TryParseXZ(string xStr, string zStr, out float x, out float z) { x = 0f; z = 0f; CultureInfo invariantCulture = CultureInfo.InvariantCulture; if (!float.TryParse(xStr, NumberStyles.Float, invariantCulture, out x)) { return false; } if (!float.TryParse(zStr, NumberStyles.Float, invariantCulture, out z)) { return false; } return true; } private static float Distance2D(float px, float pz, float x, float z) { float num = px - x; float num2 = pz - z; return (float)Math.Sqrt(num * num + num2 * num2); } private static string FormatBuildRowShort(string[] r) { string text = ((r[0].Length >= 19) ? r[0].Substring(11, 8) : r[0]); string text2 = r[1].ToUpperInvariant().PadRight(7); string text3 = (string.IsNullOrEmpty(r[3]) ? (r[2] ?? "") : r[3]); if (text3.Length > 18) { text3 = text3.Substring(0, 18); } text3 = text3.PadRight(18); string text4 = (r[4] ?? "").PadRight(22); string text5 = r[5] ?? ""; string text6 = r[7] ?? ""; return " " + text + " " + text2 + " " + text3 + " " + text4 + " (" + text5 + ", " + text6 + ")"; } public void OnPlayerDeathReceived(ZNetPeer peer, string payload) { try { if (peer == null || _settings == null || !_settings.EnableDeathLog || string.IsNullOrWhiteSpace(payload)) { return; } string[] array = payload.Split(new char[1] { '|' }); if (array.Length >= 6) { CultureInfo invariantCulture = CultureInfo.InvariantCulture; if (!float.TryParse(array[0], NumberStyles.Float, invariantCulture, out var result)) { result = 0f; } if (!float.TryParse(array[1], NumberStyles.Float, invariantCulture, out var result2)) { result2 = 0f; } if (!float.TryParse(array[2], NumberStyles.Float, invariantCulture, out var result3)) { result3 = 0f; } string text = (array[3] ?? "").Trim().ToLowerInvariant(); string text2 = (array[4] ?? "").Trim(); string text3 = (array[5] ?? "").Trim(); if (text2.Length > 48) { text2 = text2.Substring(0, 48); } if (text3.Length > 24) { text3 = text3.Substring(0, 24); } string peerPlatformId = GetPeerPlatformId(peer); string text4 = FormatPlayer(peerPlatformId); string text5; switch (text) { case "player": { string text6 = LookupSteamIdByCharName(text2); text5 = (string.IsNullOrEmpty(text6) ? ("killed by **" + text2 + "** (another player)") : ("killed by **" + text2 + "** (" + text6 + ")")); break; } case "creature": text5 = (string.IsNullOrEmpty(text2) ? "killed by a creature" : ("killed by a **" + text2 + "**")); break; case "self": text5 = "took their own life"; break; default: text5 = HumanizeDeathCause(text3); break; } string text7 = $":skull: **{text4}** died at `[{result:F0}, {result3:F0}]` — {text5}"; LogS.LogInfo((object)("[ServerGuard] " + text7)); DiscordChannel target = (IsAdmin(peerPlatformId) ? DiscordChannel.Admin : DiscordChannel.Public); SendDiscordNow(text7, target); } } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] OnPlayerDeathReceived error: {arg}"); } } private string LookupSteamIdByCharName(string charName) { if (string.IsNullOrWhiteSpace(charName)) { return ""; } if (_registrations == null) { return ""; } foreach (KeyValuePair> registration in _registrations) { if (registration.Value == null) { continue; } foreach (string item in registration.Value) { if (string.Equals(item, charName, StringComparison.OrdinalIgnoreCase)) { return registration.Key; } } } return ""; } private string HumanizeDeathCause(string causeHint) { if (string.IsNullOrWhiteSpace(causeHint)) { return "died"; } return causeHint.ToLowerInvariant() switch { "fire" => "burned to death", "frost" => "froze to death", "poison" => "succumbed to poison", "spirit" => "drowned or fell to a spirit", "lightning" => "struck by lightning", "blunt" => "fell to their death", "slash" => "bled out", "pierce" => "bled out", "chop" => "died", "pickaxe" => "died", _ => "died (" + causeHint.ToLowerInvariant() + ")", }; } public void OnAnimationCancelReceived(ZNetPeer peer, string source) { try { if (peer != null && _settings.EnableAnimationCancelGate) { string peerPlatformId = GetPeerPlatformId(peer); string text = FormatPlayer(peerPlatformId); string text2 = (source ?? "").Trim(); if (text2.Length > 32) { text2 = text2.Substring(0, 32); } if (string.IsNullOrWhiteSpace(text2)) { text2 = "unknown"; } if (IsAdmin(peerPlatformId)) { LogS.LogInfo((object)("[ServerGuard] " + text + " (admin) animation-cancel via " + text2 + " - ignoring.")); } else { LogS.LogWarning((object)("[ServerGuard] " + text + " animation-cancel blocked client-side (source: " + text2 + ").")); PostPlayerEvent(":no_entry_sign:", peerPlatformId, "tried to cancel attack", text2); AddViolation(peerPlatformId, "AnimationCancel", text2); } } } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] OnAnimationCancelReceived error: {arg}"); } } public void OnDevcommandAttemptReceived(ZNetPeer peer, string command) { try { if (peer != null && _settings.EnableDevcommandGate) { string peerPlatformId = GetPeerPlatformId(peer); string text = FormatPlayer(peerPlatformId); string text2 = (command ?? "").Trim(); if (text2.Length > 64) { text2 = text2.Substring(0, 64); } if (string.IsNullOrWhiteSpace(text2)) { text2 = "(unknown)"; } if (IsAdmin(peerPlatformId)) { LogS.LogInfo((object)("[ServerGuard] " + text + " (admin) used `" + text2 + "` - bypassing devcommand gate.")); } else { LogS.LogWarning((object)("[ServerGuard] " + text + " attempted blocked command `" + text2 + "` (companion gate blocked it client-side).")); PostPlayerEvent(":no_entry_sign:", peerPlatformId, "tried to use cheats", "`" + text2 + "`"); AddViolation(peerPlatformId, "DevcommandAttempt", text2); } } } catch (Exception arg) { LogS.LogError((object)$"[ServerGuard] OnDevcommandAttemptReceived error: {arg}"); } } private PolicyVerdict ValidateAgainstPolicy(ModManifest manifest) { List list = manifest.Mods ?? new List(); Dictionary dictionary = new Dictionary(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)) { string text = value.Name ?? value.Guid; PolicyVerdict result = default(PolicyVerdict); result.Allowed = false; result.Rule = "BannedMod"; result.Reason = "Disallowed mod present: " + text; result.Detail = text; 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; result2.Detail = requiredMod.Key; return result2; } if (!string.IsNullOrEmpty(requiredMod.Sha256) && !string.Equals(requiredMod.Sha256, value2.Sha256 ?? "", StringComparison.OrdinalIgnoreCase)) { string detail = value2.Name ?? value2.Guid ?? requiredMod.Key; result2 = default(PolicyVerdict); result2.Allowed = false; result2.Rule = "HashMismatch"; result2.Reason = "Required mod hash mismatch: " + requiredMod.Key; result2.Detail = detail; return result2; } } if (!_settings.AllowUnlisted) { Dictionary dictionary2 = new Dictionary(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 text2 = ((!string.IsNullOrEmpty(item2.Name)) ? item2.Name : item2.Guid); result2 = default(PolicyVerdict); result2.Allowed = false; result2.Rule = "DisallowedMod"; result2.Reason = "Unapproved mod: " + text2; result2.Detail = text2; return result2; } if (!string.IsNullOrEmpty(allowedModEntry.Sha256) && !string.Equals(allowedModEntry.Sha256, item2.Sha256 ?? "", StringComparison.OrdinalIgnoreCase)) { string text3 = item2.Name ?? item2.Guid; result2 = default(PolicyVerdict); result2.Allowed = false; result2.Rule = "HashMismatch"; result2.Reason = "Hash pin mismatch: " + text3; result2.Detail = text3; 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 { public static class ModsetFingerprint { public static string ComputeStrict(IEnumerable> entries) { return Sha256Hex(Canonicalize(entries, withHash: true)); } public static string ComputeLoose(IEnumerable> entries) { return Sha256Hex(Canonicalize(entries, withHash: false)); } public static string Short(string fullHex) { if (!string.IsNullOrEmpty(fullHex) && fullHex.Length >= 8) { return fullHex.Substring(0, 8); } return fullHex ?? ""; } private static string Canonicalize(IEnumerable> entries, bool withHash) { if (entries == null) { return ""; } List list = (from e in entries where !string.IsNullOrWhiteSpace(e.Key) select (!withHash) ? (e.Key ?? "").ToLowerInvariant() : ((e.Key ?? "").ToLowerInvariant() + "|" + (e.Value ?? "").ToLowerInvariant())).Distinct(StringComparer.Ordinal).OrderBy((string s) => s, StringComparer.Ordinal).ToList(); if (list.Count != 0) { return string.Join("\n", list); } return ""; } private static string Sha256Hex(string input) { using SHA256 sHA = SHA256.Create(); return BitConverter.ToString(sHA.ComputeHash(Encoding.UTF8.GetBytes(input ?? ""))).Replace("-", "").ToLowerInvariant(); } } [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 Mods = new List(); public string Hmac; public string CanonicalForHmac() { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.Append(SchemaVersion ?? "").Append('|'); stringBuilder.Append(Challenge ?? "").Append('|'); stringBuilder.Append(TimestampUtc).Append('|'); List list = new List(Mods ?? new List()); 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; } } }