Files
TallyNote/server/cli/admin-init.ts
T
Qiufeng ab2d24a5c7
TallyNote release / linux-x64 (push) Successful in 6m11s
fix: keep manually set admin password, echo SSH input
- manual admin password no longer forces first-login change
- --generate still requires password change on first login
- add --mark-password-configured to repair legacy flag
- echo interactive username/password input in SSH terminal
- installer prints absolute admin-init path (sudo secure_path compat)
- use python3 pty helper for CI tests (no expect on Linux)
release: 1.2.9
2026-09-10 18:04:06 +08:00

225 lines
9.0 KiB
TypeScript

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<string> {
if (!input.isTTY) throw new Error("admin:init 需要交互式 TTY,不能通过管道传入密码");
output.write(prompt);
return await new Promise<string>((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;
});