import { stdin as input, stdout as output } from "node:process"; import { randomUUID } from "node:crypto"; import { StringDecoder } from "node:string_decoder"; import { openDatabase, openDatabaseReadOnly } from "../db/index.js"; import { acquireInstanceLock, loadConfig, prepareDataDirectories } from "../config.js"; import { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword, verifyPassword } from "../security.js"; import { writeAudit } from "../audit.js"; function arg(name: string): string | undefined { const index = process.argv.indexOf(name); return index >= 0 ? process.argv[index + 1] : undefined; } // A terminal paste can contain more than one line. Keep the unread tail for // the next prompt instead of silently discarding credentials after the first // newline. let pendingInput = ""; let pendingSkipLf = false; async function readSecret(prompt: string): Promise { if (!input.isTTY) throw new Error("admin:init 需要交互式 TTY,不能通过管道传入密码"); output.write(prompt); return await new Promise((resolve, reject) => { let value = ""; let escapeSequence = false; let cleaned = false; const decoder = new StringDecoder("utf8"); const wasRaw = Boolean(input.isRaw); const initialInput = pendingInput; pendingInput = ""; let onData: (chunk: Buffer | string) => void; let onSignal: () => void; const cleanup = () => { if (cleaned) return; cleaned = true; input.off("data", onData); input.off("error", onInputError); process.off("SIGINT", onSignal); process.off("SIGTERM", onSignal); input.setRawMode?.(wasRaw); input.pause(); }; const finish = (error?: Error) => { cleanup(); if (error) reject(error); else { output.write("\n"); resolve(value); } }; const onInputError = (error: Error) => finish(error); onSignal = () => finish(new Error("已取消")); const consume = (text: string) => { let offset = 0; for (const character of text) { offset += character.length; if (pendingSkipLf) { if (character === "\n") { pendingSkipLf = false; continue; } pendingSkipLf = false; } if (character === "\u0003") { finish(new Error("已取消")); return; } if (escapeSequence) { if (/[A-Za-z~]/.test(character)) escapeSequence = false; continue; } if (character === "\u001b") { escapeSequence = true; } else if (character === "\r" || character === "\n") { const tail = text.slice(offset); pendingInput = tail.startsWith("\n") && character === "\r" ? tail.slice(1) : tail; pendingSkipLf = character === "\r" && !tail.startsWith("\n"); finish(); return; } else if (character === "\u007f" || character === "\b") { value = value.slice(0, -1); // Keep the credential visible in the SSH terminal as requested. // Redraw the current line so backspace behaves predictably without // putting the value into logs or command arguments. output.write("\r\u001b[2K" + prompt + value); } else { value += character; output.write(character); } } }; onData = (chunk) => { consume(typeof chunk === "string" ? chunk : decoder.write(chunk)); }; input.resume(); input.setRawMode?.(true); process.once("SIGINT", onSignal); process.once("SIGTERM", onSignal); input.once("error", onInputError); input.on("data", onData); if (initialInput) consume(initialInput); }); } async function main() { const config = loadConfig(); const checkOnly = process.argv.includes("--check"); if (checkOnly) { let database; try { database = openDatabaseReadOnly(config); } catch (error) { if (error && typeof error === "object" && "code" in error && (error as NodeJS.ErrnoException).code === "ENOENT") { console.log("empty"); return; } throw error; } try { const hasAdminsTable = database.sqlite .prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'admins'") .get(); const existing = hasAdminsTable ? database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number } : { count: 0 }; console.log(existing.count > 0 ? "initialized" : "empty"); } finally { database.sqlite.close(); } return; } prepareDataDirectories(config); const release = acquireInstanceLock(config); const database = openDatabase(config); try { const markPasswordConfigured = process.argv.includes("--mark-password-configured"); if (markPasswordConfigured) { const username = arg("--username") ?? (await readSecret("用户名: ")); const password = await readSecret("当前密码: "); const normalized = normalizeUsername(username); const admin = database.sqlite.prepare( "SELECT id, password_hash, must_change_password, version FROM admins WHERE username_norm = ?", ).get(normalized) as { id: string; password_hash: string; must_change_password: number; version: number } | undefined; if (!admin || !(await verifyPassword(admin.password_hash, password))) { throw new Error("用户名或当前密码不正确"); } if (!admin.must_change_password) { console.log("该管理员已经可以直接使用当前密码登录。"); return; } const now = Date.now(); database.sqlite.transaction(() => { const result = database.sqlite.prepare( "UPDATE admins SET must_change_password=0, auth_version=auth_version+1, version=version+1 WHERE id=? AND version=?", ).run(admin.id, admin.version); if (result.changes !== 1) throw new Error("管理员资料已被其他操作更新,请重试"); writeAudit(database.sqlite, { requestId: `cli:${randomUUID()}`, actorUsername: "cli", action: "admin.password_policy_cleared", targetType: "admin", targetId: admin.id, after: { username: normalized, mustChangePassword: false, changedAt: now }, }); })(); console.log("已确认当前密码为正式密码,后续登录不再要求修改密码。"); return; } const existing = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number }; if (existing.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化"); const username = arg("--username") ?? (await readSecret("用户名: ")); const displayName = arg("--display-name") ?? (await readSecret("显示名称: ")); const generate = process.argv.includes("--generate"); let password = generate ? temporaryPassword() : await readSecret("密码(至少 12 个字符): "); if (!generate) { const confirmation = await readSecret("再次输入密码: "); if (password !== confirmation) throw new Error("两次密码输入不一致"); } const policyError = validateNewPassword(password); if (policyError) throw new Error(policyError); const normalized = normalizeUsername(username); if ([...normalized].length < 3) throw new Error("用户名至少需要 3 个字符"); if ([...normalized].length > 64) throw new Error("用户名最多 64 个字符"); const normalizedDisplayName = displayName.normalize("NFKC").trim(); if ([...normalizedDisplayName].length < 1 || [...normalizedDisplayName].length > 80) throw new Error("显示名称必须为 1-80 个字符"); const passwordHash = await hashPassword(password); const id = randomUUID(); const now = Date.now(); database.sqlite.transaction(() => { const current = database.sqlite.prepare("SELECT COUNT(*) AS count FROM admins").get() as { count: number }; if (current.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化"); database.sqlite.prepare(` INSERT INTO admins(id, username, username_norm, display_name, password_hash, status, must_change_password, auth_version, version, created_at) VALUES (?, ?, ?, ?, ?, 'active', ?, 1, 1, ?) `).run( id, username.normalize("NFKC").trim(), normalized, normalizedDisplayName, passwordHash, generate ? 1 : 0, now, ); writeAudit(database.sqlite, { requestId: `cli:${randomUUID()}`, actorUsername: "cli", action: "admin.initialized", targetType: "admin", targetId: id, after: { username: normalized, displayName: normalizedDisplayName, status: "active" }, }); })(); console.log(generate ? `已创建首位管理员。一次性密码:${password}` : "已创建首位管理员。"); } finally { database.sqlite.close(); release(); } } main().catch((error) => { console.error(error instanceof Error ? error.message : error); process.exitCode = 1; });