Whitelist: write allowlist.json+whitelist.json, verify after reload, learn UUIDs from logs

On 26.2 the file whitelist entries load at startup but 'whitelist reload'
and shutdown saves use the renamed allowlist.json, so panel writes never
took effect live and got wiped on stop. Now the panel writes both file
names, verifies the player actually appears in 'whitelist list' after
reload, falls back to the vanilla add command, and captures each player's
real UUID from 'UUID of player …' log lines for future entries. The auth
watcher's self-heal loop reports a loud notification after 3 failed
retries instead of silently spamming reloads.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Fable 5 committed 2026-07-11 17:16:22 -05:00
1 parent b2ff88b255
commit b043f03d28
3 files changed
+93 -13

No files matched your search

+32 -2
View File
@@ -157,6 +157,27 @@ export async function importFromWhitelist(): Promise<number> {
const g = globalThis as unknown as { __craftdeckAuthWatch?: { timer: NodeJS.Timeout } }; 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<string, { count: number; notified: boolean }>();
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() { async function reconcile() {
const pending = db() const pending = db()
.prepare("SELECT * FROM approved_players WHERE status = 'pending'") .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 live = new Set((await whitelistLive()).map((n) => n.toLowerCase()));
const missing = pending.filter((p) => !live.has(p.username.toLowerCase())); const missing = pending.filter((p) => !live.has(p.username.toLowerCase()));
if (missing.length > 0) { 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. // They couldn't have joined yet — give the windows back the lost time.
db() db()
.prepare("UPDATE approved_players SET window_expires_at = window_expires_at + 5000 WHERE status = 'pending'") .prepare("UPDATE approved_players SET window_expires_at = window_expires_at + 5000 WHERE status = 'pending'")
.run(); .run();
logActivity("whitelist_heal", null, missing.map((m) => m.username).join(", ")); if (healed) logActivity("whitelist_heal", null, missing.map((m) => m.username).join(", "));
return; return;
} }
healFailures.clear(); // whitelist is consistent again
} catch { } catch {
// whitelist list unavailable — fall through and try again next tick // whitelist list unavailable — fall through and try again next tick
} }
+9
View File
@@ -2,6 +2,7 @@ import { streamContainerLogs, type LogStreamHandle } from "./docker";
import { bus, pushLogLine } from "./bus"; import { bus, pushLogLine } from "./bus";
import { db, logActivity, getBoolSetting } from "./db"; import { db, logActivity, getBoolSetting } from "./db";
import { notify } from "./notify"; import { notify } from "./notify";
import { noteObservedUuid } from "./mc";
import { env } from "./env"; import { env } from "./env";
/** /**
@@ -40,6 +41,14 @@ function shouldNotifyBlocked(key: string): boolean {
function parseEvent(line: string, backfill: boolean) { function parseEvent(line: string, backfill: boolean) {
const msg = messageOf(line).replace(/§[0-9a-fk-or]/gi, ""); 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): // spark ping output routed through the console (see sampler fallback):
// "[⚡] Alice: 23ms" // "[⚡] Alice: 23ms"
const ping = msg.match(/\[⚡\]\s*([A-Za-z0-9_]{3,16}):\s*([\d.]+)\s*ms/); const ping = msg.match(/\[⚡\]\s*([A-Za-z0-9_]{3,16}):\s*([\d.]+)\s*ms/);
+52 -11
View File
@@ -65,42 +65,83 @@ export function offlineUuid(name: string): string {
type WhitelistEntry = { uuid: string; name: string }; type WhitelistEntry = { uuid: string; name: string };
function whitelistPath(): string { // 26.x renamed server files as part of the registry cleanup; startup still
return path.join(env.mcDataDir, "whitelist.json"); // 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[] { function readWhitelistFile(): WhitelistEntry[] {
const byName = new Map<string, WhitelistEntry>();
for (const file of WHITELIST_FILES) {
try { try {
const data = JSON.parse(fs.readFileSync(whitelistPath(), "utf8")); const data = JSON.parse(fs.readFileSync(path.join(env.mcDataDir, file), "utf8"));
return Array.isArray(data) ? (data as WhitelistEntry[]) : []; if (Array.isArray(data)) {
} catch { for (const e of data as WhitelistEntry[]) {
return []; if (e?.name && !byName.has(e.name.toLowerCase())) byName.set(e.name.toLowerCase(), e);
} }
} }
} catch {}
}
return [...byName.values()];
}
function writeWhitelistFile(entries: WhitelistEntry[]) { function writeWhitelistFile(entries: WhitelistEntry[]) {
fs.writeFileSync(whitelistPath(), JSON.stringify(entries, null, 2) + "\n"); 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 { try {
fs.chownSync(whitelistPath(), 1000, 1000); // keep it writable by the MC user fs.chownSync(p, 1000, 1000); // keep it writable by the MC user
} catch {} } catch {}
} }
}
async function reloadWhitelist(): Promise<void> { async function reloadWhitelist(): Promise<void> {
await rcon().exec("whitelist reload"); 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<string, string>();
export function noteObservedUuid(name: string, uuid: string) {
observedUuids.set(name.toLowerCase(), uuid.toLowerCase());
}
export async function whitelistAdd(name: string): Promise<string> { export async function whitelistAdd(name: string): Promise<string> {
assertValidUsername(name); assertValidUsername(name);
const uuid = observedUuids.get(name.toLowerCase()) ?? offlineUuid(name);
const entries = readWhitelistFile().filter((e) => e.name.toLowerCase() !== name.toLowerCase()); const entries = readWhitelistFile().filter((e) => e.name.toLowerCase() !== name.toLowerCase());
entries.push({ uuid: offlineUuid(name), name }); entries.push({ uuid, name });
writeWhitelistFile(entries); writeWhitelistFile(entries);
await reloadWhitelist(); 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<string> { export async function whitelistRemove(name: string): Promise<string> {
assertValidUsername(name); assertValidUsername(name);
writeWhitelistFile(readWhitelistFile().filter((e) => e.name.toLowerCase() !== name.toLowerCase())); writeWhitelistFile(readWhitelistFile().filter((e) => e.name.toLowerCase() !== name.toLowerCase()));
try {
await rcon().exec(`whitelist remove ${name}`); // clears any in-memory entry
} catch {}
await reloadWhitelist(); await reloadWhitelist();
return `Removed ${name} from the whitelist`; return `Removed ${name} from the whitelist`;
} }