Please disclose if any significant portion of your mod was created using AI tools by adding the 'AI Generated' category. Failing to do so may result in the mod being removed from Thunderstore.
Decompiled source of Valheim ServerGuard v1.8.1
Valheim-ServerGuard.dll
Decompiled a week ago
The result has been truncated due to the large size, download it to view full contents!
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 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.8.1.0")] [assembly: AssemblyInformationalVersion("1.8.1")] [assembly: AssemblyProduct("Valheim-ServerGuard")] [assembly: AssemblyTitle("Valheim-ServerGuard")] [assembly: AssemblyVersion("1.8.1.0")] [BepInPlugin("com.taeguk.valheim.serverguard", "Valheim ServerGuard", "1.8.1")] public class Plugin : BaseUnityPlugin { private class PingState { public bool FirstPosted; public List<float> Samples = new List<float>(); } 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<string, bool> CountAsViolation { get; set; } = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase) { ["CompanionMissing"] = false, ["HmacInvalid"] = false, ["ChallengeMismatch"] = false, ["RequiredModMissing"] = false, ["DisallowedMod"] = false, ["BannedMod"] = false, ["HashMismatch"] = false, ["CharacterNameLimitExceeded"] = true, ["DevcommandAttempt"] = true, ["SpeedHack"] = true, ["ConsoleCommandBlocked"] = false, ["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 EnableCheatItemRemoval { get; set; } = true; public List<string> CheatItems { get; set; } = new List<string> { "SwordCheat", "SledgeCheat" }; public bool EnableArrivalShout { get; set; } = true; public bool EnableForceMapPositions { get; set; } public bool ForceMapPositionsExemptAdmins { get; set; } public bool EnableBanLayer { get; set; } = true; public string BanLayerKickMessage { get; set; } = "You are banned from this server."; public bool BanLayerMirrorToVanilla { get; set; } = true; public string ConsoleGuardMode { get; set; } = "restricted"; public bool ConsoleGuardExemptModerators { get; set; } = true; public string ConsoleGuardBindPolicy { get; set; } = "purge"; public List<string> ConsoleBlockedCommands { get; set; } = new List<string>(); public List<string> ConsoleAllowedCommands { get; set; } = new List<string>(); public bool ConsoleGuardReportAttempts { get; set; } = true; 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 ModeratorsDoc { public List<string> moderators { get; set; } = new List<string>(); [YamlMember(Alias = "admins", ApplyNamingConventions = false)] public List<string> admins { get; set; } = new List<string>(); public IEnumerable<string> All() { return (moderators ?? new List<string>()).Concat(admins ?? new List<string>()); } } private class OwnersDoc { public List<string> owners { get; set; } = new List<string>(); } private class BansDoc { public List<BanEntry> bans { get; set; } = new List<BanEntry>(); } internal class BanEntry { public string id { get; set; } = ""; public string reason { get; set; } = ""; public string expires { get; set; } = ""; public string added { get; set; } = ""; public string addedBy { get; set; } = ""; public bool TryGetExpiry(out DateTime utc) { utc = DateTime.MinValue; if (string.IsNullOrWhiteSpace(expires)) { return false; } return DateTime.TryParse(expires, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal, out utc); } public bool IsExpired(DateTime nowUtc) { if (TryGetExpiry(out var utc)) { return nowUtc >= utc; } return false; } } private class AllowedModsDoc { [YamlMember(Alias = "required_mods", ApplyNamingConventions = false)] public List<string> required_mods { get; set; } = new List<string>(); [YamlMember(Alias = "allowed_mods", ApplyNamingConventions = false)] public List<string> allowed_mods { get; set; } = new List<string>(); [YamlMember(Alias = "banned_mods", ApplyNamingConventions = false)] public List<string> banned_mods { get; set; } = new List<string>(); } private class AllowedModEntry { public string Key; public string Sha256; } private class PendingAttestation { public string Challenge; public DateTime SentAt; public string SteamId; public ZNetPeer Peer; } private class DetectionMetrics { public long total_players_checked { get; set; } public long total_mods_detected { get; set; } public long phase1_rpc_detections { get; set; } public long phase2_assembly_detections { get; set; } public long version_keyword_detections { get; set; } public long allowlist_bypasses { get; set; } public long admin_bypasses { get; set; } public long violations_issued { get; set; } public long players_banned { get; set; } public long ban_layer_blocks { get; set; } public long console_blocks { get; set; } public Dictionary<string, long> top_detected_mods { get; set; } = new Dictionary<string, long>(StringComparer.OrdinalIgnoreCase); public DateTime last_updated { get; set; } = DateTime.UtcNow; } private class RegistrationsDoc { public Dictionary<string, List<string>> registrations { get; set; } = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); } private class ViolationsDoc { public Dictionary<string, Dictionary<string, int>> violations { get; set; } = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase); } 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<string> 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), "IsAllowed")] public static class Patch_ZNet_IsAllowed { public static void Postfix(string hostName, string playerName, ref bool __result) { try { if (__result && !((Object)(object)Instance == (Object)null) && !((Object)(object)ZNet.instance == (Object)null) && ZNet.instance.IsServer() && Instance.IsBannedId(hostName, out var entry)) { __result = false; string text = Instance.FormatPlayer(entry.id); string text2 = (string.IsNullOrWhiteSpace(playerName) ? "?" : playerName); LogS.LogWarning((object)("[ServerGuard] Refused banned SteamID at handshake: " + text + " (character '" + text2 + "') — " + entry.reason)); Instance.PostAdminEvent(":no_entry_sign: Blocked banned **" + text + "** at connect — " + entry.reason); if (Instance._settings.EnableMetrics) { Instance._metrics.ban_layer_blocks++; Instance.SaveMetrics(); } } } catch (Exception arg) { ManualLogSource logS = LogS; if (logS != null) { logS.LogError((object)$"[ServerGuard] IsAllowed ban gate error: {arg}"); } } } } [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 peerSocketId = GetPeerSocketId(peer); if (Instance.IsBannedId(peerSocketId, out var entry)) { string text = Instance.FormatPlayer(entry.id); LogS.LogWarning((object)("[ServerGuard] Refused banned SteamID at socket accept: " + text + " — " + entry.reason)); Instance.PostAdminEvent(":no_entry_sign: Blocked banned **" + text + "** at socket accept — " + entry.reason); if (Instance._settings.EnableMetrics) { Instance._metrics.ban_layer_blocks++; Instance.SaveMetrics(); } Instance.DisconnectAsBanned(peer); return; } string peerPlatformId = GetPeerPlatformId(peer); LogS.LogInfo((object)("[ServerGuard] Incoming connection: " + Instance.FormatPlayer(peerPlatformId))); peer.m_rpc.Register<string>("ServerGuard_Manifest", (Action<ZRpc, string>)delegate(ZRpc rpc, string json) { Instance.OnManifestReceived(peer, json); }); peer.m_rpc.Register<string>("ServerGuard_DevcommandAttempt", (Action<ZRpc, string>)delegate(ZRpc rpc, string command) { Instance.OnDevcommandAttemptReceived(peer, command); }); peer.m_rpc.Register<string>("ServerGuard_AnimationCancelAttempt", (Action<ZRpc, string>)delegate(ZRpc rpc, string source) { Instance.OnAnimationCancelReceived(peer, source); }); peer.m_rpc.Register<string>("ServerGuard_SkillReport", (Action<ZRpc, string>)delegate(ZRpc rpc, string payload) { Instance.OnSkillReportReceived(peer, payload); }); peer.m_rpc.Register<string>("ServerGuard_PlayerDeath", (Action<ZRpc, string>)delegate(ZRpc rpc, string payload) { Instance.OnPlayerDeathReceived(peer, payload); }); peer.m_rpc.Register<string>("ServerGuard_Chat", (Action<ZRpc, string>)delegate(ZRpc rpc, string payload) { Instance.OnChatReceived(peer, payload); }); peer.m_rpc.Register<string>("ServerGuard_BuildPlace", (Action<ZRpc, string>)delegate(ZRpc rpc, string payload) { Instance.OnBuildPlaceReceived(peer, payload); }); peer.m_rpc.Register<string>("ServerGuard_BuildDestroy", (Action<ZRpc, string>)delegate(ZRpc rpc, string payload) { Instance.OnBuildDestroyReceived(peer, payload); }); peer.m_rpc.Register<string>("ServerGuard_AdminCommand", (Action<ZRpc, string>)delegate(ZRpc rpc, string command) { Instance.OnAdminCommandReceived(peer, command); }); Instance.SendArrivalShoutPolicy(peer); Instance.SendConsolePolicy(peer); if (Instance.IsAdmin(peerPlatformId)) { LogS.LogInfo((object)("[ServerGuard] " + Instance.FormatPlayer(peerPlatformId) + " is " + Instance.RoleOf(peerPlatformId) + " - skipping attestation challenge.")); if (Instance._settings.EnableMetrics) { Instance._metrics.admin_bypasses++; Instance.SaveMetrics(); } Instance.PostPlayerEvent(Instance.IsOwner(peerPlatformId) ? ":crown:" : ":shield:", peerPlatformId, Instance.IsOwner(peerPlatformId) ? "joined as owner" : "joined as moderator"); } else { if (Instance._settings.EnableMetrics) { Instance._metrics.total_players_checked++; Instance.SaveMetrics(); } string text2 = Instance.GenerateChallenge(); Instance.RegisterPending(peer, peerPlatformId, text2); peer.m_rpc.Invoke("ServerGuard_RequestManifest", new object[1] { text2 }); ((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<string>(); Instance._registrations[peerPlatformId] = value; } if (value.Any((string n) => string.Equals(n, charName, StringComparison.Ordinal))) { Instance.SendCheatItemRemovalIfEnabled(val); 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}'"); Instance.SendCheatItemRemovalIfEnabled(val); 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}"); } } } [HarmonyPatch(typeof(ZNet), "RPC_ServerSyncedPlayerData")] public static class Patch_ForceMapPositions { public static void Postfix(ZNet __instance, ZRpc rpc) { try { Settings settings = Instance?._settings; if (settings != null && settings.EnableForceMapPositions && !((Object)(object)ZNet.instance == (Object)null) && ZNet.instance.IsServer()) { ZNetPeer val = ResolvePeerFromRpc(__instance, rpc); if (val != null) { Instance.ApplyForcedMapPosition(val); } } } catch (Exception ex) { ManualLogSource logS = LogS; if (logS != null) { logS.LogWarning((object)("[ServerGuard] Force-map-position patch error: " + ex.Message)); } } } } private class SkillOverflowState { public Dictionary<string, double> LastReportedLevel = new Dictionary<string, double>(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<ZNetPeer> 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<string> _buffer = new Queue<string>(); private static readonly HttpClient _http = new HttpClient(); private bool _isFlushing; private const int MaxDiscordLength = 2000; private const int MaxPostLength = 1800; public DiscordLogListener(string webhook, string prefixTag, string allowedSourceName) { _webhook = webhook?.Trim(); _prefix = (string.IsNullOrWhiteSpace(prefixTag) ? "[ServerGuard]" : prefixTag.Trim()); _allowedSourceName = allowedSourceName ?? string.Empty; _flushTimer = new Timer(2000.0); _flushTimer.AutoReset = true; _flushTimer.Elapsed += delegate { FlushIfNeeded(); }; _flushTimer.Start(); } public void LogEvent(object sender, LogEventArgs eventArgs) { //IL_0041: Unknown result type (might be due to invalid IL or missing references) //IL_0046: Unknown result type (might be due to invalid IL or missing references) try { if (string.IsNullOrWhiteSpace(_webhook)) { return; } ILogSource source = eventArgs.Source; if (!string.Equals(((source != null) ? source.SourceName : null) ?? string.Empty, _allowedSourceName, StringComparison.Ordinal)) { return; } string text = ((object)eventArgs.Level/*cast due to .constrained prefix*/).ToString().ToUpperInvariant(); string text2 = eventArgs.Data?.ToString() ?? ""; string item = (_prefix + " [" + text + "] " + text2).Trim(); lock (_buffer) { _buffer.Enqueue(item); if (_buffer.Count > 1000) { _buffer.Dequeue(); } } } catch { } } private async void FlushIfNeeded() { if (string.IsNullOrWhiteSpace(_webhook) || _isFlushing) { return; } List<string> list = null; lock (_buffer) { if (_buffer.Count == 0) { return; } list = new List<string>(_buffer); _buffer.Clear(); } _isFlushing = true; try { StringBuilder chunk = new StringBuilder(); foreach (string line in list) { int num = line.Length + 1; if (chunk.Length + num > 1800) { await PostAsync(chunk.ToString()); chunk.Clear(); } chunk.AppendLine((line.Length > 2000) ? line.Substring(0, 2000) : line); } if (chunk.Length > 0) { await PostAsync(chunk.ToString()); } } catch { } finally { _isFlushing = false; } } private async Task PostAsync(string content) { if (!string.IsNullOrWhiteSpace(content)) { string text = JsonConvert.SerializeObject((object)new { content }); StringContent req = new StringContent(text, Encoding.UTF8, "application/json"); try { await _http.PostAsync(_webhook, (HttpContent)(object)req); } finally { ((IDisposable)req)?.Dispose(); } } } public void Dispose() { try { _flushTimer?.Stop(); _flushTimer?.Dispose(); } catch { } } } [HarmonyPatch] public static class Patch_SetRandomEvent { private static MethodBase TargetMethod() { try { MethodInfo? method = typeof(RandEventSystem).GetMethod("SetRandomEvent", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic); if (method == null) { ManualLogSource logS = LogS; if (logS != null) { logS.LogWarning((object)"[ServerGuard] RandEventSystem.SetRandomEvent not found — raid start logging unavailable."); } } return method; } catch (Exception ex) { ManualLogSource logS2 = LogS; if (logS2 != null) { logS2.LogWarning((object)("[ServerGuard] Failed to locate RandEventSystem.SetRandomEvent: " + ex.Message)); } return null; } } public static void Postfix(RandomEvent ev, Vector3 pos) { //IL_0038: Unknown result type (might be due to invalid IL or missing references) try { if (!((Object)(object)ZNet.instance == (Object)null) && ZNet.instance.IsServer() && ev != null && !string.IsNullOrEmpty(ev.m_name)) { Instance.OnRaidStarted(ev.m_name, pos); } } catch (Exception ex) { ManualLogSource logS = LogS; if (logS != null) { logS.LogError((object)("[ServerGuard] SetRandomEvent patch error: " + ex.Message)); } } } } [HarmonyPatch(typeof(RandEventSystem), "ResetRandomEvent")] public static class Patch_ResetRandomEvent { public static void Prefix() { try { if (!((Object)(object)ZNet.instance == (Object)null) && ZNet.instance.IsServer()) { Instance.OnRaidEnded(); } } catch (Exception ex) { ManualLogSource logS = LogS; if (logS != null) { logS.LogError((object)("[ServerGuard] ResetRandomEvent patch error: " + ex.Message)); } } } } 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 ModeratorsYaml = Path.Combine(ConfDir, "moderators.yaml"); private static readonly string OwnersYaml = Path.Combine(ConfDir, "owners.yaml"); private static readonly string LegacyAdminsYaml = Path.Combine(ConfDir, "admins.yaml"); private static readonly string BansYaml = Path.Combine(ConfDir, "bans.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 static ISerializer _yamlOutFull; private Settings _settings; private HashSet<string> _admins = new HashSet<string>(StringComparer.OrdinalIgnoreCase); private HashSet<string> _owners = new HashSet<string>(StringComparer.OrdinalIgnoreCase); private DetectionMetrics _metrics; private Dictionary<string, BanEntry> _bans = new Dictionary<string, BanEntry>(StringComparer.OrdinalIgnoreCase); private readonly object _bansLock = new object(); private List<AllowedModEntry> _requiredMods = new List<AllowedModEntry>(); private List<AllowedModEntry> _allowedMods = new List<AllowedModEntry>(); private List<AllowedModEntry> _bannedMods = new List<AllowedModEntry>(); private string _modsetFingerprintStrict = ""; private string _modsetFingerprintLoose = ""; private Dictionary<long, PendingAttestation> _pending = new Dictionary<long, PendingAttestation>(); private readonly object _pendingLock = new object(); private readonly HashSet<long> _suppressLogoutFor = new HashSet<long>(); private readonly Dictionary<long, PingState> _pingState = new Dictionary<long, PingState>(); private static FieldInfo _rpcPingField; private readonly Dictionary<long, SpeedState> _speedState = new Dictionary<long, SpeedState>(); private Dictionary<string, List<string>> _registrations = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); private Dictionary<string, Dictionary<string, int>> _violations = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase); private const string RULE_COMPANION_MISSING = "CompanionMissing"; private const string RULE_HMAC_INVALID = "HmacInvalid"; private const string RULE_CHALLENGE_MISMATCH = "ChallengeMismatch"; private const string RULE_REQUIRED_MOD_MISSING = "RequiredModMissing"; private const string RULE_DISALLOWED_MOD = "DisallowedMod"; private const string RULE_BANNED_MOD = "BannedMod"; private const string RULE_HASH_MISMATCH = "HashMismatch"; private const string RULE_CHAR_NAME_LIMIT = "CharacterNameLimitExceeded"; private const string RULE_DEVCOMMAND_ATTEMPT = "DevcommandAttempt"; private const string RULE_CONSOLE_COMMAND = "ConsoleCommandBlocked"; 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[15] { "CompanionMissing", "HmacInvalid", "ChallengeMismatch", "RequiredModMissing", "DisallowedMod", "BannedMod", "HashMismatch", "CharacterNameLimitExceeded", "DevcommandAttempt", "ConsoleCommandBlocked", "SpeedHack", "IllegalItem", "StackOverflow", "AnimationCancel", "SkillOverflow" }; private FileSystemWatcher _watchSettings; private FileSystemWatcher _watchAdmins; private FileSystemWatcher _watchOwners; private FileSystemWatcher _watchAllowed; private FileSystemWatcher _watchBans; private readonly Dictionary<string, DateTime> _lastSeenWrite = new Dictionary<string, DateTime>(); private string _currentRaidName; private Vector3 _currentRaidPos = Vector3.zero; private bool _raidPaused; private Coroutine _raidMonitorCoroutine; private static readonly Dictionary<string, string> RaidDisplayNames = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) { ["army_eikthyr"] = "Eikthyr Rallies His Herd", ["army_theelder"] = "The Forest Is Moving", ["army_bonemass"] = "A Foul Smell From the Swamp", ["army_moder"] = "A Cold Wind Blows From the Mountains", ["army_goblin"] = "The Horde Is Attacking", ["skeletons"] = "Skeleton Surprise", ["blobs"] = "The Ooze Bomb", ["foresttrolls"] = "The Ground Is Shaking", ["wolves"] = "You Are Being Hunted", ["bats"] = "Bat Attack", ["surtlings"] = "It's Raining Fire", ["army_gjall"] = "Mistlands Quiver", ["army_gsecret"] = "Seeker Swarm", ["army_dverger"] = "Dverger Invasion", ["army_charred"] = "Charred Assault", ["army_fallen"] = "The Fallen March", ["army_asksvin"] = "Asksvin Attack" }; 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<string, int> _summaryKickReasons = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase); private int _summaryLeaves; private readonly Dictionary<long, SkillOverflowState> _skillOverflowState = new Dictionary<long, SkillOverflowState>(); private readonly ConditionalWeakTable<WearNTear, LastHitBox> _lastHitOnPiece = new ConditionalWeakTable<WearNTear, LastHitBox>(); private static FieldInfo _hitAttackerField; private const int AdminReplyMaxLines = 25; private const int BuildQueryDefaultDays = 7; private const int BuildQueryMaxResults = 20; private static readonly HashSet<string> _animationCancelIgnoredSources = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "sheathe" }; private static readonly string[] VALID_CONSOLE_MODES = new string[4] { "open", "restricted", "whitelist", "disabled" }; private static readonly string[] VALID_BIND_POLICIES = new string[4] { "allow", "block", "purge", "wipe" }; private static string GetRaidDisplayName(string internalName) { if (string.IsNullOrEmpty(internalName)) { return internalName; } if (!RaidDisplayNames.TryGetValue(internalName, out var value)) { return internalName; } return value; } 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_004e: Unknown result type (might be due to invalid IL or missing references) //IL_005d: Expected O, but got Unknown //IL_00af: Unknown result type (might be due to invalid IL or missing references) //IL_00b9: Expected O, but got Unknown Instance = this; LogS = ((BaseUnityPlugin)this).Logger; _yamlIn = ((BuilderSkeleton<DeserializerBuilder>)new DeserializerBuilder()).WithNamingConvention(CamelCaseNamingConvention.Instance).IgnoreUnmatchedProperties().Build(); _yamlOut = ((BuilderSkeleton<SerializerBuilder>)new SerializerBuilder()).WithNamingConvention(CamelCaseNamingConvention.Instance).ConfigureDefaultValuesHandling((DefaultValuesHandling)2).Build(); _yamlOutFull = ((BuilderSkeleton<SerializerBuilder>)new SerializerBuilder()).WithNamingConvention(CamelCaseNamingConvention.Instance).Build(); EnsureFoldersAndFiles(); LoadSettings(); TopUpSettingsFile(); LoadOwners(); LoadAdmins(); LoadBans(); LoadAllowedMods(); LoadRegistrations(); LoadViolations(); LoadMetrics(); StartWatchers(); _harmony = new Harmony("com.taeguk.valheim.serverguard"); _harmony.PatchAll(); LogS.LogInfo((object)("[ServerGuard] Loaded (v1.8.1). 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}. " + $"Owners: {_owners.Count}, Moderators: {_admins.Count}. " + "Metrics: " + (_settings.EnableMetrics ? "ON" : "OFF"))); if (_owners.Count == 0) { LogS.LogWarning((object)"[ServerGuard] No owner configured. Add your SteamID64 to conf/owners.yaml to be exempt from every rule."); } 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<SelfTestResult> 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.8.1 enforce=" + (_settings.Enforce ? "ON" : "off") + " requireHmac=" + (_settings.RequireHmac ? "ON" : "off") + " " + $"req/allow/ban={_requiredMods.Count}/{_allowedMods.Count}/{_bannedMods.Count} " + "modset=`" + ModsetFingerprint.Short(_modsetFingerprintLoose) + "`"); SendDiscordNow(":hourglass_flowing_sand: **Server is starting...**"); ((MonoBehaviour)this).StartCoroutine(ServerReadyWatcher()); _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")); } if (_settings.EnableForceMapPositions) { LogS.LogInfo((object)("[ServerGuard] Forced map positions enabled exemptAdmins=" + (_settings.ForceMapPositionsExemptAdmins ? "yes" : "no"))); } } private static bool IsServerReadyForPlayers() { try { ZNet instance = ZNet.instance; if ((Object)(object)instance == (Object)null || !instance.IsServer()) { return false; } ZoneSystem instance2 = ZoneSystem.instance; if ((Object)(object)instance2 == (Object)null) { return false; } return instance2.LocationsGenerated; } catch { return false; } } private IEnumerator ServerReadyWatcher() { float deadline = Time.realtimeSinceStartup + 900f; while (Time.realtimeSinceStartup < deadline) { if (IsServerReadyForPlayers()) { LogS.LogInfo((object)"[ServerGuard] Server is ready for players."); SendDiscordNow(":white_check_mark: **The server has started, you may now login.**"); yield break; } yield return (object)new WaitForSeconds(1f); } LogS.LogWarning((object)"[ServerGuard] Server-ready watcher timed out after 15 minutes; skipping the public 'server started' post."); PostAdminEvent(":warning: Server-ready watcher timed out - no public \"server started\" message was sent. Check whether world generation finished."); } private void PostShutdownNoticeBlocking() { //IL_0039: Unknown result type (might be due to invalid IL or missing references) //IL_003f: Expected O, but got Unknown //IL_003f: Unknown result type (might be due to invalid IL or missing references) //IL_0044: Unknown result type (might be due to invalid IL or missing references) //IL_0059: Expected O, but got Unknown try { string text = _settings?.discordWebhookUrl; if (string.IsNullOrWhiteSpace(text)) { return; } StringContent val = new StringContent(JsonConvert.SerializeObject((object)new { content = ":octagonal_sign: **Server is shutting down.**" }), Encoding.UTF8, "application/json"); try { HttpClient val2 = new HttpClient { Timeout = TimeSpan.FromSeconds(5.0) }; try { val2.PostAsync(text, (HttpContent)(object)val).GetAwaiter().GetResult(); } finally { ((IDisposable)val2)?.Dispose(); } } finally { ((IDisposable)val)?.Dispose(); } } catch (Exception ex) { ManualLogSource logS = LogS; if (logS != null) { logS.LogWarning((object)("[ServerGuard] Shutdown notice failed: " + ex.Message)); } } } private void OnDestroy() { PostShutdownNoticeBlocking(); 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.8.1)"); 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) reported by the companion"); stringBuilder.AppendLine("# 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("# Sheathing is NOT part of this rule - weapon swaps,"); stringBuilder.AppendLine("# looting and building all holster the weapon."); 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("# Cheat item removal:"); stringBuilder.AppendLine("# enableCheatItemRemoval - if true, the companion strips the items listed in"); stringBuilder.AppendLine("# cheatItems from any non-admin player's inventory on login."); stringBuilder.AppendLine("# cheatItems - prefab names to remove (default: SwordCheat, SledgeCheat)."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Arrival shout:"); stringBuilder.AppendLine("# enableArrivalShout - vanilla makes every player shout \"I have arrived!\" the"); stringBuilder.AppendLine("# first time they spawn in. Set to false and the companion"); stringBuilder.AppendLine("# swallows it - useful when the server already announces"); stringBuilder.AppendLine("# logins and the shout is just noise. Players can still"); stringBuilder.AppendLine("# shout manually. Default true (vanilla behaviour)."); stringBuilder.AppendLine("# Hot-reloads: online players pick up the change at once."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Forced map positions:"); stringBuilder.AppendLine("# enableForceMapPositions - if true, every player is permanently visible on"); stringBuilder.AppendLine("# everyone's map, regardless of their own"); stringBuilder.AppendLine("# 'public position' minimap toggle. Enforced"); stringBuilder.AppendLine("# server-side, so a modified client can't opt out."); stringBuilder.AppendLine("# Default false (vanilla behaviour: each player"); stringBuilder.AppendLine("# chooses). Takes effect within ~2s of a reload -"); stringBuilder.AppendLine("# no restart needed."); stringBuilder.AppendLine("# forceMapPositionsExemptAdmins - if true, moderators keep their own"); stringBuilder.AppendLine("# toggle and can stay hidden. Default false."); 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("# Privilege tiers (two separate files, no setting needed):"); stringBuilder.AppendLine("# conf/owners.yaml - OWNER. Exempt from every rule in this mod,"); stringBuilder.AppendLine("# unconditionally. Cannot be kicked or banned by"); stringBuilder.AppendLine("# ServerGuard. Keep this list to yourself."); stringBuilder.AppendLine("# conf/moderators.yaml"); stringBuilder.AppendLine("# - MODERATOR (staff). Runs `sg` commands and skips the"); stringBuilder.AppendLine("# attestation, devcommand, speed, character-limit and"); stringBuilder.AppendLine("# console checks. Still subject to the ban layer."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Ban layer:"); stringBuilder.AppendLine("# enableBanLayer - independent SteamID denylist held in conf/bans.yaml."); stringBuilder.AppendLine("# Enforced inside the Valheim handshake (ZNet.IsAllowed)"); stringBuilder.AppendLine("# instead of on the 5-second sweep vanilla uses, so a"); stringBuilder.AppendLine("# banned player is refused before they can spawn."); stringBuilder.AppendLine("# banLayerKickMessage - text shown to a refused player."); stringBuilder.AppendLine("# banLayerMirrorToVanilla - also write bans into Valheim's own banlist.txt."); stringBuilder.AppendLine("# Manage with: sg ban / sg unban / sg bans"); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Console guard:"); stringBuilder.AppendLine("# consoleGuardMode - 'open' no gating"); stringBuilder.AppendLine("# 'restricted' block risky + cheat-flagged commands (default)"); stringBuilder.AppendLine("# 'whitelist' block everything except consoleAllowedCommands"); stringBuilder.AppendLine("# 'disabled' the F5 console cannot be opened at all"); stringBuilder.AppendLine("# consoleGuardExemptModerators"); stringBuilder.AppendLine("# - moderators (moderators.yaml) keep full console access."); stringBuilder.AppendLine("# Note: `sg` commands are typed in the console, so"); stringBuilder.AppendLine("# setting this false under 'disabled' removes the sg"); stringBuilder.AppendLine("# interface for moderators too."); stringBuilder.AppendLine("# Owners (owners.yaml) are ALWAYS exempt - this"); stringBuilder.AppendLine("# setting does not apply to them."); stringBuilder.AppendLine("# consoleGuardBindPolicy - 'allow' | 'block' | 'purge' (default) | 'wipe'."); stringBuilder.AppendLine("# Key binds are the sharpest edge in the console:"); stringBuilder.AppendLine("# Valheim runs them with the context check skipped and"); stringBuilder.AppendLine("# persists them client-side, so a bind made offline"); stringBuilder.AppendLine("# still fires on your server. 'purge' clears them for"); stringBuilder.AppendLine("# the session; 'wipe' also erases them from disk."); stringBuilder.AppendLine("# consoleBlockedCommands - extra command names to block (mode: restricted)."); stringBuilder.AppendLine("# consoleAllowedCommands - the permitted set (mode: whitelist)."); stringBuilder.AppendLine("# consoleGuardReportAttempts - log/post/count blocked attempts."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine(_yamlOutFull.Serialize((object)settings)); File.WriteAllText(SettingsYaml, stringBuilder.ToString()); } MigrateAdminsToModerators(); if (!File.Exists(ModeratorsYaml)) { WriteModeratorsFile(new List<string>()); } if (!File.Exists(OwnersYaml)) { StringBuilder stringBuilder2 = new StringBuilder(); stringBuilder2.AppendLine("# OWNER list: one SteamID64 per entry. Normally just you."); stringBuilder2.AppendLine("#"); stringBuilder2.AppendLine("# An owner is exempt from EVERY rule in this mod, unconditionally. There is no"); stringBuilder2.AppendLine("# setting to make a rule apply to an owner. Specifically, an owner:"); stringBuilder2.AppendLine("# - is never kicked or banned by ServerGuard, and an entry in bans.yaml"); stringBuilder2.AppendLine("# matching an owner is ignored"); stringBuilder2.AppendLine("# - never accrues violation strikes, so can never hit the auto-ban threshold"); stringBuilder2.AppendLine("# - skips the mod-manifest attestation entirely"); stringBuilder2.AppendLine("# - is never speed-checked, skill-capped or animation-cancel checked"); stringBuilder2.AppendLine("# - is never subject to the character limit or cheat-item removal"); stringBuilder2.AppendLine("# - has full console access regardless of consoleGuardMode, and keeps its"); stringBuilder2.AppendLine("# key binds regardless of consoleGuardBindPolicy"); stringBuilder2.AppendLine("# - is exempt from forced map positions"); stringBuilder2.AppendLine("# - has full `sg` command access"); stringBuilder2.AppendLine("#"); stringBuilder2.AppendLine("# Owners do NOT need to be listed in moderators.yaml as well - the owner tier"); stringBuilder2.AppendLine("# already includes everything moderators can do."); stringBuilder2.AppendLine("#"); stringBuilder2.AppendLine("# Keep this list as short as it can possibly be. Anyone here is invisible to"); stringBuilder2.AppendLine("# every check the mod performs."); stringBuilder2.AppendLine("#"); stringBuilder2.AppendLine("# TO ADD YOURSELF: delete the '#' on the line below and put your SteamID64 in."); stringBuilder2.AppendLine(); stringBuilder2.AppendLine("owners:"); stringBuilder2.AppendLine("# - \"76561198000000000\""); File.WriteAllText(OwnersYaml, stringBuilder2.ToString()); } if (!File.Exists(BansYaml)) { SaveBans(); } TryRenameLegacy(LegacyIgnoreModsYaml, LegacyIgnoreModsYaml + ".legacy"); TryRenameLegacy(LegacyModPatternsYaml, LegacyModPatternsYaml + ".legacy"); if (!File.Exists(AllowedModsYaml)) { StringBuilder stringBuilder3 = new StringBuilder(); stringBuilder3.AppendLine("# ServerGuard allowed_mods.yaml (v1.3+)"); stringBuilder3.AppendLine("#"); stringBuilder3.AppendLine("# Each entry references a mod by its BepInEx plugin GUID (preferred) or display Name."); stringBuilder3.AppendLine("# Optional `|<sha256_hex>` suffix pins the DLL hash; mismatch -> kick."); stringBuilder3.AppendLine("#"); stringBuilder3.AppendLine("# required_mods: every connecting client MUST report all of these in its manifest."); stringBuilder3.AppendLine("# allowed_mods : extra mods the client may run beyond the required set."); stringBuilder3.AppendLine("# banned_mods : if any of these appear in the client manifest, the client is kicked."); stringBuilder3.AppendLine("#"); stringBuilder3.AppendLine("# 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("# <profile>/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 { string text = File.ReadAllText(SettingsYaml); _settings = _yamlIn.Deserialize<Settings>(text) ?? new Settings(); if (string.IsNullOrWhiteSpace(_settings.discordWebhookUrlAdmin)) { string text2 = ReadScalarKey(text, "discordAdminWebhookUrl"); if (!string.IsNullOrWhiteSpace(text2)) { _settings.discordWebhookUrlAdmin = text2; LogS.LogWarning((object)"[ServerGuard] settings.yaml uses the legacy key 'discordAdminWebhookUrl'. Honouring it, but rename it to 'discordWebhookUrlAdmin' - the legacy spelling may go away."); } } if (_settings.CountAsViolation != null && _settings.CountAsViolation.Comparer != StringComparer.OrdinalIgnoreCase) { Dictionary<string, bool> dictionary = new Dictionary<string, bool>(StringComparer.OrdinalIgnoreCase); foreach (KeyValuePair<string, bool> item in _settings.CountAsViolation) { dictionary[item.Key] = item.Value; } _settings.CountAsViolation = dictionary; } 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"); BroadcastArrivalShoutPolicy(); BroadcastConsolePolicy(); SweepBannedPeers(); } } 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 static string ReadScalarKey(string yamlText, string key) { if (string.IsNullOrEmpty(yamlText) || string.IsNullOrEmpty(key)) { return null; } string[] array = yamlText.Split(new char[1] { '\n' }); for (int i = 0; i < array.Length; i++) { string text = array[i].TrimEnd(new char[1] { '\r' }); if (text.Length == 0 || char.IsWhiteSpace(text[0]) || text.TrimStart(Array.Empty<char>()).StartsWith("#")) { continue; } int num = text.IndexOf(':'); if (num <= 0 || !string.Equals(text.Substring(0, num).Trim(), key, StringComparison.Ordinal)) { continue; } string text2 = text.Substring(num + 1).Trim(); if (text2.Length > 0 && text2[0] != '"' && text2[0] != '\'') { int num2 = text2.IndexOf('#'); if (num2 >= 0) { text2 = text2.Substring(0, num2).Trim(); } } if (text2.Length >= 2 && ((text2[0] == '"' && text2[text2.Length - 1] == '"') || (text2[0] == '\'' && text2[text2.Length - 1] == '\''))) { text2 = text2.Substring(1, text2.Length - 2); } return text2; } return null; } private void TopUpSettingsFile() { try { if (_settings == null || !File.Exists(SettingsYaml)) { return; } string text = File.ReadAllText(SettingsYaml); HashSet<string> present = new HashSet<string>(StringComparer.Ordinal); string[] array = text.Split(new char[1] { '\n' }); for (int i = 0; i < array.Length; i++) { string text2 = array[i].TrimEnd(new char[1] { '\r' }); if (text2.Length == 0 || char.IsWhiteSpace(text2[0])) { continue; } string text3 = text2.TrimStart(Array.Empty<char>()); if (!text3.StartsWith("#") && !text3.StartsWith("-")) { int num = text2.IndexOf(':'); if (num > 0) { present.Add(text2.Substring(0, num).Trim()); } } } string text4 = _yamlOutFull.Serialize((object)_settings); List<KeyValuePair<string, List<string>>> list = new List<KeyValuePair<string, List<string>>>(); List<string> list2 = null; array = text4.Split(new char[1] { '\n' }); for (int i = 0; i < array.Length; i++) { string text5 = array[i].TrimEnd(new char[1] { '\r' }); if (text5.Length == 0) { list2?.Add(text5); continue; } if (!char.IsWhiteSpace(text5[0]) && !text5.TrimStart(Array.Empty<char>()).StartsWith("-")) { int num2 = text5.IndexOf(':'); if (num2 > 0) { list2 = new List<string>(); list.Add(new KeyValuePair<string, List<string>>(text5.Substring(0, num2).Trim(), list2)); } } list2?.Add(text5); } List<KeyValuePair<string, List<string>>> list3 = list.Where((KeyValuePair<string, List<string>> b) => !present.Contains(b.Key)).ToList(); if (list3.Count == 0) { return; } StringBuilder stringBuilder = new StringBuilder(); if (!text.EndsWith("\n")) { stringBuilder.AppendLine(); } stringBuilder.AppendLine(); stringBuilder.AppendLine("# ---------------------------------------------------------------------------"); stringBuilder.AppendLine($"# Settings added automatically on {DateTime.UtcNow:yyyy-MM-dd} because they were missing"); stringBuilder.AppendLine("# from this file. The values below are the ones already in effect, so writing"); stringBuilder.AppendLine("# them changes nothing - they are here so you can see and edit them."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# (Older builds generated settings.yaml with defaults omitted, which hid every"); stringBuilder.AppendLine("# option that defaults to false, 0 or an empty list.)"); stringBuilder.AppendLine("# ---------------------------------------------------------------------------"); foreach (KeyValuePair<string, List<string>> item in list3) { foreach (string item2 in item.Value) { if (item2.Length != 0) { stringBuilder.AppendLine(item2); } } } File.AppendAllText(SettingsYaml, stringBuilder.ToString()); LogS.LogWarning((object)($"[ServerGuard] settings.yaml was missing {list3.Count} option(s) - appended them with their current values: " + string.Join(", ", list3.Select((KeyValuePair<string, List<string>> b) => b.Key)))); } catch (Exception ex) { ManualLogSource logS = LogS; if (logS != null) { logS.LogWarning((object)("[ServerGuard] Could not top up settings.yaml: " + ex.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 { if (!File.Exists(ModeratorsYaml)) { _admins = new HashSet<string>(StringComparer.OrdinalIgnoreCase); LogS.LogWarning((object)"[ServerGuard] moderators.yaml not present - no moderators configured."); return; } string text = File.ReadAllText(ModeratorsYaml); ModeratorsDoc obj = _yamlIn.Deserialize<ModeratorsDoc>(text) ?? new ModeratorsDoc(); HashSet<string> hashSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase); int num = 0; foreach (string item in obj.All()) { string text2 = (item ?? "").Trim(); if (!IsValidSteamId(text2)) { string text3 = ExtractSteamIdFromString(text2); if (IsValidSteamId(text3)) { text2 = text3; } } if (!IsValidSteamId(text2)) { num++; } else { hashSet.Add(text2); } } _admins = hashSet; LogS.LogInfo((object)($"[ServerGuard] moderators.yaml loaded ({_admins.Count} moderator(s)" + ((num > 0) ? $", {num} invalid" : "") + ")")); WarnIfIdsOnlyInComments(ModeratorsYaml, "moderators.yaml", _admins.Count); if (_bootCompleted) { PostAdminEvent($":arrows_counterclockwise: moderators.yaml reloaded ({_admins.Count} moderator(s))"); BroadcastConsolePolicy(); } } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load moderators.yaml: " + ex.Message)); _admins = new HashSet<string>(StringComparer.OrdinalIgnoreCase); } } private void WarnIfIdsOnlyInComments(string path, string label, int loadedCount) { try { if (loadedCount > 0 || !File.Exists(path)) { return; } string[] array = File.ReadAllLines(path); for (int i = 0; i < array.Length; i++) { string text = array[i].Trim(); if (text.StartsWith("#")) { string text2 = ExtractSteamIdFromString(text); if (IsValidSteamId(text2) && !(text2 == "76561198000000000")) { LogS.LogWarning((object)("[ServerGuard] " + label + " has no entries, but a COMMENTED line contains SteamID " + text2 + ". Remove the leading '#' for it to take effect - as written it does nothing.")); break; } } } } catch { } } private void MigrateAdminsToModerators() { try { if (!File.Exists(LegacyAdminsYaml)) { return; } if (File.Exists(ModeratorsYaml)) { TryRenameLegacy(LegacyAdminsYaml, LegacyAdminsYaml + ".legacy"); return; } List<string> list = new List<string>(); try { foreach (string item in (_yamlIn.Deserialize<ModeratorsDoc>(File.ReadAllText(LegacyAdminsYaml)) ?? new ModeratorsDoc()).All()) { string text = (item ?? "").Trim(); if (!IsValidSteamId(text)) { string text2 = ExtractSteamIdFromString(text); if (IsValidSteamId(text2)) { text = text2; } } if (IsValidSteamId(text) && !list.Contains(text)) { list.Add(text); } } } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Could not parse admins.yaml for migration (" + ex.Message + "). Left it in place - copy your SteamIDs into moderators.yaml manually.")); return; } WriteModeratorsFile(list); TryRenameLegacy(LegacyAdminsYaml, LegacyAdminsYaml + ".legacy"); LogS.LogWarning((object)($"[ServerGuard] Migrated admins.yaml -> moderators.yaml ({list.Count} moderator(s)). " + "The old file is now admins.yaml.legacy and is no longer read. The admin tier was renamed to MODERATOR in 1.7.0; the new owners.yaml is the tier above it.")); } catch (Exception ex2) { LogS.LogError((object)("[ServerGuard] admins.yaml migration failed: " + ex2.Message)); } } private void WriteModeratorsFile(List<string> ids) { StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("# MODERATOR list: one SteamID64 per entry."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Moderators are staff. They can run `sg` commands and are exempt from the"); stringBuilder.AppendLine("# attestation handshake, the devcommand gate, the console guard, the speed check"); stringBuilder.AppendLine("# and the character limit."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# They are NOT exempt from the ban layer and can still be kicked. The tier that"); stringBuilder.AppendLine("# bypasses everything is conf/owners.yaml."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Renamed from admins.yaml in 1.7.0. An `admins:` key is still read here for"); stringBuilder.AppendLine("# convenience if you paste an old file in."); stringBuilder.AppendLine(); if (ids == null || ids.Count == 0) { stringBuilder.AppendLine("# TO ADD SOMEONE: delete the '#' on the line below and put their SteamID64 in."); stringBuilder.AppendLine(); stringBuilder.AppendLine("moderators:"); stringBuilder.AppendLine("# - \"76561198000000000\""); } else { stringBuilder.AppendLine("moderators:"); foreach (string id in ids) { stringBuilder.AppendLine(" - \"" + YamlEscape(id) + "\""); } } File.WriteAllText(ModeratorsYaml, stringBuilder.ToString()); } private void LoadOwners() { try { if (!File.Exists(OwnersYaml)) { _owners = new HashSet<string>(StringComparer.OrdinalIgnoreCase); LogS.LogInfo((object)"[ServerGuard] owners.yaml not present - no owner configured."); return; } OwnersDoc obj = _yamlIn.Deserialize<OwnersDoc>(File.ReadAllText(OwnersYaml)) ?? new OwnersDoc(); HashSet<string> hashSet = new HashSet<string>(StringComparer.OrdinalIgnoreCase); int num = 0; foreach (string item in obj.owners ?? new List<string>()) { string text = (item ?? "").Trim(); if (!IsValidSteamId(text)) { string text2 = ExtractSteamIdFromString(text); if (IsValidSteamId(text2)) { text = text2; } } if (!IsValidSteamId(text)) { num++; } else { hashSet.Add(text); } } _owners = hashSet; LogS.LogInfo((object)($"[ServerGuard] owners.yaml loaded ({_owners.Count} owner(s)" + ((num > 0) ? $", {num} invalid" : "") + ")")); WarnIfIdsOnlyInComments(OwnersYaml, "owners.yaml", _owners.Count); if (_bootCompleted) { PostAdminEvent($":crown: owners.yaml reloaded ({_owners.Count} owner(s))"); BroadcastConsolePolicy(); } } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load owners.yaml: " + ex.Message + ". No owner is configured until this is fixed.")); _owners = new HashSet<string>(StringComparer.OrdinalIgnoreCase); } } private void LoadBans() { try { if (!File.Exists(BansYaml)) { lock (_bansLock) { _bans = new Dictionary<string, BanEntry>(StringComparer.OrdinalIgnoreCase); } LogS.LogInfo((object)"[ServerGuard] bans.yaml not present - ban layer has no entries."); return; } BansDoc obj = _yamlIn.Deserialize<BansDoc>(File.ReadAllText(BansYaml)) ?? new BansDoc(); Dictionary<string, BanEntry> dictionary = new Dictionary<string, BanEntry>(StringComparer.OrdinalIgnoreCase); DateTime utcNow = DateTime.UtcNow; int num = 0; int num2 = 0; foreach (BanEntry item in obj.bans ?? new List<BanEntry>()) { if (item == null) { continue; } string text = (item.id ?? "").Trim(); if (!IsValidSteamId(text)) { string text2 = ExtractSteamIdFromString(text); if (IsValidSteamId(text2)) { text = text2; } } if (!IsValidSteamId(text)) { num++; continue; } if (item.IsExpired(utcNow)) { num2++; continue; } item.id = text; dictionary[text] = item; } lock (_bansLock) { _bans = dictionary; } LogS.LogInfo((object)($"[ServerGuard] bans.yaml loaded ({dictionary.Count} active" + ((num2 > 0) ? $", {num2} expired" : "") + ((num > 0) ? $", {num} invalid" : "") + ")")); if (_bootCompleted) { PostAdminEvent($":arrows_counterclockwise: bans.yaml reloaded ({dictionary.Count} active ban(s))"); SweepBannedPeers(); } } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load bans.yaml: " + ex.Message)); if (_bootCompleted) { PostAdminEvent(":x: bans.yaml failed to parse - ban layer is running on the last good list. " + ex.Message); } } } private void SaveBans() { try { List<BanEntry> list; lock (_bansLock) { DateTime now = DateTime.UtcNow; list = _bans.Values.Where((BanEntry e) => e != null && !e.IsExpired(now)).OrderBy<BanEntry, string>((BanEntry e) => e.id, StringComparer.Ordinal).ToList(); _bans = list.ToDictionary<BanEntry, string, BanEntry>((BanEntry e) => e.id, (BanEntry e) => e, StringComparer.OrdinalIgnoreCase); } StringBuilder stringBuilder = new StringBuilder(); stringBuilder.AppendLine("# ServerGuard ban list."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Enforced at the earliest point of the Valheim handshake at which the SteamID is"); stringBuilder.AppendLine("# known, so a banned player's connection is refused before they ever spawn. This is"); stringBuilder.AppendLine("# separate from Valheim's own banlist.txt - an in-game `unban` does NOT clear an"); stringBuilder.AppendLine("# entry here. Use `sg unban <steamid>` or delete the line below."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Hot-reloaded: edits take effect within a second, and anyone already online who"); stringBuilder.AppendLine("# matches a new entry is disconnected immediately."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# Fields: id (required, SteamID64), reason, expires (ISO-8601 UTC, empty ="); stringBuilder.AppendLine("# permanent), added, addedBy. Expired entries are removed on the next write."); stringBuilder.AppendLine("#"); stringBuilder.AppendLine("# bans:"); stringBuilder.AppendLine("# - id: \"76561198000000000\""); stringBuilder.AppendLine("# reason: \"Item duping\""); stringBuilder.AppendLine("# expires: \"\""); stringBuilder.AppendLine(); if (list.Count == 0) { stringBuilder.AppendLine("bans:"); } else { stringBuilder.AppendLine("bans:"); foreach (BanEntry item in list) { stringBuilder.AppendLine(" - id: \"" + YamlEscape(item.id) + "\""); stringBuilder.AppendLine(" reason: \"" + YamlEscape(item.reason) + "\""); stringBuilder.AppendLine(" expires: \"" + YamlEscape(item.expires) + "\""); stringBuilder.AppendLine(" added: \"" + YamlEscape(item.added) + "\""); stringBuilder.AppendLine(" addedBy: \"" + YamlEscape(item.addedBy) + "\""); } } File.WriteAllText(BansYaml, stringBuilder.ToString()); } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to save bans.yaml: " + ex.Message)); } } private static string YamlEscape(string s) { return (s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\r", " ") .Replace("\n", " "); } internal bool IsBannedId(string candidate, out BanEntry entry) { entry = null; if (_settings == null || !_settings.EnableBanLayer) { return false; } if (string.IsNullOrWhiteSpace(candidate)) { return false; } string text = candidate.Trim(); if (!IsValidSteamId(text)) { string text2 = ExtractSteamIdFromString(text); if (!IsValidSteamId(text2)) { return false; } text = text2; } if (IsOwner(text)) { return false; } BanEntry value; lock (_bansLock) { if (!_bans.TryGetValue(text, out value) || value == null) { return false; } } if (value.IsExpired(DateTime.UtcNow)) { return false; } entry = value; return true; } internal BanEntry AddBan(string steamId, string reason, string addedBy, string expiresIso = "") { if (IsOwner(steamId)) { LogS.LogWarning((object)("[ServerGuard] Refusing to ban " + FormatPlayer(steamId) + " - owner.")); return null; } BanEntry banEntry = new BanEntry { id = steamId, reason = (string.IsNullOrWhiteSpace(reason) ? "No reason given." : reason.Trim()), expires = (expiresIso ?? ""), added = DateTime.UtcNow.ToString("o"), addedBy = (addedBy ?? "") }; lock (_bansLock) { _bans[steamId] = banEntry; } SaveBans(); if (_settings != null && _settings.BanLayerMirrorToVanilla) { TryVanillaBan(steamId); } return banEntry; } internal bool RemoveBan(string steamId) { bool flag; lock (_bansLock) { flag = _bans.Remove(steamId); } if (flag) { SaveBans(); } return flag; } private void SweepBannedPeers() { try { if ((Object)(object)ZNet.instance == (Object)null || !ZNet.instance.IsServer() || _settings == null || !_settings.EnableBanLayer) { return; } foreach (ZNetPeer item in ZNet.instance.GetPeers()?.ToList() ?? new List<ZNetPeer>()) { if (item != null) { string peerPlatformId = GetPeerPlatformId(item); if (IsBannedId(peerPlatformId, out var entry)) { LogS.LogWarning((object)("[ServerGuard] Ban sweep: disconnecting " + FormatPlayer(peerPlatformId) + " (" + entry.reason + ")")); PostPlayerEvent(":no_entry:", peerPlatformId, "was removed", "banned"); TryKick(item, _settings.BanLayerKickMessage + " (" + entry.reason + ")"); } } } } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Ban sweep error: " + ex.Message)); } } private void TryVanillaBan(string platformId) { try { object obj = typeof(ZNet).GetProperty("instance", BindingFlags.Static | BindingFlags.Public | BindingFlags.NonPublic)?.GetValue(null); obj?.GetType().GetMethod("Ban", BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic, null, new Type[1] { typeof(string) }, null)?.Invoke(obj, new object[1] { platformId }); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Vanilla ban mirror failed for " + platformId + ": " + ex.Message)); } } private static string GetPeerSocketId(ZNetPeer peer) { try { object obj; if (peer == null) { obj = null; } else { ISocket socket = peer.m_socket; obj = ((socket != null) ? socket.GetHostName() : null); } string text = (string)obj; if (string.IsNullOrWhiteSpace(text)) { return null; } if (IsValidSteamId(text)) { return text; } string text2 = ExtractSteamIdFromString(text); return IsValidSteamId(text2) ? text2 : null; } catch { return null; } } private void LoadAllowedMods() { try { string text = File.ReadAllText(AllowedModsYaml); AllowedModsDoc allowedModsDoc = _yamlIn.Deserialize<AllowedModsDoc>(text) ?? new AllowedModsDoc(); _requiredMods = ParseAllowedList(allowedModsDoc.required_mods); _allowedMods = ParseAllowedList(allowedModsDoc.allowed_mods); _bannedMods = ParseAllowedList(allowedModsDoc.banned_mods); LogS.LogInfo((object)$"[ServerGuard] allowed_mods.yaml loaded (required={_requiredMods.Count}, allowed={_allowedMods.Count}, banned={_bannedMods.Count})"); 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<AllowedModEntry>(); _allowedMods = new List<AllowedModEntry>(); _bannedMods = new List<AllowedModEntry>(); } RecomputeModsetFingerprint(); } private void RecomputeModsetFingerprint() { try { List<KeyValuePair<string, string>> entries = (from e in _requiredMods.Concat(_allowedMods) select new KeyValuePair<string, string>(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<AllowedModEntry> ParseAllowedList(List<string> raw) { List<AllowedModEntry> list = new List<AllowedModEntry>(); if (raw == null) { return list; } foreach (string item in raw) { if (!string.IsNullOrWhiteSpace(item)) { string[] array = item.Split(new char[1] { '|' }); string text = array[0].Trim(); string sha = ((array.Length > 1) ? array[1].Trim().ToLowerInvariant() : null); if (!string.IsNullOrEmpty(text)) { list.Add(new AllowedModEntry { Key = text.ToLowerInvariant(), Sha256 = sha }); } } } return list; } private void LoadRegistrations() { try { string text = File.ReadAllText(RegistrationsYaml); RegistrationsDoc registrationsDoc = _yamlIn.Deserialize<RegistrationsDoc>(text); if (registrationsDoc?.registrations != null && registrationsDoc.registrations.Count > 0) { _registrations = registrationsDoc.registrations; } else { Dictionary<string, Dictionary<string, string>> dictionary = _yamlIn.Deserialize<Dictionary<string, Dictionary<string, string>>>(text); if (dictionary != null && dictionary.TryGetValue("registrations", out var value) && value != null) { Dictionary<string, List<string>> dictionary2 = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); foreach (KeyValuePair<string, string> item in value) { if (!string.IsNullOrWhiteSpace(item.Key) && !string.IsNullOrWhiteSpace(item.Value)) { dictionary2[item.Key] = new List<string> { item.Value.Trim() }; } } _registrations = dictionary2; SaveRegistrations(); } else { _registrations = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); } } LogS.LogInfo((object)$"[ServerGuard] registrations.yaml loaded ({_registrations.Count} SteamIDs)"); } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load registrations.yaml: " + ex.Message)); _registrations = new Dictionary<string, List<string>>(StringComparer.OrdinalIgnoreCase); } } private void LoadViolations() { try { string text = File.ReadAllText(ViolationsYaml); ViolationsDoc violationsDoc = _yamlIn.Deserialize<ViolationsDoc>(text) ?? new ViolationsDoc(); _violations = violationsDoc.violations ?? new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase); LogS.LogInfo((object)$"[ServerGuard] violations.yaml loaded ({_violations.Count} players)"); } catch (Exception ex) { LogS.LogError((object)("[ServerGuard] Failed to load violations.yaml: " + ex.Message)); _violations = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase); } } private void LoadMetrics() { try { string text = File.ReadAllText(MetricsYaml); _metrics = _yamlIn.Deserialize<DetectionMetrics>(text) ?? new DetectionMetrics(); _metrics.last_updated = DateTime.UtcNow; LogS.LogInfo((object)$"[ServerGuard] metrics.yaml loaded (Checked: {_metrics.total_players_checked}, Detected: {_metrics.total_mods_detected})"); } catch (Exception ex) { LogS.LogWarning((object)("[ServerGuard] Failed to load metrics.yaml: " + ex.Message)); _metrics = new DetectionMetrics(); } } private void SaveRegistrations() { RegistrationsDoc registrationsDoc = new RegistrationsDoc { registrations = _registrations }; File.WriteAllText(RegistrationsYaml, _yamlOut.Serialize((object)registrationsDoc)); } private void SaveViolations() { ViolationsDoc violationsDoc = new ViolationsDoc { violations = _violations }; File.WriteAllText(ViolationsYaml, _yamlOut.Serialize((object)violationsDoc)); } private void SaveMetrics() { try { if (_settings.EnableMetrics) { _metrics.last_updated = DateTime.UtcNow; DetectionMetrics metrics = _metrics; File.WriteAllText(MetricsYaml, _yamlOutFull.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 | BindingFla