diff --git a/dashboard/src/server/authflow.ts b/dashboard/src/server/authflow.ts index 0383af6..f5b43db 100644 --- a/dashboard/src/server/authflow.ts +++ b/dashboard/src/server/authflow.ts @@ -157,6 +157,27 @@ export async function importFromWhitelist(): Promise { const g = globalThis as unknown as { __craftdeckAuthWatch?: { timer: NodeJS.Timeout } }; +/** + * Consecutive whitelist-heal failures per player. After a few strikes the + * admin gets ONE loud notification instead of a silent 5-second retry loop. + */ +const healFailures = new Map(); + +function reportHealFailure(username: string, message: string) { + const entry = healFailures.get(username) ?? { count: 0, notified: false }; + entry.count++; + if (entry.count >= 3 && !entry.notified) { + entry.notified = true; + notify( + "error", + `Whitelist is not applying for ${username}`, + `The server keeps rejecting the entry after several retries (${message}). ` + + `Check the Console page and docs/troubleshooting.md — the panel will keep retrying meanwhile.` + ); + } + healFailures.set(username, entry); +} + async function reconcile() { const pending = db() .prepare("SELECT * FROM approved_players WHERE status = 'pending'") @@ -183,14 +204,23 @@ async function reconcile() { const live = new Set((await whitelistLive()).map((n) => n.toLowerCase())); const missing = pending.filter((p) => !live.has(p.username.toLowerCase())); if (missing.length > 0) { - for (const p of missing) await whitelistAdd(p.username); // rewrites file + reloads + let healed = true; + for (const p of missing) { + try { + await whitelistAdd(p.username); // rewrites files + reloads + verifies + } catch (err) { + healed = false; + reportHealFailure(p.username, (err as Error).message); + } + } // They couldn't have joined yet — give the windows back the lost time. db() .prepare("UPDATE approved_players SET window_expires_at = window_expires_at + 5000 WHERE status = 'pending'") .run(); - logActivity("whitelist_heal", null, missing.map((m) => m.username).join(", ")); + if (healed) logActivity("whitelist_heal", null, missing.map((m) => m.username).join(", ")); return; } + healFailures.clear(); // whitelist is consistent again } catch { // whitelist list unavailable — fall through and try again next tick } diff --git a/dashboard/src/server/logwatch.ts b/dashboard/src/server/logwatch.ts index ca4dc5e..def5730 100644 --- a/dashboard/src/server/logwatch.ts +++ b/dashboard/src/server/logwatch.ts @@ -2,6 +2,7 @@ import { streamContainerLogs, type LogStreamHandle } from "./docker"; import { bus, pushLogLine } from "./bus"; import { db, logActivity, getBoolSetting } from "./db"; import { notify } from "./notify"; +import { noteObservedUuid } from "./mc"; import { env } from "./env"; /** @@ -40,6 +41,14 @@ function shouldNotifyBlocked(key: string): boolean { function parseEvent(line: string, backfill: boolean) { const msg = messageOf(line).replace(/§[0-9a-fk-or]/gi, ""); + // The server announces each connecting player's UUID — remember it so + // whitelist entries always use exactly what the server will match. + const uuidLine = msg.match(/^UUID of player ([A-Za-z0-9_]{3,16}) is ([0-9a-fA-F-]{36})/); + if (uuidLine) { + noteObservedUuid(uuidLine[1], uuidLine[2]); + return; + } + // spark ping output routed through the console (see sampler fallback): // "[⚡] Alice: 23ms" const ping = msg.match(/\[⚡\]\s*([A-Za-z0-9_]{3,16}):\s*([\d.]+)\s*ms/); diff --git a/dashboard/src/server/mc.ts b/dashboard/src/server/mc.ts index d24a58b..a958c89 100644 --- a/dashboard/src/server/mc.ts +++ b/dashboard/src/server/mc.ts @@ -65,42 +65,83 @@ export function offlineUuid(name: string): string { type WhitelistEntry = { uuid: string; name: string }; -function whitelistPath(): string { - return path.join(env.mcDataDir, "whitelist.json"); -} +// 26.x renamed server files as part of the registry cleanup; startup still +// migrates the legacy name but `whitelist reload` and shutdown saves use the +// new one. Writing both keeps every code path in agreement on any version. +const WHITELIST_FILES = ["allowlist.json", "whitelist.json"]; function readWhitelistFile(): WhitelistEntry[] { - try { - const data = JSON.parse(fs.readFileSync(whitelistPath(), "utf8")); - return Array.isArray(data) ? (data as WhitelistEntry[]) : []; - } catch { - return []; + const byName = new Map(); + for (const file of WHITELIST_FILES) { + try { + const data = JSON.parse(fs.readFileSync(path.join(env.mcDataDir, file), "utf8")); + if (Array.isArray(data)) { + for (const e of data as WhitelistEntry[]) { + if (e?.name && !byName.has(e.name.toLowerCase())) byName.set(e.name.toLowerCase(), e); + } + } + } catch {} } + return [...byName.values()]; } function writeWhitelistFile(entries: WhitelistEntry[]) { - fs.writeFileSync(whitelistPath(), JSON.stringify(entries, null, 2) + "\n"); - try { - fs.chownSync(whitelistPath(), 1000, 1000); // keep it writable by the MC user - } catch {} + const json = JSON.stringify(entries, null, 2) + "\n"; + for (const file of WHITELIST_FILES) { + const p = path.join(env.mcDataDir, file); + fs.writeFileSync(p, json); + try { + fs.chownSync(p, 1000, 1000); // keep it writable by the MC user + } catch {} + } } async function reloadWhitelist(): Promise { await rcon().exec("whitelist reload"); } +/** + * UUIDs the server itself reported in log lines ("UUID of player X is …"). + * These beat any derivation we could do — if Mojang changes the offline-UUID + * scheme again, the panel adapts automatically. + */ +const observedUuids = new Map(); + +export function noteObservedUuid(name: string, uuid: string) { + observedUuids.set(name.toLowerCase(), uuid.toLowerCase()); +} + export async function whitelistAdd(name: string): Promise { assertValidUsername(name); + const uuid = observedUuids.get(name.toLowerCase()) ?? offlineUuid(name); const entries = readWhitelistFile().filter((e) => e.name.toLowerCase() !== name.toLowerCase()); - entries.push({ uuid: offlineUuid(name), name }); + entries.push({ uuid, name }); writeWhitelistFile(entries); await reloadWhitelist(); - return `Added ${name} to the whitelist (offline UUID)`; + + // Trust nothing: confirm the server actually loaded the entry. + let live = await whitelistLive(); + if (live.some((n) => n.toLowerCase() === name.toLowerCase())) { + return `Added ${name} to the whitelist`; + } + // File route didn't take — fall back to the vanilla command (in-memory, + // applies instantly) and re-verify. + const out = stripColors(await rcon().exec(`whitelist add ${name}`)); + live = await whitelistLive(); + if (live.some((n) => n.toLowerCase() === name.toLowerCase())) { + return `Added ${name} to the whitelist (via command)`; + } + throw new Error( + `The server did not accept the whitelist entry for ${name} (reload + command both failed: ${out.trim() || "no output"})` + ); } export async function whitelistRemove(name: string): Promise { assertValidUsername(name); writeWhitelistFile(readWhitelistFile().filter((e) => e.name.toLowerCase() !== name.toLowerCase())); + try { + await rcon().exec(`whitelist remove ${name}`); // clears any in-memory entry + } catch {} await reloadWhitelist(); return `Removed ${name} from the whitelist`; }