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-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/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/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/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/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/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/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; } + } + } +} 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(); diff --git a/src/KitsuneCommand/refs/LiteNetLib.dll b/src/KitsuneCommand/refs/LiteNetLib.dll new file mode 100644 index 0000000..9db6483 Binary files /dev/null and b/src/KitsuneCommand/refs/LiteNetLib.dll differ 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