TallyNote release / linux-x64 (push) Successful in 6m11s
- 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
225 lines
9.0 KiB
TypeScript
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;
|
|
});
|