From ab2d24a5c76250d7129c2f0fe5bcf433cd6245d6 Mon Sep 17 00:00:00 2001 From: Qiufeng Date: Thu, 10 Sep 2026 18:04:06 +0800 Subject: [PATCH] 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 --- README.md | 10 ++++- install.sh | 16 ++++---- package.json | 2 +- server/cli/admin-init.ts | 52 ++++++++++++++++++++++++-- tests/admin-init.test.ts | 81 +++++++++++++++++++++++++++++++++++++++- tests/helpers/pty-run.py | 66 ++++++++++++++++++++++++++++++++ 6 files changed, 213 insertions(+), 14 deletions(-) create mode 100755 tests/helpers/pty-run.py diff --git a/README.md b/README.md index 4934c1c..4a36694 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ pnpm build:next `build:next` 与 `pnpm build` 一样输出到 `dist/web`,可直接由生产 Fastify 服务提供。 -本地开发首次初始化管理员使用 `pnpm admin:init`。生产安装器会在首次安装时提供管理员初始化向导;如果选择稍后创建,执行 `sudo tallynote-admin-init` 即可。也可以使用 `sudo tallynote-admin-init --username admin --display-name 管理员 --generate` 生成一次性临时密码。 +本地开发首次初始化管理员使用 `pnpm admin:init`。生产安装器会在首次安装时提供管理员初始化向导;如果选择稍后创建,执行 `sudo /usr/local/sbin/tallynote-admin-init` 即可。也可以使用 `sudo /usr/local/sbin/tallynote-admin-init --username admin --display-name 管理员 --generate` 生成一次性临时密码。 默认地址为 `http://127.0.0.1:3000`,开发界面为 `http://127.0.0.1:5173`。配置项见 `.env.example`。 @@ -62,7 +62,13 @@ curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/ra curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash ``` -首次安装完成网络配置后,向导会询问是否立即创建管理员。选择创建时,用户名、显示名称和密码都在当前 SSH 终端中输入;选择稍后创建也不会阻塞服务启动,之后执行 `sudo tallynote-admin-init` 即可。升级已有安装时,向导会自动识别现有管理员并跳过创建,不会覆盖账号或账目。 +首次安装完成网络配置后,向导会询问是否立即创建管理员。选择创建时,用户名、显示名称和密码都在当前 SSH 终端中输入,并会直接回显当前输入内容;密码不会写入安装日志、配置文件或命令行参数。选择稍后创建也不会阻塞服务启动,之后执行 `sudo /usr/local/sbin/tallynote-admin-init` 即可。升级已有安装时,向导会自动识别现有管理员并跳过创建,不会覆盖账号或账目。 + +如果账号是在旧版本中用正式密码创建、但仍被标记为“首次登录需要修改密码”,可以在服务器上用当前密码修复标志位(不会更换密码): + +```bash +sudo /usr/local/sbin/tallynote-admin-init --mark-password-configured --username <用户名> +``` 监听方式有两个选项:`127.0.0.1` 仅本机访问(默认、更安全),或 `0.0.0.0` 允许通过局域网/公网 IP 访问。安装时可输入自定义端口(直接回车使用默认端口),安装器会检查 TCP 端口是否已被占用;选择 `0.0.0.0` 时会尝试通过 HTTPS 自动获取公网 IPv4,并将 `http://公网IP:端口` 作为默认访问地址,也可以改填域名。不能填写 `http://0.0.0.0:3000`。直连 HTTP 未加密,安装器会要求明确确认,只适合受控网络。绑定域名后应改为 HTTPS 反向代理,设置真实的 `TALLYNOTE_PUBLIC_ORIGIN`、`TALLYNOTE_COOKIE_SECURE=true`、`TALLYNOTE_ALLOW_INSECURE_HTTP=false`,然后执行 `sudo systemctl restart tallynote.service`。服务启动后,安装器会先请求本机 `/health`;只有健康检查通过才会报告安装完成并输出最终访问链接。监听 `127.0.0.1` 时该链接只对服务器本机有效;需要公网或其他设备访问时请选择 `0.0.0.0`。健康检查失败时会输出 systemd 状态和最近日志并回滚本次切换。 diff --git a/install.sh b/install.sh index ed0e27c..b5d9ea5 100755 --- a/install.sh +++ b/install.sh @@ -230,26 +230,26 @@ run_initial_admin_wizard() { return 0 fi if (( NON_INTERACTIVE )); then - log '非交互模式:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init' + log "非交互模式:跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH" return 0 fi [[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || { - log '未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init' + log "未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH" return 0 } [[ -x "$ADMIN_INIT_PATH" ]] || die '管理员初始化命令未安装' local status choice if ! status=$("$ADMIN_INIT_PATH" --check 2>/dev/null); then - log '无法检查管理员初始化状态;基础安装已完成,稍后可执行 sudo tallynote-admin-init' + log "无法检查管理员初始化状态;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH" return 0 fi [[ "$status" == empty ]] || return 0 - exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请稍后执行 sudo tallynote-admin-init' + exec 9<"$PROMPT_INPUT" || die "无法打开终端输入;请稍后执行 sudo $ADMIN_INIT_PATH" { printf '\n首次安装还差一步:请创建管理员账号。\n' - printf '管理员账号用于登录 TallyNote,首次登录后需要设置正式密码。\n' + printf '管理员账号用于登录 TallyNote;这里输入的密码会直接作为正式密码。\n' } > "$PROMPT_OUTPUT" while :; do prompt_value '现在创建管理员?输入 yes 继续,其他内容稍后创建' 'yes' @@ -258,7 +258,7 @@ run_initial_admin_wizard() { yes|YES|Yes|y|Y) break ;; no|NO|No|n|N|'') exec 9<&- - log '已跳过管理员初始化;稍后可执行 sudo tallynote-admin-init' + log "已跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH" return 0 ;; *) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;; @@ -267,7 +267,7 @@ run_initial_admin_wizard() { stage '创建首位管理员(密码不会写入安装日志)' if ! "$ADMIN_INIT_PATH" <&9 > "$PROMPT_OUTPUT"; then exec 9<&- - log '管理员初始化未完成;基础安装已完成,稍后可执行 sudo tallynote-admin-init' + log "管理员初始化未完成;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH" return 0 fi exec 9<&- @@ -1538,5 +1538,7 @@ main() { fi log "访问地址:$access_url" log '查看服务状态:systemctl status tallynote.service' + log "管理员初始化命令:sudo $ADMIN_INIT_PATH" + log '如 sudo 找不到该命令,请使用上面输出的绝对路径' } main "$@" diff --git a/package.json b/package.json index 7501b2b..050b1cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.2.8", + "version": "1.2.9", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/server/cli/admin-init.ts b/server/cli/admin-init.ts index 43a6f4d..d3a005e 100644 --- a/server/cli/admin-init.ts +++ b/server/cli/admin-init.ts @@ -3,7 +3,7 @@ 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 } from "../security.js"; +import { hashPassword, normalizeUsername, validateNewPassword, temporaryPassword, verifyPassword } from "../security.js"; import { writeAudit } from "../audit.js"; function arg(name: string): string | undefined { @@ -79,8 +79,13 @@ async function readSecret(prompt: string): Promise { 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); } } }; @@ -128,6 +133,39 @@ async function main() { 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("用户名: ")); @@ -154,8 +192,16 @@ async function main() { 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, 1, ?) - `).run(id, username.normalize("NFKC").trim(), normalized, normalizedDisplayName, passwordHash, now); + 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", diff --git a/tests/admin-init.test.ts b/tests/admin-init.test.ts index fdaa7d5..28ca599 100644 --- a/tests/admin-init.test.ts +++ b/tests/admin-init.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; @@ -8,6 +8,9 @@ import Database from "better-sqlite3"; const root = path.resolve(process.cwd()); const cli = path.join(root, "server", "cli", "admin-init.ts"); const tsx = path.join(root, "node_modules", "tsx", "dist", "cli.mjs"); +const ptyHelper = path.join(root, "tests", "helpers", "pty-run.py"); +const hasPython3 = spawnSync("python3", ["--version"]).status === 0; +const ttyTest = hasPython3 ? it : it.skip; function runAdmin(dataDir: string, args: string[]) { return spawnSync(process.execPath, [tsx, cli, ...args], { @@ -24,6 +27,42 @@ function runAdmin(dataDir: string, args: string[]) { }); } +function testEnv(dataDir: string) { + return { + ...process.env, + NODE_ENV: "test", + TALLYNOTE_DATA_DIR: dataDir, + TALLYNOTE_PUBLIC_ORIGIN: "http://127.0.0.1:3999", + TALLYNOTE_COOKIE_SECURE: "false", + TALLYNOTE_UPDATE_STRATEGY: "disabled", + }; +} + +// The CI runner has no `expect` binary. Drive the interactive CLI through a +// real pseudo-terminal via a tiny Python pty helper (python3 ships on both +// macOS and the Linux CI image). This avoids `expect` (not installed on CI) +// and BSD `script` (injects a stray EOT byte from file input, corrupting the +// first prompt value). If python3 is unavailable the tests are skipped rather +// than failing the build. +function runAdminTTY(dataDir: string, args: string[], inputText: string) { + const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-tty-")); + const inputFile = path.join(parent, "input"); + const exitFile = path.join(parent, "exit-code"); + writeFileSync(inputFile, inputText); + try { + const result = spawnSync("python3", [ptyHelper, process.execPath, tsx, cli, ...args], { + cwd: root, + env: { ...testEnv(dataDir), PTY_STDIN_FILE: inputFile, PTY_EXIT_FILE: exitFile }, + encoding: "utf8", + timeout: 30_000, + }); + const exitCode = existsSync(exitFile) ? Number(readFileSync(exitFile, "utf8")) : null; + return { exitCode, output: `${result.stdout}${result.stderr}`, spawnError: result.error }; + } finally { + rmSync(parent, { recursive: true, force: true }); + } +} + describe("生产管理员初始化 CLI", () => { it("--check 是只读的,空数据目录不会被创建", () => { const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-")); @@ -85,6 +124,46 @@ describe("生产管理员初始化 CLI", () => { } }, 15_000); + ttyTest("交互式输入正式密码后不会强制首次改密", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-")); + try { + const result = runAdminTTY(dataDir, [], "manual-admin\n手动管理员\nStrong-password-2026!\nStrong-password-2026!\n"); + expect(result.spawnError).toBeUndefined(); + expect(result.exitCode).toBe(0); + expect(result.output).toContain("已创建首位管理员"); + expect(result.output).toContain("Strong-password-2026!"); + + const database = new Database(path.join(dataDir, "tallynote.db")); + const admin = database.prepare("SELECT username, must_change_password FROM admins").get() as { username: string; must_change_password: number }; + expect(admin).toEqual({ username: "manual-admin", must_change_password: 0 }); + database.close(); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }, 30_000); + + ttyTest("可以验证当前密码并清除旧版本遗留的首次改密标志", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-")); + try { + const first = runAdmin(dataDir, ["--username", "legacy-admin", "--display-name", "旧版管理员", "--generate"]); + expect(first.status).toBe(0); + const generated = first.stdout.match(/一次性密码:([^\s]+)/)?.[1]; + expect(generated).toBeTruthy(); + + const result = runAdminTTY(dataDir, ["--mark-password-configured", "--username", "legacy-admin"], `${generated}\n`); + expect(result.spawnError).toBeUndefined(); + expect(result.exitCode).toBe(0); + expect(result.output).toContain("已确认当前密码为正式密码"); + + const database = new Database(path.join(dataDir, "tallynote.db")); + const admin = database.prepare("SELECT must_change_password FROM admins WHERE username_norm='legacy-admin'").get() as { must_change_password: number }; + expect(admin.must_change_password).toBe(0); + database.close(); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }, 30_000); + it("密码输入不是 TTY 时明确拒绝通过管道传入", () => { const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-")); try { diff --git a/tests/helpers/pty-run.py b/tests/helpers/pty-run.py new file mode 100755 index 0000000..7075de9 --- /dev/null +++ b/tests/helpers/pty-run.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +"""Minimal cross-platform pty driver for the admin-init CLI tests. + +Forks a child on a real pseudo-terminal so the CLI sees a TTY and runs its +raw-mode password prompts. Forwards a prepared input file to the child's stdin +and copies child output to stdout. Writes the child's exit code to a file so +the Node test can read it deterministically. + +Used instead of `expect` (not installed on CI) or BSD `script` (injects a stray +EOT byte when stdin is a regular file, corrupting the first prompt value). +""" +import os +import pty +import select +import sys + +argv = sys.argv[1:] +exit_file = os.environ.get("PTY_EXIT_FILE", "") +stdin_file = os.environ.get("PTY_STDIN_FILE", "") + +pid, master = pty.fork() +if pid == 0: + # Child: replace with the target command. argv[0] is an absolute node path. + os.execvp(argv[0], argv) + os._exit(127) + +in_fd = os.open(stdin_file, os.O_RDONLY) if stdin_file else -1 +open_stdin = in_fd >= 0 +try: + while True: + fds = [master] + if open_stdin: + fds.append(in_fd) + try: + readable, _, _ = select.select(fds, [], [], 30.0) + except (OSError, ValueError): + break + if not readable: + break + if master in readable: + try: + data = os.read(master, 4096) + except OSError: + break + if not data: + break + os.write(1, data) + if open_stdin and in_fd in readable: + data = os.read(in_fd, 4096) + if data: + os.write(master, data) + else: + open_stdin = False + os.close(in_fd) +finally: + try: + _, status = os.waitpid(pid, 0) + except ChildProcessError: + status = 0 + code = os.waitstatus_to_exitcode(status) if hasattr(os, "waitstatus_to_exitcode") else (status >> 8) + if exit_file: + try: + with open(exit_file, "w") as handle: + handle.write(str(code)) + except OSError: + pass