fix: keep manually set admin password and echo SSH input
TallyNote release / linux-x64 (push) Failing after 3m0s

- 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 with current password
- echo interactive username/password input in SSH terminal
- installer prints absolute admin-init path (sudo secure_path compat)
release: 1.2.9
This commit is contained in:
Qiufeng
2026-09-10 17:01:42 +08:00
parent a070ad0434
commit 19a3b5157c
5 changed files with 147 additions and 13 deletions
+8 -2
View File
@@ -42,7 +42,7 @@ pnpm build:next
`build:next` 与 `pnpm build` 一样输出到 `dist/web`,可直接由生产 Fastify 服务提供。 `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`。 默认地址为 `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 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 状态和最近日志并回滚本次切换。 监听方式有两个选项:`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 状态和最近日志并回滚本次切换。
+9 -7
View File
@@ -230,26 +230,26 @@ run_initial_admin_wizard() {
return 0 return 0
fi fi
if (( NON_INTERACTIVE )); then if (( NON_INTERACTIVE )); then
log '非交互模式:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init' log "非交互模式:跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
return 0 return 0
fi fi
[[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || { [[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || {
log '未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init' log "未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
return 0 return 0
} }
[[ -x "$ADMIN_INIT_PATH" ]] || die '管理员初始化命令未安装' [[ -x "$ADMIN_INIT_PATH" ]] || die '管理员初始化命令未安装'
local status choice local status choice
if ! status=$("$ADMIN_INIT_PATH" --check 2>/dev/null); then if ! status=$("$ADMIN_INIT_PATH" --check 2>/dev/null); then
log '无法检查管理员初始化状态;基础安装已完成,稍后可执行 sudo tallynote-admin-init' log "无法检查管理员初始化状态;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH"
return 0 return 0
fi fi
[[ "$status" == empty ]] || return 0 [[ "$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 '\n首次安装还差一步:请创建管理员账号。\n'
printf '管理员账号用于登录 TallyNote,首次登录后需要设置正式密码。\n' printf '管理员账号用于登录 TallyNote;这里输入的密码会直接作为正式密码。\n'
} > "$PROMPT_OUTPUT" } > "$PROMPT_OUTPUT"
while :; do while :; do
prompt_value '现在创建管理员?输入 yes 继续,其他内容稍后创建' 'yes' prompt_value '现在创建管理员?输入 yes 继续,其他内容稍后创建' 'yes'
@@ -258,7 +258,7 @@ run_initial_admin_wizard() {
yes|YES|Yes|y|Y) break ;; yes|YES|Yes|y|Y) break ;;
no|NO|No|n|N|'') no|NO|No|n|N|'')
exec 9<&- exec 9<&-
log '已跳过管理员初始化;稍后可执行 sudo tallynote-admin-init' log "已跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
return 0 return 0
;; ;;
*) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;; *) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;;
@@ -267,7 +267,7 @@ run_initial_admin_wizard() {
stage '创建首位管理员(密码不会写入安装日志)' stage '创建首位管理员(密码不会写入安装日志)'
if ! "$ADMIN_INIT_PATH" <&9 > "$PROMPT_OUTPUT"; then if ! "$ADMIN_INIT_PATH" <&9 > "$PROMPT_OUTPUT"; then
exec 9<&- exec 9<&-
log '管理员初始化未完成;基础安装已完成,稍后可执行 sudo tallynote-admin-init' log "管理员初始化未完成;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH"
return 0 return 0
fi fi
exec 9<&- exec 9<&-
@@ -1538,5 +1538,7 @@ main() {
fi fi
log "访问地址:$access_url" log "访问地址:$access_url"
log '查看服务状态:systemctl status tallynote.service' log '查看服务状态:systemctl status tallynote.service'
log "管理员初始化命令:sudo $ADMIN_INIT_PATH"
log '如 sudo 找不到该命令,请使用上面输出的绝对路径'
} }
main "$@" main "$@"
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "tallynote", "name": "tallynote",
"version": "1.2.8", "version": "1.2.9",
"private": true, "private": true,
"type": "module", "type": "module",
"packageManager": "pnpm@9.0.6", "packageManager": "pnpm@9.0.6",
+49 -3
View File
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { StringDecoder } from "node:string_decoder"; import { StringDecoder } from "node:string_decoder";
import { openDatabase, openDatabaseReadOnly } from "../db/index.js"; import { openDatabase, openDatabaseReadOnly } from "../db/index.js";
import { acquireInstanceLock, loadConfig, prepareDataDirectories } from "../config.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"; import { writeAudit } from "../audit.js";
function arg(name: string): string | undefined { function arg(name: string): string | undefined {
@@ -79,8 +79,13 @@ async function readSecret(prompt: string): Promise<string> {
return; return;
} else if (character === "\u007f" || character === "\b") { } else if (character === "\u007f" || character === "\b") {
value = value.slice(0, -1); 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 { } else {
value += character; value += character;
output.write(character);
} }
} }
}; };
@@ -128,6 +133,39 @@ async function main() {
const release = acquireInstanceLock(config); const release = acquireInstanceLock(config);
const database = openDatabase(config); const database = openDatabase(config);
try { 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 }; 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:管理员已经初始化"); if (existing.count > 0) throw new Error("INITIAL_ADMIN_EXISTS:管理员已经初始化");
const username = arg("--username") ?? (await readSecret("用户名: ")); const username = arg("--username") ?? (await readSecret("用户名: "));
@@ -154,8 +192,16 @@ async function main() {
database.sqlite.prepare(` database.sqlite.prepare(`
INSERT INTO admins(id, username, username_norm, display_name, password_hash, status, INSERT INTO admins(id, username, username_norm, display_name, password_hash, status,
must_change_password, auth_version, version, created_at) must_change_password, auth_version, version, created_at)
VALUES (?, ?, ?, ?, ?, 'active', 1, 1, 1, ?) VALUES (?, ?, ?, ?, ?, 'active', ?, 1, 1, ?)
`).run(id, username.normalize("NFKC").trim(), normalized, normalizedDisplayName, passwordHash, now); `).run(
id,
username.normalize("NFKC").trim(),
normalized,
normalizedDisplayName,
passwordHash,
generate ? 1 : 0,
now,
);
writeAudit(database.sqlite, { writeAudit(database.sqlite, {
requestId: `cli:${randomUUID()}`, requestId: `cli:${randomUUID()}`,
actorUsername: "cli", actorUsername: "cli",
+80
View File
@@ -85,6 +85,86 @@ describe("生产管理员初始化 CLI", () => {
} }
}, 15_000); }, 15_000);
it("交互式输入正式密码后不会强制首次改密", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
try {
const expectScript = [
"set timeout 15",
`spawn ${process.execPath} ${tsx} ${cli}`,
'expect "用户名: "',
'send "manual-admin\\r"',
'expect "显示名称: "',
'send "手动管理员\\r"',
'expect "密码(至少 12 个字符): "',
'send "Strong-password-2026!\\r"',
'expect "再次输入密码: "',
'send "Strong-password-2026!\\r"',
'expect eof',
].join("\n");
const result = spawnSync("expect", ["-c", expectScript], {
cwd: root,
env: {
...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",
},
encoding: "utf8",
});
expect(result.status).toBe(0);
expect(`${result.stdout}${result.stderr}`).toContain("已创建首位管理员");
expect(`${result.stdout}${result.stderr}`).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);
it("可以验证当前密码并清除旧版本遗留的首次改密标志", () => {
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 expectScript = [
"set timeout 15",
`spawn ${process.execPath} ${tsx} ${cli} --mark-password-configured --username legacy-admin`,
'expect "当前密码: "',
`send "${generated}\\r"`,
'expect eof',
].join("\n");
const result = spawnSync("expect", ["-c", expectScript], {
cwd: root,
env: {
...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",
},
encoding: "utf8",
});
expect(result.status).toBe(0);
expect(`${result.stdout}${result.stderr}`).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 时明确拒绝通过管道传入", () => { it("密码输入不是 TTY 时明确拒绝通过管道传入", () => {
const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-")); const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-"));
try { try {