From ed860c2211d34b4b37b721e402c02e89cfec0fd9 Mon Sep 17 00:00:00 2001 From: Ada Vale Date: Wed, 27 May 2026 13:11:57 -0400 Subject: [PATCH 1/3] fix(auth): stop rotating KC admin password on world regen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The data dir was anchored to GameIO.GetSaveGameDir(), which returns the *current world's* save folder. Every new world (or any 7DTD boot that landed on a different save dir for any reason) produced an empty KitsuneCommand DB and re-ran AuthService.EnsureAdminExists, silently rotating the admin password and writing a fresh FIRST_RUN_PASSWORD.txt. Observed on a live server: four "FIRST RUN" blocks in two days, each generating a new random password and invalidating the operator's stored panel creds with no obvious cause. The only fingerprint was the recurring banner in the nssm log. Fix: anchor KC's persistent data (SQLite DB, appsettings.json override, FIRST_RUN_PASSWORD.txt, RESET_PASSWORD.txt) to the 7DTD user-data root (parent of Saves/) instead of inside any specific save. Survives world regen, save deletion, and PackRelay mod re-installs. - ConfigManager.ResolveWorldAgnosticDataDir() — new public static, the single source of truth for the data dir path. AuthService and WebServerHost both call it instead of duplicating the path walk. - ConfigManager.TryMigrateLegacyDataDir() — idempotent best-effort copy from the legacy per-world location so existing operators don't lose their DB on upgrade. - Two reassurance log lines after the FIRST RUN banner so the operator knows subsequent restarts will not rotate the password. - Bumped to 2.7.4 with a CHANGELOG entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 30 ++++- frontend/package.json | 2 +- .../Configuration/ConfigManager.cs | 119 +++++++++++++++++- src/KitsuneCommand/ModInfo.xml | 2 +- src/KitsuneCommand/Web/Auth/AuthService.cs | 22 ++-- src/KitsuneCommand/Web/WebServerHost.cs | 10 +- 6 files changed, 167 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8ca6bed..d029df0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,33 @@ pulls notes from — it's the minimum, the GitHub release page is the maximum. ## [Unreleased] +## [2.7.4] - 2026-05-27 + +> [Full notes](https://github.com/Kitsune-Den/KitsuneCommand/releases/tag/v2.7.4) +> · Patch release — the KC admin password no longer rotates on new +> worlds. Persistent data (DB, FIRST_RUN / RESET password files) now +> lives in a world-agnostic location. + +### Fixed + +- **Admin password no longer rotates on world regen.** The KC data dir + was anchored to `GameIO.GetSaveGameDir()`, which returns the + *current world's* save folder. Every new world (or any 7DTD boot + that landed on a different save dir) produced an empty KC database + and re-ran `AuthService.EnsureAdminExists`, silently rotating the + admin password and writing a fresh `FIRST_RUN_PASSWORD.txt`. + Observed on a live server as four "FIRST RUN" blocks in two days, + each invalidating the operator's stored panel creds with no obvious + cause. Fix: anchor the data dir to the 7DTD user-data root (parent + of `Saves/`) so the DB, `appsettings.json` override, + `FIRST_RUN_PASSWORD.txt`, and `RESET_PASSWORD.txt` survive world + regen, save deletion, and PackRelay mod re-installs. Includes a + best-effort one-time migration that copies any existing per-world + data forward on first boot with the new code. New + `ConfigManager.ResolveWorldAgnosticDataDir()` is the single source + of truth — `AuthService` and `WebServerHost` both call it instead + of duplicating the path walk. + ## [2.7.3] - 2026-05-19 > [Full notes](https://github.com/Kitsune-Den/KitsuneCommand/releases/tag/v2.7.3) @@ -489,7 +516,8 @@ The 2.0 cut, not a continuation of v1.x. --- -[Unreleased]: https://github.com/Kitsune-Den/KitsuneCommand/compare/v2.7.3...HEAD +[Unreleased]: https://github.com/Kitsune-Den/KitsuneCommand/compare/v2.7.4...HEAD +[2.7.4]: https://github.com/Kitsune-Den/KitsuneCommand/compare/v2.7.3...v2.7.4 [2.7.3]: https://github.com/Kitsune-Den/KitsuneCommand/compare/v2.7.2...v2.7.3 [2.7.2]: https://github.com/Kitsune-Den/KitsuneCommand/compare/v2.7.1...v2.7.2 [2.7.1]: https://github.com/Kitsune-Den/KitsuneCommand/compare/v2.7.0...v2.7.1 diff --git a/frontend/package.json b/frontend/package.json index 3701625..795189a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "kitsunecommand-frontend", "private": true, - "version": "2.7.3", + "version": "2.7.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src/KitsuneCommand/Configuration/ConfigManager.cs b/src/KitsuneCommand/Configuration/ConfigManager.cs index a0be9ce..4f8ad36 100644 --- a/src/KitsuneCommand/Configuration/ConfigManager.cs +++ b/src/KitsuneCommand/Configuration/ConfigManager.cs @@ -23,13 +23,28 @@ public static AppSettings LoadAppSettings(string modPath) { var defaultConfigPath = Path.Combine(modPath, "Config", "appsettings.json"); - // Production config lives outside the mod folder so it survives updates - var dataDir = Path.Combine(GameIO.GetSaveGameDir(), "KitsuneCommand"); + // World-agnostic data dir. Earlier versions of this method used + // GameIO.GetSaveGameDir() as the base, which returns the *current + // world's* save folder. The consequence: every new world (or any + // 7DTD boot that landed on a different save dir for any reason) + // produced an empty KitsuneCommand DB and re-ran + // AuthService.EnsureAdminExists, silently rotating the admin + // password and writing a fresh FIRST_RUN_PASSWORD.txt. Operators + // saw their saved panel creds stop working with no obvious cause — + // and the only fingerprint was a recurring "FIRST RUN" block in + // the nssm log. Fix: anchor KC's data to a path that's stable + // across worlds, mod updates, and PackRelay re-installs. + var dataDir = ResolveWorldAgnosticDataDir(); if (!Directory.Exists(dataDir)) { Directory.CreateDirectory(dataDir); } + // Best-effort one-time copy from the legacy per-world location. + // Idempotent — safe to call on every boot. + TryMigrateLegacyDataDir(dataDir); + + // Production config lives outside the mod folder so it survives updates var productionConfigPath = Path.Combine(dataDir, "appsettings.json"); // Copy default config to production path if it doesn't exist @@ -63,6 +78,106 @@ public static AppSettings LoadAppSettings(string modPath) return settings; } + /// + /// Returns the stable, world-agnostic directory for KitsuneCommand's + /// persistent data — the SQLite DB, the appsettings.json production + /// override, the FIRST_RUN_PASSWORD.txt, and the emergency + /// RESET_PASSWORD.txt drop-file. Anchors to the 7DTD user-data root + /// (parent of Saves/) so it survives world regen, individual + /// save deletion, and PackRelay mod re-installs. + /// + /// Path shape assumed: <UserDataRoot>/Saves/<World>/<Game>/. + /// If that walk fails (e.g. 7DTD changes its layout in a future patch), + /// falls back to the legacy per-world dir with a loud warning rather + /// than throwing — that preserves the buggy-but-functional old behavior + /// instead of breaking mod load entirely. + /// + /// Public so other components that land files next to the DB + /// ('s FIRST_RUN_PASSWORD.txt, + /// 's RESET_PASSWORD.txt) can share + /// the resolution logic instead of duplicating the path walk. + /// + public static string ResolveWorldAgnosticDataDir() + { + var saveGameDir = GameIO.GetSaveGameDir(); + // /Saves/// → walk up 3 levels to land at /. + var userDataRoot = Directory.GetParent(saveGameDir)?.Parent?.Parent?.FullName; + if (string.IsNullOrEmpty(userDataRoot)) + { + Log.Warning( + "[KitsuneCommand] Could not resolve user-data root from save dir '" + + saveGameDir + "' — falling back to per-world data dir. " + + "Admin password may regenerate on world regen until this is fixed."); + return Path.Combine(saveGameDir, "KitsuneCommand"); + } + return Path.Combine(userDataRoot, "KitsuneCommand"); + } + + /// + /// One-time copy from the legacy per-world data dir to the new + /// world-agnostic dir. Idempotent: returns immediately if the new dir + /// already contains a .db or .json file (i.e. a previous migration ran, + /// or this is a clean install on the new code). Best-effort: any + /// failure logs a warning and lets boot continue — the new dir just + /// stays empty and the FIRST RUN flow kicks in, which is the same as + /// any clean install. + /// + /// The legacy files are intentionally left in place rather than moved. + /// Worst case the operator deletes the old per-world dir manually after + /// verifying the panel still logs in; cheap insurance against this + /// migration eating data we needed. + /// + private static void TryMigrateLegacyDataDir(string newDataDir) + { + try + { + var legacyDir = Path.Combine(GameIO.GetSaveGameDir(), "KitsuneCommand"); + if (!Directory.Exists(legacyDir)) return; + if (string.Equals(legacyDir, newDataDir, StringComparison.OrdinalIgnoreCase)) return; + + // Skip if the new dir already has meaningful data — don't clobber + // a previously-migrated or freshly-installed DB. + if (Directory.Exists(newDataDir)) + { + foreach (var f in Directory.GetFiles(newDataDir)) + { + var ext = Path.GetExtension(f); + if (ext.Equals(".db", StringComparison.OrdinalIgnoreCase) || + ext.Equals(".json", StringComparison.OrdinalIgnoreCase)) + { + return; + } + } + } + + var copied = 0; + foreach (var f in Directory.GetFiles(legacyDir)) + { + var dest = Path.Combine(newDataDir, Path.GetFileName(f)); + if (!File.Exists(dest)) + { + File.Copy(f, dest); + copied++; + } + } + + if (copied > 0) + { + Log.Out( + "[KitsuneCommand] Migrated " + copied + " file(s) from legacy " + + "per-world data dir '" + legacyDir + "' → '" + newDataDir + "'. " + + "Legacy files left in place; safe to delete after verifying " + + "the panel still logs in."); + } + } + catch (Exception ex) + { + Log.Warning( + "[KitsuneCommand] Legacy data-dir migration failed: " + ex.Message + ". " + + "Continuing with empty new data dir — first-run admin will be created fresh."); + } + } + /// /// Gets the current app settings. /// diff --git a/src/KitsuneCommand/ModInfo.xml b/src/KitsuneCommand/ModInfo.xml index 0b96a29..c910cf7 100644 --- a/src/KitsuneCommand/ModInfo.xml +++ b/src/KitsuneCommand/ModInfo.xml @@ -2,7 +2,7 @@ - + diff --git a/src/KitsuneCommand/Web/Auth/AuthService.cs b/src/KitsuneCommand/Web/Auth/AuthService.cs index a732347..0f77061 100644 --- a/src/KitsuneCommand/Web/Auth/AuthService.cs +++ b/src/KitsuneCommand/Web/Auth/AuthService.cs @@ -1,3 +1,4 @@ +using KitsuneCommand.Configuration; using KitsuneCommand.Data; using KitsuneCommand.Data.Entities; using KitsuneCommand.Data.Repositories; @@ -42,19 +43,22 @@ public void EnsureAdminExists() Log.Out($"[KitsuneCommand] Username: admin"); Log.Out($"[KitsuneCommand] Password: {password}"); Log.Out("[KitsuneCommand] Please change this password after first login."); + // Reassurance for operators who used to see this block re-print on + // every world regen: the data dir is now world-agnostic, so this + // password persists across worlds, mod updates, and server reboots + // — it will only regenerate if the underlying user_accounts table + // is empty (i.e. the DB was deleted or freshly re-initialized). + Log.Out("[KitsuneCommand] Data dir is world-agnostic — restarts and"); + Log.Out("[KitsuneCommand] new worlds will NOT rotate this password."); Log.Out("============================================================"); - // Also write to a file for convenience - var passwordFile = Path.Combine( - Path.GetDirectoryName(_userRepo is UserAccountRepository repo - ? "." : "."), - "FIRST_RUN_PASSWORD.txt" - ); - + // Also write to a convenience file next to the DB. Same world-agnostic + // location as the rest of KC's persistent data; see + // ConfigManager.ResolveWorldAgnosticDataDir for the resolution. try { - var saveDir = Path.Combine(GameIO.GetSaveGameDir(), "KitsuneCommand"); - passwordFile = Path.Combine(saveDir, "FIRST_RUN_PASSWORD.txt"); + var dataDir = ConfigManager.ResolveWorldAgnosticDataDir(); + var passwordFile = Path.Combine(dataDir, "FIRST_RUN_PASSWORD.txt"); File.WriteAllText(passwordFile, $"KitsuneCommand Admin Credentials (delete this file after reading)\n" + $"Username: admin\n" + diff --git a/src/KitsuneCommand/Web/WebServerHost.cs b/src/KitsuneCommand/Web/WebServerHost.cs index f39393f..e099330 100644 --- a/src/KitsuneCommand/Web/WebServerHost.cs +++ b/src/KitsuneCommand/Web/WebServerHost.cs @@ -228,12 +228,14 @@ private void HandleLogin(HttpListenerContext ctx) { // Emergency password reset mechanism: if BCrypt verification fails (e.g. hash // was corrupted or the Mono runtime mangled it), the server admin can place a - // plaintext RESET_PASSWORD.txt in the save-game KitsuneCommand folder. When the - // submitted password matches that file's contents, the password is re-hashed - // with BCrypt and the reset file is deleted, restoring normal login. + // plaintext RESET_PASSWORD.txt in the KitsuneCommand data folder — same + // world-agnostic location as the DB (see + // ConfigManager.ResolveWorldAgnosticDataDir). When the submitted password + // matches that file's contents, the password is re-hashed with BCrypt and + // the reset file is deleted, restoring normal login. try { - var resetFile = Path.Combine(GameIO.GetSaveGameDir(), "KitsuneCommand", "RESET_PASSWORD.txt"); + var resetFile = Path.Combine(ConfigManager.ResolveWorldAgnosticDataDir(), "RESET_PASSWORD.txt"); if (File.Exists(resetFile)) { var resetPassword = File.ReadAllText(resetFile).Trim(); From 776634c4560096c48c7c9281957df6dfd682a050 Mon Sep 17 00:00:00 2001 From: Ada Vale Date: Thu, 28 May 2026 09:55:02 -0400 Subject: [PATCH 2/3] feat(KitsuneJoinDiag): v0.1 client-side mod surfacing actual LiteNetLib DisconnectReason Vanilla 7DTD catches LiteNetLib's OnPeerDisconnected with the real DisconnectReason (PeerNotFound, Timeout, ConnectionFailed, InvalidProtocol, etc.) and then throws it away, showing the player a generic "Could not retrieve server information" dialog. With that string alone, neither the player nor an admin can tell whether the failure is NAT, rate-limit, version mismatch, firewall, or anything else. KitsuneJoinDiag is a tiny standalone mod that Harmony-postfixes NetworkClientLiteNetLib.OnDisconnectedFromServer and logs the real DisconnectInfo at ERR level in Player.log, plus a short hint mapping each reason to player-actionable advice. Harmless on dedicated servers (the client-side code path doesn't fire there), so safe to ship in either client-only or whole-pack mod distributions. v0.1 has two known cosmetic issues filed as Kitsunebi card #216: - peer:Port logs LiteNetLib's local source port, not the typed target port - timeSinceLastPkt is a NetManager-global counter, meaningless on fresh failures Also includes tools/test-joindiag.ps1: a PowerShell harness that extracts the diag block from a ModLauncher profile's output_log.txt (one-shot or -Watch mode), so you can iterate on the format without fishing the block out of a 2MB log every time. LiteNetLib.dll added to refs/ so the mod's csproj can resolve DisconnectReason / DisconnectInfo at compile time. The DLL is referenced as Private=false because the game ships its own copy; we only need the type metadata. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/KitsuneCommand/refs/LiteNetLib.dll | Bin 0 -> 117248 bytes src/KitsuneJoinDiag/KitsuneJoinDiag.csproj | 58 +++++++ src/KitsuneJoinDiag/ModEntry.cs | 39 +++++ src/KitsuneJoinDiag/ModInfo.xml | 9 + .../Patches/ConnectionFailedPatch.cs | 159 ++++++++++++++++++ src/KitsuneJoinDiag/tools/test-joindiag.ps1 | 133 +++++++++++++++ 6 files changed, 398 insertions(+) create mode 100644 src/KitsuneCommand/refs/LiteNetLib.dll create mode 100644 src/KitsuneJoinDiag/KitsuneJoinDiag.csproj create mode 100644 src/KitsuneJoinDiag/ModEntry.cs create mode 100644 src/KitsuneJoinDiag/ModInfo.xml create mode 100644 src/KitsuneJoinDiag/Patches/ConnectionFailedPatch.cs create mode 100644 src/KitsuneJoinDiag/tools/test-joindiag.ps1 diff --git a/src/KitsuneCommand/refs/LiteNetLib.dll b/src/KitsuneCommand/refs/LiteNetLib.dll new file mode 100644 index 0000000000000000000000000000000000000000..9db6483c255ce3c2bc1855710e6c4245f51fa1ef GIT binary patch literal 117248 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�Enf4nf(uM6C?~*x^Dt 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�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 literal 0 HcmV?d00001 diff --git a/src/KitsuneJoinDiag/KitsuneJoinDiag.csproj b/src/KitsuneJoinDiag/KitsuneJoinDiag.csproj new file mode 100644 index 0000000..d7951d7 --- /dev/null +++ b/src/KitsuneJoinDiag/KitsuneJoinDiag.csproj @@ -0,0 +1,58 @@ + + + + net48 + 11.0 + KitsuneJoinDiag + KitsuneJoinDiag + Library + disable + disable + false + false + + + + + + ..\KitsuneCommand\refs\Assembly-CSharp.dll + false + + + ..\KitsuneCommand\refs\Assembly-CSharp-firstpass.dll + false + + + ..\KitsuneCommand\refs\LogLibrary.dll + false + + + ..\KitsuneCommand\refs\UnityEngine.dll + false + + + ..\KitsuneCommand\refs\UnityEngine.CoreModule.dll + false + + + ..\KitsuneCommand\refs\0Harmony.dll + false + + + ..\KitsuneCommand\refs\LiteNetLib.dll + false + + + + + + + + + diff --git a/src/KitsuneJoinDiag/ModEntry.cs b/src/KitsuneJoinDiag/ModEntry.cs new file mode 100644 index 0000000..8719053 --- /dev/null +++ b/src/KitsuneJoinDiag/ModEntry.cs @@ -0,0 +1,39 @@ +using System; +using HarmonyLib; + +namespace KitsuneJoinDiag +{ + /// + /// Mod entry point — 7DTD calls once at mod-load. + /// We just install Harmony patches and bow out. + /// + /// On a dedicated server, the patches install fine but never fire — the + /// client-side NetworkClientLiteNetLib.OnDisconnectedFromServer + /// code path doesn't execute server-side (server peers run through + /// NetworkServerLiteNetLib, a different class). So this mod is + /// safe to ship in a pack that's installed on both clients and the + /// server. + /// + public class ModEntry : IModApi + { + private static Harmony _harmony; + + public void InitMod(Mod _modInstance) + { + Log.Out("[KitsuneJoinDiag] Initializing..."); + try + { + _harmony = new Harmony("net.kitsuneden.joindiag"); + _harmony.PatchAll(typeof(ModEntry).Assembly); + Log.Out("[KitsuneJoinDiag] Harmony patches applied. " + + "On a connection failure, the actual LiteNetLib DisconnectReason " + + "will be logged at ERR level for easy diagnosis."); + } + catch (Exception ex) + { + Log.Error("[KitsuneJoinDiag] Failed to apply Harmony patches: " + ex.Message); + Log.Exception(ex); + } + } + } +} diff --git a/src/KitsuneJoinDiag/ModInfo.xml b/src/KitsuneJoinDiag/ModInfo.xml new file mode 100644 index 0000000..3893bb7 --- /dev/null +++ b/src/KitsuneJoinDiag/ModInfo.xml @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/KitsuneJoinDiag/Patches/ConnectionFailedPatch.cs b/src/KitsuneJoinDiag/Patches/ConnectionFailedPatch.cs new file mode 100644 index 0000000..a4551c6 --- /dev/null +++ b/src/KitsuneJoinDiag/Patches/ConnectionFailedPatch.cs @@ -0,0 +1,159 @@ +using System; +using HarmonyLib; +using LiteNetLib; + +namespace KitsuneJoinDiag.Patches +{ + /// + /// Postfix on NetworkClientLiteNetLib.OnDisconnectedFromServer + /// (the client-side handler for LiteNetLib's + /// NetEventListener.OnPeerDisconnected). When the connection + /// fails — for any reason, mid-handshake or otherwise — this fires + /// with the actual . + /// + /// Vanilla 7DTD takes that , sets a flag + /// in a closure (NetworkClientLiteNetLib+<>c__DisplayClass13_0 + /// has reason, additionalDisconnectCause, + /// hasDisconnectInfo fields, captured from this event), and + /// somewhere downstream populates the UI dialog with a localized + /// catch-all "Could not retrieve server information" string — the + /// player never sees the reason. + /// + /// We can't easily intercept the dialog text from a source-mode mod + /// without spelunking through 7DTD's XUiC widget tree, so for v0.1 we + /// settle for surfacing the reason at the LOG level. Players can read + /// Player.log after a failed join and see, e.g.: + /// + /// + /// ERR [KitsuneJoinDiag] CONNECTION FAILED — actual LiteNetLib reason: + /// reason: PeerNotFound + /// peer: 73.230.2.245:26906 + /// extraDataBytes: 0 + /// timeSinceLastPkt: 0.42s + /// roundTripTime: 35ms + /// + /// + /// Or paste those lines to an admin and the admin immediately knows + /// the failure class (NAT/router issue vs version mismatch vs rate + /// limit vs etc.). + /// + /// A future v0.2+ will also patch the XUiC dialog widget to show the + /// reason in-game; this is the foundation. + /// + [HarmonyPatch(typeof(NetworkClientLiteNetLib), nameof(NetworkClientLiteNetLib.OnDisconnectedFromServer))] + public static class ClientDisconnectFromServerPatch + { + [HarmonyPostfix] + public static void Postfix(NetPeer _peer, DisconnectInfo _info) + { + try + { + string ep; + if (_peer == null) + { + ep = "(unknown — peer null at disconnect)"; + } + else + { + try { ep = _peer.Address + ":" + _peer.Port; } + catch { ep = "(peer endpoint read failed)"; } + } + + string reason = _info.Reason.ToString(); + int extraBytes = _info.AdditionalData != null + ? _info.AdditionalData.AvailableBytes + : 0; + + // Optional context the player might find useful — the + // last-packet timing and RTT hint at whether the + // connection was lively before failing vs DOA. + string timeSinceLastPkt = "(n/a)"; + string rtt = "(n/a)"; + if (_peer != null) + { + try { timeSinceLastPkt = _peer.TimeSinceLastPacket.ToString("F2") + "s"; } catch { } + try { rtt = _peer.RoundTripTime + "ms"; } catch { } + } + + // ERR level so it's visually obvious in Player.log — the + // failing player or their admin should be able to spot + // this block at a glance. + Log.Error( + "\n" + + "================================================================\n" + + "[KitsuneJoinDiag] CONNECTION FAILED — actual LiteNetLib reason:\n" + + " reason: " + reason + "\n" + + " peer: " + ep + "\n" + + " extraDataBytes: " + extraBytes + "\n" + + " timeSinceLastPkt: " + timeSinceLastPkt + "\n" + + " roundTripTime: " + rtt + "\n" + + HintFor(_info.Reason) + + "================================================================"); + } + catch (Exception ex) + { + Log.Warning("[KitsuneJoinDiag] Postfix threw: " + ex.Message); + } + } + + /// + /// Map of LiteNetLib's values to a + /// short, player-actionable hint. Conservative wording — we + /// don't want to mis-diagnose. Anything ambiguous gets a generic + /// "ask the admin" suggestion rather than confidently wrong + /// advice. + /// + private static string HintFor(DisconnectReason r) + { + switch (r) + { + case DisconnectReason.PeerNotFound: + return " hint: server rejected your peer mid-handshake. Common causes:\n" + + " - symmetric NAT on your router rewriting UDP source ports\n" + + " - server-side rate limit (you connected too fast after a previous attempt)\n" + + " - try Direct Connect again in 30 seconds, or use the server's alternate join address\n"; + + case DisconnectReason.Timeout: + return " hint: server didn't respond. Check your internet, try a different address,\n" + + " or confirm the server is online with the admin.\n"; + + case DisconnectReason.HostUnreachable: + case DisconnectReason.NetworkUnreachable: + return " hint: no network route to the server. Check your internet connection,\n" + + " VPN status (if any), or the address you typed.\n"; + + case DisconnectReason.ConnectionFailed: + return " hint: low-level connection attempt failed (different from timeout).\n" + + " Often a firewall on either side blocking UDP, or a wrong port.\n"; + + case DisconnectReason.RemoteConnectionClose: + return " hint: server actively kicked your connection. You may be banned, the server\n" + + " may be full, or your version/mods may not match. Check with the admin.\n"; + + case DisconnectReason.ConnectionRejected: + return " hint: server explicitly rejected this connection (vs failing). Common causes:\n" + + " password mismatch, max-player limit, server in protected mode.\n"; + + case DisconnectReason.InvalidProtocol: + return " hint: game protocol mismatch. Your client and the server are on different\n" + + " 7DTD versions or LiteNetLib versions. Update via Steam.\n"; + + case DisconnectReason.UnknownHost: + return " hint: the hostname couldn't be resolved. DNS issue, or you typed the\n" + + " address wrong.\n"; + + case DisconnectReason.DisconnectPeerCalled: + return " hint: the server's mod or admin explicitly disconnected you. Check chat\n" + + " history or ask the admin.\n"; + + case DisconnectReason.Reconnect: + return " hint: a fresh connection from your IP replaced this one. Probably the game\n" + + " retrying; not actually a fatal failure.\n"; + + default: + return " hint: an uncommon LiteNetLib reason — ask the admin to check the server\n" + + " log around this timestamp.\n"; + } + } + } +} diff --git a/src/KitsuneJoinDiag/tools/test-joindiag.ps1 b/src/KitsuneJoinDiag/tools/test-joindiag.ps1 new file mode 100644 index 0000000..0e1ce2d --- /dev/null +++ b/src/KitsuneJoinDiag/tools/test-joindiag.ps1 @@ -0,0 +1,133 @@ +# KitsuneJoinDiag -- diagnostic block extractor. +# +# Hunts the latest `[KitsuneJoinDiag] CONNECTION FAILED` block out of a +# ModLauncher profile's output_log.txt and prints it. Optionally tails +# the log live until a fresh block appears, so you can fire a failed +# connect attempt via the normal UI and have the answer waiting for you. +# +# Why not "launch the game with bad target + scrape" fully-automated? +# Because 7DTD 2.6's -connecttoip command line arg is parsed and then +# WARN'd as "not a configfile property, ignoring." The game never +# auto-connects from it -- the documented behavior is misleading. So we +# split the work: the human (or computer-use) drives the UI, the script +# extracts the result. +# +# Usage: +# # Print the most recent diag block in the current log (one-shot): +# .\test-joindiag.ps1 +# +# # Tail the log until a NEW diag block appears, then print: +# .\test-joindiag.ps1 -Watch +# +# # Tail and print, but also kill the game once we have the block: +# .\test-joindiag.ps1 -Watch -StopGameOnHit +# +# # Pick a different profile: +# .\test-joindiag.ps1 -Profile Kitsune_Den -Watch +# +# Exit codes: +# 0 - a block was found and printed +# 1 - no block found (one-shot mode) / timed out (watch mode) +# 2 - bad args, missing files + +[CmdletBinding()] +param( + # ModLauncher profile name under G:\7D2D\Custom\. + [string]$Profile = 'TestingDen', + + # If set, tail the log waiting for a NEW diag block (one written + # AFTER the script starts). Without this flag, prints the most + # recent block in the existing log. + [switch]$Watch, + + # Max seconds to wait in -Watch mode. + [int]$TimeoutSec = 180, + + # In -Watch mode, kill 7DTD after capturing a block. Saves the manual + # alt-F4 between iterations. + [switch]$StopGameOnHit, + + # Root for ModLauncher's per-profile UserDataFolders. + [string]$ProfileRoot = 'G:\7D2D\Custom' +) + +$ErrorActionPreference = 'Stop' + +$logPath = Join-Path (Join-Path $ProfileRoot $Profile) 'output_log.txt' +if (-not (Test-Path $logPath)) { + Write-Error "Log not found: $logPath (profile '$Profile' may not have been launched yet)" + exit 2 +} + +# Pattern: two 64-equals rules with the diag header and body between. +# `(?s)` for dotall so the body spans multiple lines. +$pattern = '(?s)={64}\s*\r?\n\[KitsuneJoinDiag\] CONNECTION FAILED[\s\S]*?={64}' + +function Get-AllBlocks { + param([string]$Path) + # Read whole file (output_log.txt is rewritten each launch, typically + # tens of KB to a few MB -- fits comfortably in memory). + $text = [System.IO.File]::ReadAllText($Path) + [regex]::Matches($text, $pattern) | ForEach-Object { $_.Value } +} + +# --- One-shot mode: print the most recent existing block --- +if (-not $Watch) { + $blocks = @(Get-AllBlocks -Path $logPath) + if ($blocks.Count -eq 0) { + Write-Host "[test-joindiag] no diag block found in $logPath" -ForegroundColor Yellow + Write-Host " (mod may not have caught a failure yet -- trigger a failed connect attempt and retry)" + exit 1 + } + Write-Host "[test-joindiag] $($blocks.Count) block(s) in log. Printing most recent:" -ForegroundColor Green + Write-Host "" + Write-Host $blocks[-1] + exit 0 +} + +# --- Watch mode: wait for a NEW block written after script start --- +Write-Host "[test-joindiag] watching $logPath for new diag blocks (timeout ${TimeoutSec}s)..." -ForegroundColor Cyan +Write-Host " Trigger a failed connect attempt via the normal Direct Connect flow." -ForegroundColor Cyan +Write-Host "" + +$baselineBlocks = @(Get-AllBlocks -Path $logPath) +$baselineCount = $baselineBlocks.Count +Write-Host "[test-joindiag] baseline: $baselineCount existing block(s)" -ForegroundColor DarkGray + +$deadline = (Get-Date).AddSeconds($TimeoutSec) +while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds 2 + if (-not (Test-Path $logPath)) { + # Log rotated/deleted (e.g. game launched fresh) -- reset baseline. + Write-Host "[test-joindiag] log gone, waiting for it to be recreated..." -ForegroundColor DarkGray + $baselineCount = 0 + continue + } + $blocks = @(Get-AllBlocks -Path $logPath) + if ($blocks.Count -gt $baselineCount) { + Write-Host "" + Write-Host "[test-joindiag] NEW BLOCK CAPTURED:" -ForegroundColor Green + Write-Host "" + Write-Host $blocks[-1] + + if ($StopGameOnHit) { + $game = Get-Process -Name '7DaysToDie*' -ErrorAction SilentlyContinue + if ($game) { + Write-Host "" + Write-Host "[test-joindiag] stopping 7DTD (PID $($game.Id))..." -ForegroundColor Cyan + Stop-Process -Id $game.Id -Force -ErrorAction SilentlyContinue + } + } + exit 0 + } + # Log being smaller than last poll means the game restarted -- reset + # our baseline to the new (smaller) count. + if ($blocks.Count -lt $baselineCount) { + Write-Host "[test-joindiag] log shrank (game restarted?). Resetting baseline." -ForegroundColor DarkGray + $baselineCount = $blocks.Count + } +} + +Write-Host "" +Write-Host "[test-joindiag] timed out after ${TimeoutSec}s with no new block." -ForegroundColor Red +exit 1 From 254ac307fd12fbc52febaa8aeded194c68b518d6 Mon Sep 17 00:00:00 2001 From: Ada Vale Date: Thu, 28 May 2026 09:55:45 -0400 Subject: [PATCH 3/3] feat(KC): Join Attempts panel -- in-memory ring of LiteNetLib auth-wrapper events Server-side companion to KitsuneJoinDiag. Where the client-side mod surfaces a player's own DisconnectReason to their Player.log, this panel gives the admin a view of all join activity hitting the server, whether or not the connecting client ran any mods. Backend ------- - Diagnostics/JoinAttemptEvent.cs: data record for a single observed event (timestamp, peer endpoint, event type, result, deliveryMethod, bytes, channel, extra data bytes, auth state count). - Diagnostics/JoinAttemptRing.cs: lock-protected ring buffer, 500 events, with Record() / Snapshot(limit, sinceUtc) / Clear() / TotalRecorded. Single lock is fine at the expected event rate. - GameIntegration/Harmony/AuthWrapperServerDiagnostics.cs: five Harmony patches on NetworkServerLiteNetLib+LiteNetLibAuthWrapperServer capturing ConnectionRequestCheck, OnNetworkReceiveEvent, OnPeerConnectedEvent, OnPeerDisconnectedEvent, and Update. Each feeds JoinAttemptRing.Record(). Verbose console-log gating via a static Enabled flag so we can hot-toggle the noisy console output without losing the ring data (it's always recorded). - Web/Controllers/JoinAttemptsController.cs: admin-only REST under /api/join-attempts: GET (with limit + since query), POST /clear, POST /verbose for the Enabled flag. - KitsuneCommand.csproj: reference LiteNetLib (Private=false; the game ships its own copy, we only need the type metadata) so the diagnostics patches can name DisconnectReason / DeliveryMethod / ConnectionRequest directly without reflection. Frontend -------- - views/JoinAttemptsView.vue: PrimeVue DataTable view with auto-refresh, peer grouping, and per-event detail. Admin-only (server enforces, client checks roles before showing the nav entry). - api/joinAttempts.ts: typed thin wrapper over the REST endpoints. - router/index.ts: route registration. - components/layout/AppLayout.vue: nav entry visible to admins. - i18n: keys added across en/de/es/fr/ja/ko/zh-CN/zh-TW. Non-English Asian locales (ja, ko, zh-CN, zh-TW) use English placeholders for now; de/es/fr have proper translations. Pairs with KitsuneJoinDiag v0.1 (sibling mod, separate commit) to give both sides of a failed-join investigation: the player sees the real DisconnectReason in their Player.log, the admin sees the event hit the server's ring buffer (or sees that it didn't, which is also diagnostic). Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/package-lock.json | 4 +- frontend/src/api/joinAttempts.ts | 90 ++++ frontend/src/components/layout/AppLayout.vue | 1 + frontend/src/i18n/locales/de.ts | 1 + frontend/src/i18n/locales/en.ts | 23 + frontend/src/i18n/locales/es.ts | 1 + frontend/src/i18n/locales/fr.ts | 1 + frontend/src/i18n/locales/ja.ts | 24 ++ frontend/src/i18n/locales/ko.ts | 24 ++ frontend/src/i18n/locales/zh-CN.ts | 24 ++ frontend/src/i18n/locales/zh-TW.ts | 24 ++ frontend/src/router/index.ts | 10 + frontend/src/views/JoinAttemptsView.vue | 396 +++++++++++++++++ .../Diagnostics/JoinAttemptEvent.cs | 73 ++++ .../Diagnostics/JoinAttemptRing.cs | 116 +++++ .../Harmony/AuthWrapperServerDiagnostics.cs | 405 ++++++++++++++++++ src/KitsuneCommand/KitsuneCommand.csproj | 22 + .../Web/Controllers/JoinAttemptsController.cs | 109 +++++ 18 files changed, 1346 insertions(+), 2 deletions(-) create mode 100644 frontend/src/api/joinAttempts.ts create mode 100644 frontend/src/views/JoinAttemptsView.vue create mode 100644 src/KitsuneCommand/Diagnostics/JoinAttemptEvent.cs create mode 100644 src/KitsuneCommand/Diagnostics/JoinAttemptRing.cs create mode 100644 src/KitsuneCommand/GameIntegration/Harmony/AuthWrapperServerDiagnostics.cs create mode 100644 src/KitsuneCommand/Web/Controllers/JoinAttemptsController.cs diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1719557..cef3bc5 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "kitsunecommand-frontend", - "version": "2.0.0", + "version": "2.7.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "kitsunecommand-frontend", - "version": "2.0.0", + "version": "2.7.4", "dependencies": { "@primevue/themes": "^4.2.5", "@vue-leaflet/vue-leaflet": "^0.10.1", diff --git a/frontend/src/api/joinAttempts.ts b/frontend/src/api/joinAttempts.ts new file mode 100644 index 0000000..a03f6b7 --- /dev/null +++ b/frontend/src/api/joinAttempts.ts @@ -0,0 +1,90 @@ +import apiClient from './client' + +/** + * One event in a client's connection lifecycle on the server side, captured + * by KitsuneCommand's AuthWrapperServerDiagnostics Harmony patches. One + * "click Direct Connect" typically produces 10-30 of these as LiteNetLib + * retries and the auth-state state machine transitions — bursts that the + * panel renders as one logical "join attempt" by grouping on peerIp:peerPort. + * + * Matches the JSON shape returned by the C# `JoinAttemptEvent` (see + * `KitsuneCommand/Diagnostics/JoinAttemptEvent.cs`). + */ +export interface JoinAttemptEvent { + /** UTC ISO-8601 timestamp the event was recorded. */ + timestamp: string + + /** One of: ConnReq, Recv, Conn, Disc, Update. */ + eventType: string + + /** Source IP. Null for Update events. */ + peerIp: string | null + + /** Source port. Null for Update events. */ + peerPort: number | null + + /** + * For ConnReq: Accept / Reject / RejectForce / None. + * For Disc: LiteNetLib DisconnectReason name (PeerNotFound, Timeout, + * DisconnectPeerCalled, etc.) — the field operators most care about. + * Null otherwise. + */ + result: string | null + + /** + * ConnReq payload size in bytes. 2 = bare LiteNetLib version handshake, + * 0 = wrapper already consumed it (Accept happened). Null otherwise. + */ + dataBytes: number | null + + /** Channel byte for Recv events. */ + channel: number | null + + /** ReliableOrdered / Unreliable / ... for Recv events. */ + deliveryMethod: string | null + + /** Size of disconnect packet's optional payload (Disc events only). */ + extraDataBytes: number | null + + /** authStates dict size at the moment of this event. */ + authStateCount: number | null +} + +export interface JoinAttemptListResponse { + events: JoinAttemptEvent[] + /** Process-lifetime monotonic counter — useful for "Hey, the ring is filling fast". */ + totalRecorded: number + /** Whether [KC-NetDiag] verbose console logging is currently on. */ + verboseLogging: boolean + /** Ring buffer capacity (events older than this get overwritten). */ + capacity: number +} + +/** + * Fetch up to `limit` most-recent events, optionally only those at or after + * `since` (ISO-8601). Returns newest-first. + */ +export async function getJoinAttempts( + limit: number = 100, + since: string | null = null +): Promise { + const params: Record = { limit } + if (since) params.since = since + const res = await apiClient.get('/api/join-attempts', { params }) + return res.data.data +} + +/** Empty the ring buffer. Doesn't reset the monotonic totalRecorded counter. */ +export async function clearJoinAttempts(): Promise { + const res = await apiClient.post('/api/join-attempts/clear') + return res.data.message +} + +/** + * Turn the [KC-NetDiag] verbose console logging on or off at runtime. + * Ring-buffer recording is unaffected (it's always on). + */ +export async function setVerboseLogging(enabled: boolean): Promise<{ enabled: boolean }> { + const res = await apiClient.post('/api/join-attempts/verbose', { enabled }) + return res.data.data +} diff --git a/frontend/src/components/layout/AppLayout.vue b/frontend/src/components/layout/AppLayout.vue index f3a504f..5918b2d 100644 --- a/frontend/src/components/layout/AppLayout.vue +++ b/frontend/src/components/layout/AppLayout.vue @@ -34,6 +34,7 @@ const navItems = computed(() => [ { label: t('nav.serverControl'), icon: 'pi pi-server', route: '/server' }, { label: t('nav.serverUpdate'), icon: 'pi pi-sync', route: '/server-update' }, { label: t('nav.console'), icon: 'pi pi-code', route: '/console' }, + { label: t('nav.joinAttempts'), icon: 'pi pi-sign-in', route: '/join-attempts' }, { label: t('nav.configEditor'), icon: 'pi pi-file-edit', route: '/config' }, { label: t('nav.mods'), icon: 'pi pi-box', route: '/mods' }, { label: t('nav.packRelay'), icon: 'pi pi-cloud-upload', route: '/packrelay' }, diff --git a/frontend/src/i18n/locales/de.ts b/frontend/src/i18n/locales/de.ts index fd92b46..216bdb2 100644 --- a/frontend/src/i18n/locales/de.ts +++ b/frontend/src/i18n/locales/de.ts @@ -46,6 +46,7 @@ const de = { dashboard: 'Übersicht', players: 'Spieler', console: 'Konsole', + joinAttempts: 'Verbindungsversuche', map: 'Karte', chat: 'Chat', teleport: 'Teleport', diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 43f9c69..1a5b251 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -43,6 +43,7 @@ const en = { dashboard: 'Dashboard', players: 'Players', console: 'Console', + joinAttempts: 'Join Attempts', map: 'Map', chat: 'Chat', teleport: 'Teleport', @@ -241,6 +242,28 @@ const en = { clearLog: 'Clear', }, + joinAttempts: { + title: 'Join Attempts', + subtitle: 'Live view of LiteNetLib-layer connection events. Powered by KitsuneCommand\'s in-process ring buffer. Restart clears the ring.', + totalRecorded: 'Events (lifetime)', + bufferUsage: 'Buffer usage', + verbose: 'Verbose console logging', + verboseOn: 'Now logging each event to nssm-stdout.log as [KC-NetDiag] lines.', + verboseOff: 'Console logging disabled; ring buffer recording continues.', + autoRefresh: 'Auto-refresh', + clear: 'Clear ring', + cleared: 'Join-attempt ring cleared.', + failedToClear: 'Failed to clear ring.', + failedToLoad: 'Failed to load join attempts', + failedToToggle: 'Failed to toggle verbose logging.', + peer: 'Peer', + time: 'When', + events: 'Events', + outcome: 'Outcome', + steps: 'Steps', + empty: 'No join attempts in the ring buffer. Try clicking Direct Connect on a 7DTD client to populate it.', + }, + map: { title: 'Map', loadingMap: 'Loading map...', diff --git a/frontend/src/i18n/locales/es.ts b/frontend/src/i18n/locales/es.ts index b1089ae..0adb532 100644 --- a/frontend/src/i18n/locales/es.ts +++ b/frontend/src/i18n/locales/es.ts @@ -46,6 +46,7 @@ const es = { dashboard: 'Panel principal', players: 'Jugadores', console: 'Consola', + joinAttempts: 'Intentos de conexión', map: 'Mapa', chat: 'Chat', teleport: 'Teletransporte', diff --git a/frontend/src/i18n/locales/fr.ts b/frontend/src/i18n/locales/fr.ts index 784746e..b7782ea 100644 --- a/frontend/src/i18n/locales/fr.ts +++ b/frontend/src/i18n/locales/fr.ts @@ -46,6 +46,7 @@ const fr = { dashboard: 'Tableau de bord', players: 'Joueurs', console: 'Console', + joinAttempts: 'Tentatives de connexion', map: 'Carte', chat: 'Chat', teleport: 'Téléportation', diff --git a/frontend/src/i18n/locales/ja.ts b/frontend/src/i18n/locales/ja.ts index d8179d8..4abcb67 100644 --- a/frontend/src/i18n/locales/ja.ts +++ b/frontend/src/i18n/locales/ja.ts @@ -47,6 +47,7 @@ const ja: Messages = { dashboard: 'ダッシュボード', players: 'プレイヤー', console: 'コンソール', + joinAttempts: 'Join Attempts', map: 'マップ', chat: 'チャット', teleport: 'テレポート', @@ -245,6 +246,29 @@ const ja: Messages = { clearLog: 'クリア', }, + // English placeholders pending translation. + joinAttempts: { + title: 'Join Attempts', + subtitle: 'Live view of LiteNetLib-layer connection events. Powered by KitsuneCommand\'s in-process ring buffer. Restart clears the ring.', + totalRecorded: 'Events (lifetime)', + bufferUsage: 'Buffer usage', + verbose: 'Verbose console logging', + verboseOn: 'Now logging each event to nssm-stdout.log as [KC-NetDiag] lines.', + verboseOff: 'Console logging disabled; ring buffer recording continues.', + autoRefresh: 'Auto-refresh', + clear: 'Clear ring', + cleared: 'Join-attempt ring cleared.', + failedToClear: 'Failed to clear ring.', + failedToLoad: 'Failed to load join attempts', + failedToToggle: 'Failed to toggle verbose logging.', + peer: 'Peer', + time: 'When', + events: 'Events', + outcome: 'Outcome', + steps: 'Steps', + empty: 'No join attempts in the ring buffer. Try clicking Direct Connect on a 7DTD client to populate it.', + }, + map: { title: 'マップ', loadingMap: 'マップを読み込み中...', diff --git a/frontend/src/i18n/locales/ko.ts b/frontend/src/i18n/locales/ko.ts index d762e5d..9b94339 100644 --- a/frontend/src/i18n/locales/ko.ts +++ b/frontend/src/i18n/locales/ko.ts @@ -47,6 +47,7 @@ const ko: Messages = { dashboard: '대시보드', players: '플레이어', console: '콘솔', + joinAttempts: 'Join Attempts', map: '지도', chat: '채팅', teleport: '텔레포트', @@ -245,6 +246,29 @@ const ko: Messages = { clearLog: '지우기', }, + // English placeholders pending translation. + joinAttempts: { + title: 'Join Attempts', + subtitle: 'Live view of LiteNetLib-layer connection events. Powered by KitsuneCommand\'s in-process ring buffer. Restart clears the ring.', + totalRecorded: 'Events (lifetime)', + bufferUsage: 'Buffer usage', + verbose: 'Verbose console logging', + verboseOn: 'Now logging each event to nssm-stdout.log as [KC-NetDiag] lines.', + verboseOff: 'Console logging disabled; ring buffer recording continues.', + autoRefresh: 'Auto-refresh', + clear: 'Clear ring', + cleared: 'Join-attempt ring cleared.', + failedToClear: 'Failed to clear ring.', + failedToLoad: 'Failed to load join attempts', + failedToToggle: 'Failed to toggle verbose logging.', + peer: 'Peer', + time: 'When', + events: 'Events', + outcome: 'Outcome', + steps: 'Steps', + empty: 'No join attempts in the ring buffer. Try clicking Direct Connect on a 7DTD client to populate it.', + }, + map: { title: '지도', loadingMap: '지도 로딩 중...', diff --git a/frontend/src/i18n/locales/zh-CN.ts b/frontend/src/i18n/locales/zh-CN.ts index 9c78c54..6aaabef 100644 --- a/frontend/src/i18n/locales/zh-CN.ts +++ b/frontend/src/i18n/locales/zh-CN.ts @@ -47,6 +47,7 @@ const zhCN: Messages = { dashboard: '仪表盘', players: '玩家', console: '控制台', + joinAttempts: 'Join Attempts', map: '地图', chat: '聊天', teleport: '传送', @@ -245,6 +246,29 @@ const zhCN: Messages = { clearLog: '清除', }, + // English placeholders pending translation. + joinAttempts: { + title: 'Join Attempts', + subtitle: 'Live view of LiteNetLib-layer connection events. Powered by KitsuneCommand\'s in-process ring buffer. Restart clears the ring.', + totalRecorded: 'Events (lifetime)', + bufferUsage: 'Buffer usage', + verbose: 'Verbose console logging', + verboseOn: 'Now logging each event to nssm-stdout.log as [KC-NetDiag] lines.', + verboseOff: 'Console logging disabled; ring buffer recording continues.', + autoRefresh: 'Auto-refresh', + clear: 'Clear ring', + cleared: 'Join-attempt ring cleared.', + failedToClear: 'Failed to clear ring.', + failedToLoad: 'Failed to load join attempts', + failedToToggle: 'Failed to toggle verbose logging.', + peer: 'Peer', + time: 'When', + events: 'Events', + outcome: 'Outcome', + steps: 'Steps', + empty: 'No join attempts in the ring buffer. Try clicking Direct Connect on a 7DTD client to populate it.', + }, + map: { title: '地图', loadingMap: '正在加载地图...', diff --git a/frontend/src/i18n/locales/zh-TW.ts b/frontend/src/i18n/locales/zh-TW.ts index 95aba7a..909eb08 100644 --- a/frontend/src/i18n/locales/zh-TW.ts +++ b/frontend/src/i18n/locales/zh-TW.ts @@ -47,6 +47,7 @@ const zhTW: Messages = { dashboard: '儀表板', players: '玩家', console: '主控台', + joinAttempts: 'Join Attempts', map: '地圖', chat: '聊天', teleport: '傳送', @@ -245,6 +246,29 @@ const zhTW: Messages = { clearLog: '清除', }, + // English placeholders pending translation. + joinAttempts: { + title: 'Join Attempts', + subtitle: 'Live view of LiteNetLib-layer connection events. Powered by KitsuneCommand\'s in-process ring buffer. Restart clears the ring.', + totalRecorded: 'Events (lifetime)', + bufferUsage: 'Buffer usage', + verbose: 'Verbose console logging', + verboseOn: 'Now logging each event to nssm-stdout.log as [KC-NetDiag] lines.', + verboseOff: 'Console logging disabled; ring buffer recording continues.', + autoRefresh: 'Auto-refresh', + clear: 'Clear ring', + cleared: 'Join-attempt ring cleared.', + failedToClear: 'Failed to clear ring.', + failedToLoad: 'Failed to load join attempts', + failedToToggle: 'Failed to toggle verbose logging.', + peer: 'Peer', + time: 'When', + events: 'Events', + outcome: 'Outcome', + steps: 'Steps', + empty: 'No join attempts in the ring buffer. Try clicking Direct Connect on a 7DTD client to populate it.', + }, + map: { title: '地圖', loadingMap: '正在載入地圖...', diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index a90828c..edf64d7 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -36,6 +36,16 @@ const router = createRouter({ name: 'Console', component: () => import('@/views/ConsoleView.vue'), }, + { + // Diagnostic surface for LiteNetLib-layer connection events, + // powered by AuthWrapperServerDiagnostics + JoinAttemptRing. + // Lives alongside /console because it's the same family — + // operator-facing live diagnostics — just structured (events + // table) instead of free-form (log lines). + path: 'join-attempts', + name: 'JoinAttempts', + component: () => import('@/views/JoinAttemptsView.vue'), + }, { path: 'server', name: 'ServerControl', diff --git a/frontend/src/views/JoinAttemptsView.vue b/frontend/src/views/JoinAttemptsView.vue new file mode 100644 index 0000000..0eab131 --- /dev/null +++ b/frontend/src/views/JoinAttemptsView.vue @@ -0,0 +1,396 @@ + + + + + diff --git a/src/KitsuneCommand/Diagnostics/JoinAttemptEvent.cs b/src/KitsuneCommand/Diagnostics/JoinAttemptEvent.cs new file mode 100644 index 0000000..befe16e --- /dev/null +++ b/src/KitsuneCommand/Diagnostics/JoinAttemptEvent.cs @@ -0,0 +1,73 @@ +using System; + +namespace KitsuneCommand.Diagnostics +{ + /// + /// A single observed event in a client's connection lifecycle on the + /// server side, captured by + /// and persisted (in-memory only) by . + /// + /// One client click of "Direct Connect" in 7DTD typically generates 10-30 + /// of these as LiteNetLib bursts retries and the auth-state state machine + /// transitions. Operators reading these via the web panel use them to + /// answer "why did this player just fail to join" — the kind of question + /// vanilla 7DTD's `Peer disconnected in auth state: ... / 0` log line + /// flatly refuses to answer. + /// + /// Field names are deliberately panel-friendly (not snake_case): this + /// type is the JSON shape returned by the API and rendered in the Vue + /// frontend without remapping. + /// + public class JoinAttemptEvent + { + /// UTC timestamp the event was recorded by the patch. + public DateTime Timestamp { get; set; } + + /// + /// One of: ConnReq, Recv, Conn, Disc, Update. Mirrors the patch + /// surface in AuthWrapperServerDiagnostics. + /// + public string EventType { get; set; } + + /// Source IP of the peer the event is about. Null for Update events. + public string PeerIp { get; set; } + + /// Source port. Null for Update events. + public int? PeerPort { get; set; } + + /// + /// For ConnReq: Accept / Reject / RejectForce / None. + /// For Disc: the LiteNetLib DisconnectReason name (e.g. PeerNotFound, + /// Timeout, DisconnectPeerCalled). + /// Null for Conn / Recv / Update. + /// + public string Result { get; set; } + + /// + /// For ConnReq: the size of the connect-request payload from the client. + /// 2 bytes is the LiteNetLib protocol-version handshake; larger sizes mean + /// the client included extra app-level data. + /// 0 means the wrapper consumed the bytes during ConnectionRequestCheck + /// before the diagnostic Postfix ran (so the connect succeeded past the + /// pre-rate-limit gate). + /// Null when not applicable. + /// + public int? DataBytes { get; set; } + + /// Channel byte for Recv events; null otherwise. + public int? Channel { get; set; } + + /// Delivery method for Recv events (ReliableOrdered, Unreliable, etc.); null otherwise. + public string DeliveryMethod { get; set; } + + /// Size of the disconnect packet's optional payload, for Disc events. + public int? ExtraDataBytes { get; set; } + + /// + /// authStates dict size AT THE TIME OF THIS EVENT, snapshotted via reflection + /// from the wrapper instance. Useful for spotting bursts (multiple peers + /// in auth state simultaneously) and stuck connections (count stays > 0). + /// + public int? AuthStateCount { get; set; } + } +} diff --git a/src/KitsuneCommand/Diagnostics/JoinAttemptRing.cs b/src/KitsuneCommand/Diagnostics/JoinAttemptRing.cs new file mode 100644 index 0000000..0ca7f81 --- /dev/null +++ b/src/KitsuneCommand/Diagnostics/JoinAttemptRing.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; + +namespace KitsuneCommand.Diagnostics +{ + /// + /// In-memory ring buffer for the most recent + /// s captured by the + /// AuthWrapperServerDiagnostics Harmony patches. + /// + /// Why a ring buffer and not the SQLite DB: + /// + /// Events fire at LiteNetLib speeds — a single failed-join burst + /// can produce 30+ events in 5 seconds. Writing each one to SQLite from + /// inside a Harmony Postfix on the network thread would push contention + /// onto a path that's hot during the exact moments operators care + /// about. + /// The use case is diagnostic, not audit. Operators want "what's + /// happening RIGHT NOW" not "what happened three months ago." Memory + /// is the right tier; restart-on-failure is acceptable. + /// A bounded buffer also caps the memory footprint regardless of + /// how aggressively a bad actor or broken router hammers the + /// server. + /// + /// + /// Capacity defaults to 500 events. At ~250 bytes per event that's ~125 KB + /// — trivial. A typical bad-join burst is 10-15 events, so 500 holds the + /// last ~30 distinct join attempts. + /// + /// Thread safety: is called from the LiteNetLib + /// network thread (where Harmony Postfixes execute); + /// is called from the OWIN HTTP thread. A single lock protects both — + /// contention is minimal because both paths are short, and the snapshot + /// copies the data out of the buffer before returning so the lock window + /// is just the copy, not the network IO. + /// + public static class JoinAttemptRing + { + public const int Capacity = 500; + + private static readonly object _lock = new object(); + private static readonly JoinAttemptEvent[] _buffer = new JoinAttemptEvent[Capacity]; + + /// Next slot to write. Wraps modulo Capacity. + private static int _next = 0; + + /// Total events ever recorded since process start. Exposed for stats / debug. + private static long _totalRecorded = 0; + + /// Total events captured since process start (monotonically increasing). + public static long TotalRecorded + { + get { lock (_lock) { return _totalRecorded; } } + } + + /// + /// Record an event. Cheap, non-allocating beyond the event object the + /// caller already constructed. Silently no-ops on null to keep the + /// patches forgiving (a malformed event from some edge case won't + /// crash the auth wrapper). + /// + public static void Record(JoinAttemptEvent ev) + { + if (ev == null) return; + lock (_lock) + { + _buffer[_next] = ev; + _next = (_next + 1) % Capacity; + _totalRecorded++; + } + } + + /// + /// Get up to most-recent events, optionally + /// filtered to events at or after . Returns + /// newest-first order — the same order operators want in a "live + /// activity" panel. + /// + public static List Snapshot(int limit = 100, DateTime? sinceUtc = null) + { + if (limit <= 0) return new List(); + if (limit > Capacity) limit = Capacity; + + var result = new List(limit); + lock (_lock) + { + // Walk backward from _next (one past most recent) up to Capacity slots. + for (int i = 0; i < Capacity && result.Count < limit; i++) + { + int idx = ((_next - 1 - i) + Capacity) % Capacity; + var ev = _buffer[idx]; + if (ev == null) continue; + if (sinceUtc.HasValue && ev.Timestamp < sinceUtc.Value) break; + result.Add(ev); + } + } + return result; + } + + /// + /// Drop everything. Operator-triggered reset useful for "start fresh + /// before reproducing the bug" debugging flows. + /// + public static void Clear() + { + lock (_lock) + { + Array.Clear(_buffer, 0, _buffer.Length); + _next = 0; + // _totalRecorded intentionally NOT reset — it's a monotonic + // counter representing process lifetime activity, useful even + // after a clear. + } + } + } +} diff --git a/src/KitsuneCommand/GameIntegration/Harmony/AuthWrapperServerDiagnostics.cs b/src/KitsuneCommand/GameIntegration/Harmony/AuthWrapperServerDiagnostics.cs new file mode 100644 index 0000000..dfb8e67 --- /dev/null +++ b/src/KitsuneCommand/GameIntegration/Harmony/AuthWrapperServerDiagnostics.cs @@ -0,0 +1,405 @@ +using HarmonyLib; +using KitsuneCommand.Diagnostics; +using LiteNetLib; +using System; +using System.Collections; +using System.Net; +using System.Reflection; + +namespace KitsuneCommand.GameIntegration.Harmony +{ + /// + /// Diagnostic Harmony patches on the 7DTD server-side LiteNetLib auth + /// wrapper (NetworkServerLiteNetLib+LiteNetLibAuthWrapperServer). + /// + /// 7DTD's challenge-response handshake state machine is normally invisible + /// at the default INF log level — only the terminal "Peer disconnected + /// in auth state: {ip} / {reason-int}" shows, leaving operators no way + /// to tell whether a disconnect was a rate-limit reject, a client + /// challenge-response timeout, an invalid response, an auth-state Update() + /// sweep, or something else. + /// + /// These patches do TWO things on every relevant event: + /// + /// Record a structured into + /// the in-memory . Always on. The KC web + /// panel's "Join Attempts" page reads this ring. Cheap — bounded + /// capacity, single lock, no I/O. + /// Verbose-log the same event to the 7DTD console at INF + /// level, tagged [KC-NetDiag]. Gated by + /// (default false) because the output is *extremely* chatty. Flip on + /// when you want the log file populated too. + /// + /// + /// PURE OBSERVATION — every patch is a Postfix or non-mutating Prefix. + /// The 500ms connection rate limit, 10s MaxDurationInAuthState, and every + /// other behavior knob are intentionally left untouched. The goal is to + /// SEE what's happening, not to change it. + /// + /// Why this exists: investigating a "Could not retrieve server + /// information" failure where the only signal was / 0 for the + /// reason code (turned out to be `PeerNotFound`, which mapped to a + /// router-NAT issue). Permanent enough to keep around. + /// + public static class AuthWrapperServerDiagnostics + { + /// + /// Verbose console logging gate. + /// recording happens regardless — this only controls whether each + /// event ALSO produces a [KC-NetDiag] line in nssm-stdout.log. + /// + /// Flip via reflection from a KC console command, the web panel + /// (planned), or in code where needed. Default is off because the + /// log output during a single failed-join burst is ~30 lines in 5 + /// seconds — fine while reproducing a specific bug, exhausting in + /// steady state. + /// + public static bool Enabled = false; + + // Lazy reflection handle on the wrapper's internal authStates + // dictionary, used for log-context only (we read .Count, never + // mutate). Cached at first access to avoid reflection cost per event. + private static FieldInfo _authStatesField; + private static FieldInfo AuthStatesField + { + get + { + if (_authStatesField == null) + { + _authStatesField = AccessTools.Field( + typeof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer), + "authStates"); + } + return _authStatesField; + } + } + + // ConnectionRequest.RemoteEndPoint is an internal field in this + // LiteNetLib build — not exposed as a public property. Reflection + // handle so we can record + log who the request came from. + private static FieldInfo _crRemoteEndPointField; + private static FieldInfo CrRemoteEndPointField + { + get + { + if (_crRemoteEndPointField == null) + { + _crRemoteEndPointField = AccessTools.Field( + typeof(ConnectionRequest), "RemoteEndPoint"); + } + return _crRemoteEndPointField; + } + } + + // ConnectionRequest.Result is an internal property (and its type + // ConnectionRequestResult is internal too — so we can't even name + // it in C#). Read via reflection and ToString() the boxed enum + // value. The string is what we want for the log + ring anyway — + // None/Accept/Reject/RejectForce. + private static PropertyInfo _crResultProp; + private static PropertyInfo CrResultProp + { + get + { + if (_crResultProp == null) + { + _crResultProp = AccessTools.Property( + typeof(ConnectionRequest), "Result"); + } + return _crResultProp; + } + } + + private static string RequestResult(ConnectionRequest req) + { + if (req == null) return null; + try + { + var v = CrResultProp?.GetValue(req); + return v?.ToString(); + } + catch { return null; } + } + + /// Snapshot of authStates.Count, or null on any failure. + private static int? AuthStateCount( + NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer instance) + { + try + { + var dict = AuthStatesField?.GetValue(instance) as ICollection; + return dict?.Count; + } + catch + { + return null; + } + } + + // -------- Endpoint extractors -------- + // Two flavors: one returning IP+port as separate values (for the + // ring's JoinAttemptEvent which stores them separately), one returning + // a combined "ip:port" string (for the verbose log line). + + private static (string ip, int? port) PeerIpPort(NetPeer peer) + { + if (peer == null) return (null, null); + try { return (peer.Address?.ToString(), peer.Port); } + catch { return (null, null); } + } + + private static (string ip, int? port) RequestIpPort(ConnectionRequest req) + { + if (req == null) return (null, null); + try + { + var ep = CrRemoteEndPointField?.GetValue(req) as IPEndPoint; + if (ep == null) return (null, null); + return (ep.Address?.ToString(), ep.Port); + } + catch { return (null, null); } + } + + private static string Ep(string ip, int? port) + { + if (ip == null) return "(null)"; + return port.HasValue ? (ip + ":" + port.Value) : ip; + } + + // -------- Patch surfaces -------- + + // --- 1. Connection request arrived (pre-handshake) --- + + [HarmonyPatch( + typeof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer), + nameof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer.ConnectionRequestCheck))] + public static class ConnectionRequestCheckPatch + { + [HarmonyPostfix] + public static void Postfix( + NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer __instance, + ConnectionRequest _request) + { + // _request.Result is set BY THIS METHOD before we run as + // postfix — so reading it here tells us whether the wrapper + // accepted, rejected, or force-rejected. Decisive signal. + try + { + var (ip, port) = RequestIpPort(_request); + var result = RequestResult(_request); + var dataBytes = _request?.Data?.AvailableBytes; + var authCount = AuthStateCount(__instance); + + JoinAttemptRing.Record(new JoinAttemptEvent + { + Timestamp = DateTime.UtcNow, + EventType = "ConnReq", + PeerIp = ip, + PeerPort = port, + Result = result, + DataBytes = dataBytes, + AuthStateCount = authCount, + }); + + if (Enabled) + { + Log.Out("[KC-NetDiag] ConnReq peer=" + Ep(ip, port) + + " result=" + (result ?? "(unknown)") + + " dataBytes=" + (dataBytes ?? -1) + + " authStateCount=" + (authCount ?? -1)); + } + } + catch (Exception ex) + { + if (Enabled) Log.Warning("[KC-NetDiag] ConnReq Postfix: " + ex.Message); + } + } + } + + // --- 2. Packet received from a peer (challenge response lives here) --- + + [HarmonyPatch( + typeof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer), + nameof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer.OnNetworkReceiveEvent))] + public static class OnNetworkReceiveEventPatch + { + [HarmonyPostfix] + public static void Postfix( + NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer __instance, + NetPeer _peer, + byte _channel, + DeliveryMethod _deliveryMethod) + { + try + { + var (ip, port) = PeerIpPort(_peer); + var authCount = AuthStateCount(__instance); + + JoinAttemptRing.Record(new JoinAttemptEvent + { + Timestamp = DateTime.UtcNow, + EventType = "Recv", + PeerIp = ip, + PeerPort = port, + Channel = _channel, + DeliveryMethod = _deliveryMethod.ToString(), + AuthStateCount = authCount, + }); + + if (Enabled) + { + Log.Out("[KC-NetDiag] Recv peer=" + Ep(ip, port) + + " channel=" + _channel + + " delivery=" + _deliveryMethod + + " authStateCount=" + (authCount ?? -1)); + } + } + catch (Exception ex) + { + if (Enabled) Log.Warning("[KC-NetDiag] Recv Postfix: " + ex.Message); + } + } + } + + // --- 3. Peer officially connected (challenge-response succeeded) --- + + [HarmonyPatch( + typeof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer), + nameof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer.OnPeerConnectedEvent))] + public static class OnPeerConnectedEventPatch + { + [HarmonyPostfix] + public static void Postfix( + NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer __instance, + NetPeer _peer) + { + try + { + var (ip, port) = PeerIpPort(_peer); + var authCount = AuthStateCount(__instance); + + JoinAttemptRing.Record(new JoinAttemptEvent + { + Timestamp = DateTime.UtcNow, + EventType = "Conn", + PeerIp = ip, + PeerPort = port, + AuthStateCount = authCount, + }); + + if (Enabled) + { + Log.Out("[KC-NetDiag] Conn peer=" + Ep(ip, port) + + " (challenge passed)" + + " authStateCount=" + (authCount ?? -1)); + } + } + catch (Exception ex) + { + if (Enabled) Log.Warning("[KC-NetDiag] Conn Postfix: " + ex.Message); + } + } + } + + // --- 4. Peer disconnect — THE KEY ONE --- + // + // Prefix runs before the wrapper's own generic "Peer disconnected in + // auth state: {0} / {1}" message, so the human-readable reason name + // appears in the log right above the existing line for correlation. + // Reasons we expect to see (LiteNetLib's DisconnectReason enum): + // ConnectionFailed / Timeout / HostUnreachable / NetworkUnreachable + // / RemoteConnectionClose / DisconnectPeerCalled / ConnectionRejected + // / InvalidProtocol / UnknownHost / Reconnect / PeerToPeerConnection + // / PeerNotFound + + [HarmonyPatch( + typeof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer), + nameof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer.OnPeerDisconnectedEvent))] + public static class OnPeerDisconnectedEventPatch + { + [HarmonyPrefix] + public static void Prefix( + NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer __instance, + NetPeer _peer, + DisconnectInfo _disconnectInfo) + { + try + { + var (ip, port) = PeerIpPort(_peer); + var reason = _disconnectInfo.Reason.ToString(); + var extraBytes = _disconnectInfo.AdditionalData?.AvailableBytes; + var authCount = AuthStateCount(__instance); + + JoinAttemptRing.Record(new JoinAttemptEvent + { + Timestamp = DateTime.UtcNow, + EventType = "Disc", + PeerIp = ip, + PeerPort = port, + Result = reason, + ExtraDataBytes = extraBytes, + AuthStateCount = authCount, + }); + + if (Enabled) + { + Log.Out("[KC-NetDiag] Disc peer=" + Ep(ip, port) + + " reason=" + reason + + " extraDataBytes=" + extraBytes + + " authStateCount=" + (authCount ?? -1)); + } + } + catch (Exception ex) + { + if (Enabled) Log.Warning("[KC-NetDiag] Disc Prefix: " + ex.Message); + } + } + } + + // --- 5. Periodic Update — catches auth-state timeout reaps --- + // + // Update() runs on a fixed ConnectionStateCheckInterval (10s). The + // wrapper kills any peer that's been in auth state longer than + // MaxDurationInAuthState (10s) here, which is a path that does NOT + // necessarily go through OnPeerDisconnectedEvent. We only log/record + // when the count changes — otherwise this fires too often to be useful. + + [HarmonyPatch( + typeof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer), + nameof(NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer.Update))] + public static class UpdatePatch + { + private static int _lastObservedCount; + + [HarmonyPostfix] + public static void Postfix( + NetworkServerLiteNetLib.LiteNetLibAuthWrapperServer __instance) + { + try + { + var authCount = AuthStateCount(__instance); + int n = authCount ?? -1; + if (n == _lastObservedCount) return; + + JoinAttemptRing.Record(new JoinAttemptEvent + { + Timestamp = DateTime.UtcNow, + EventType = "Update", + AuthStateCount = authCount, + }); + + if (Enabled) + { + Log.Out("[KC-NetDiag] Update authStateCount: " + + _lastObservedCount + " → " + n); + } + _lastObservedCount = n; + } + catch (Exception ex) + { + if (Enabled) Log.Warning("[KC-NetDiag] Update Postfix: " + ex.Message); + } + } + } + } +} diff --git a/src/KitsuneCommand/KitsuneCommand.csproj b/src/KitsuneCommand/KitsuneCommand.csproj index 2aa961c..c296287 100644 --- a/src/KitsuneCommand/KitsuneCommand.csproj +++ b/src/KitsuneCommand/KitsuneCommand.csproj @@ -123,6 +123,17 @@ refs\0Harmony.dll false + + + refs\LiteNetLib.dll + false + @@ -163,6 +174,17 @@ test fails OneTimeSetUp with DllNotFoundException. --> + + + diff --git a/src/KitsuneCommand/Web/Controllers/JoinAttemptsController.cs b/src/KitsuneCommand/Web/Controllers/JoinAttemptsController.cs new file mode 100644 index 0000000..31bc6df --- /dev/null +++ b/src/KitsuneCommand/Web/Controllers/JoinAttemptsController.cs @@ -0,0 +1,109 @@ +using System; +using System.Web.Http; +using KitsuneCommand.Diagnostics; +using KitsuneCommand.GameIntegration.Harmony; +using KitsuneCommand.Web.Auth; +using KitsuneCommand.Web.Models; + +namespace KitsuneCommand.Web.Controllers +{ + /// + /// Reads the in-memory populated by + /// so the panel's + /// "Join Attempts" page can show operators what's happening at + /// connection-time — specifically the LiteNetLib-layer detail that + /// 7DTD's vanilla "Peer disconnected in auth state: ... / 0" log line + /// hides. + /// + /// All endpoints are admin-only. The data isn't terribly sensitive + /// (IPs + ports + protocol-level state), but knowing which IPs are + /// hammering the server with failed handshakes IS the kind of thing + /// you'd want an admin gate on. + /// + [Authorize] + [RoutePrefix("api/join-attempts")] + public class JoinAttemptsController : ApiController + { + /// + /// Get the most recent join-attempt events. Returns newest-first. + /// + /// Query params: + /// + /// limit — max events to return (default 100, capped at 500) + /// since — ISO-8601 UTC timestamp; only events at or + /// after this time. Combine with the previous-page's newest + /// timestamp for incremental polling. + /// + /// + [HttpGet] + [Route("")] + [RoleAuthorize("admin")] + public IHttpActionResult List(int limit = 100, string since = null) + { + DateTime? sinceUtc = null; + if (!string.IsNullOrEmpty(since)) + { + if (DateTime.TryParse(since, null, System.Globalization.DateTimeStyles.AssumeUniversal | System.Globalization.DateTimeStyles.AdjustToUniversal, out var parsed)) + { + sinceUtc = parsed; + } + else + { + return Ok(ApiResponse.Error(400, "Invalid 'since' parameter; expected ISO-8601 timestamp.")); + } + } + + var events = JoinAttemptRing.Snapshot(limit, sinceUtc); + return Ok(ApiResponse.Ok(new + { + events, + totalRecorded = JoinAttemptRing.TotalRecorded, + verboseLogging = AuthWrapperServerDiagnostics.Enabled, + capacity = JoinAttemptRing.Capacity, + })); + } + + /// + /// Clear the ring buffer. Useful for "start fresh before reproducing + /// the bug" debugging flows. Doesn't reset the monotonic + /// totalRecorded counter — that's process-lifetime activity. + /// + [HttpPost] + [Route("clear")] + [RoleAuthorize("admin")] + public IHttpActionResult Clear() + { + JoinAttemptRing.Clear(); + return Ok(ApiResponse.Ok("Join-attempt ring cleared.")); + } + + /// + /// Toggle the verbose-console-logging side of the diagnostics. The + /// ring buffer always records regardless; this controls only whether + /// each event ALSO produces a [KC-NetDiag] line in + /// nssm-stdout.log. + /// + /// Default off — recommended for steady-state. Flip on when + /// reproducing a specific bug and you want the log file populated + /// alongside the panel. + /// + [HttpPost] + [Route("verbose")] + [RoleAuthorize("admin")] + public IHttpActionResult SetVerbose([FromBody] VerboseRequest body) + { + if (body == null) + return Ok(ApiResponse.Error(400, "Body required: { \"enabled\": true|false }")); + + AuthWrapperServerDiagnostics.Enabled = body.Enabled; + Log.Out("[KitsuneCommand] AuthWrapperServerDiagnostics verbose logging " + + (body.Enabled ? "ENABLED" : "disabled")); + return Ok(ApiResponse.Ok(new { enabled = body.Enabled })); + } + + public class VerboseRequest + { + public bool Enabled { get; set; } + } + } +}