Whitelist by offline UUID: write whitelist.json directly, not 'whitelist add'
The vanilla command resolves never-seen names through Mojang's API, so a premium account's UUID gets whitelisted instead of the offline UUID the player actually connects with — the exact 'You are not white-listed' loop hit on first join. The panel now computes the offline UUID (md5 OfflinePlayer:<name>, UUID v3 — verified against known vectors), writes whitelist.json itself, and issues 'whitelist reload'. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6b7839360d
commit
4fe304c5af
2 files changed
+68
-3
No files matched your search
@@ -1,3 +1,6 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { rcon, stripColors, RconError } from "./rcon";
|
import { rcon, stripColors, RconError } from "./rcon";
|
||||||
import { execInContainer } from "./docker";
|
import { execInContainer } from "./docker";
|
||||||
import { env } from "./env";
|
import { env } from "./env";
|
||||||
@@ -42,20 +45,74 @@ export async function say(message: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── whitelist ───────────────────────────────────────────────────────────
|
// ── whitelist ───────────────────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// In offline mode the whitelist matches by UUID, and the UUID of an offline
|
||||||
|
// player is derived from their name (md5 of "OfflinePlayer:<name>", UUID v3).
|
||||||
|
// The vanilla `whitelist add` command instead asks Mojang's API for the name
|
||||||
|
// and stores the *premium* account's UUID — so a friend who has never joined
|
||||||
|
// gets "You are not white-listed" even though their name is on the list.
|
||||||
|
// We therefore write whitelist.json ourselves with the correct offline UUID
|
||||||
|
// and just tell the server to reload it.
|
||||||
|
|
||||||
|
/** Java's UUID.nameUUIDFromBytes("OfflinePlayer:<name>") — what offline servers assign. */
|
||||||
|
export function offlineUuid(name: string): string {
|
||||||
|
const hash = createHash("md5").update(`OfflinePlayer:${name}`, "utf8").digest();
|
||||||
|
hash[6] = (hash[6] & 0x0f) | 0x30; // version 3
|
||||||
|
hash[8] = (hash[8] & 0x3f) | 0x80; // IETF variant
|
||||||
|
const hex = hash.toString("hex");
|
||||||
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type WhitelistEntry = { uuid: string; name: string };
|
||||||
|
|
||||||
|
function whitelistPath(): string {
|
||||||
|
return path.join(env.mcDataDir, "whitelist.json");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readWhitelistFile(): WhitelistEntry[] {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(whitelistPath(), "utf8"));
|
||||||
|
return Array.isArray(data) ? (data as WhitelistEntry[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadWhitelist(): Promise<void> {
|
||||||
|
await rcon().exec("whitelist reload");
|
||||||
|
}
|
||||||
|
|
||||||
export async function whitelistAdd(name: string): Promise<string> {
|
export async function whitelistAdd(name: string): Promise<string> {
|
||||||
assertValidUsername(name);
|
assertValidUsername(name);
|
||||||
return stripColors(await rcon().exec(`whitelist add ${name}`));
|
const entries = readWhitelistFile().filter((e) => e.name.toLowerCase() !== name.toLowerCase());
|
||||||
|
entries.push({ uuid: offlineUuid(name), name });
|
||||||
|
writeWhitelistFile(entries);
|
||||||
|
await reloadWhitelist();
|
||||||
|
return `Added ${name} to the whitelist (offline UUID)`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function whitelistRemove(name: string): Promise<string> {
|
export async function whitelistRemove(name: string): Promise<string> {
|
||||||
assertValidUsername(name);
|
assertValidUsername(name);
|
||||||
return stripColors(await rcon().exec(`whitelist remove ${name}`));
|
writeWhitelistFile(readWhitelistFile().filter((e) => e.name.toLowerCase() !== name.toLowerCase()));
|
||||||
|
await reloadWhitelist();
|
||||||
|
return `Removed ${name} from the whitelist`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function whitelistList(): Promise<string[]> {
|
export async function whitelistList(): Promise<string[]> {
|
||||||
|
const fromFile = readWhitelistFile()
|
||||||
|
.map((e) => e.name)
|
||||||
|
.filter((n) => NAME_RE.test(n));
|
||||||
|
if (fromFile.length > 0) return fromFile;
|
||||||
|
// fallback: ask the server (e.g. data dir not mounted in dev)
|
||||||
|
try {
|
||||||
const out = stripColors(await rcon().exec("whitelist list"));
|
const out = stripColors(await rcon().exec("whitelist list"));
|
||||||
// "There are 3 whitelisted player(s): Alice, Bob, Carol"
|
|
||||||
const colon = out.indexOf(":");
|
const colon = out.indexOf(":");
|
||||||
if (colon < 0) return [];
|
if (colon < 0) return [];
|
||||||
return out
|
return out
|
||||||
@@ -63,6 +120,9 @@ export async function whitelistList(): Promise<string[]> {
|
|||||||
.split(",")
|
.split(",")
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter((s) => NAME_RE.test(s));
|
.filter((s) => NAME_RE.test(s));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── EasyAuth ────────────────────────────────────────────────────────────
|
// ── EasyAuth ────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -20,6 +20,11 @@ Work down this list:
|
|||||||
1. **"Not whitelisted"** → their username isn't approved (or the window expired and
|
1. **"Not whitelisted"** → their username isn't approved (or the window expired and
|
||||||
auto-revoked them). Access & Auth → approve/re-open. Username must match **exactly**
|
auto-revoked them). Access & Auth → approve/re-open. Username must match **exactly**
|
||||||
(case matters in TLauncher).
|
(case matters in TLauncher).
|
||||||
|
Note: always whitelist through the panel, not with `whitelist add` in the console —
|
||||||
|
the vanilla command looks names up at Mojang and can store a *premium* UUID that
|
||||||
|
will never match an offline player. The panel writes the correct offline UUID
|
||||||
|
directly. If someone was added via the console and can't join, revoke and
|
||||||
|
re-approve them from the panel.
|
||||||
2. **Kicked with a "secure profile" / "invalid signature" style error** →
|
2. **Kicked with a "secure profile" / "invalid signature" style error** →
|
||||||
`enforce-secure-profile` must be `false` for offline launchers. The compose file
|
`enforce-secure-profile` must be `false` for offline launchers. The compose file
|
||||||
sets `ENFORCE_SECURE_PROFILE: "FALSE"`; if you edited `server.properties` by hand,
|
sets `ENFORCE_SECURE_PROFILE: "FALSE"`; if you edited `server.properties` by hand,
|
||||||
|
|||||||
Reference in new issue
Block a user