diff --git a/README.md b/README.md index 2a70425..6e6d8b5 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,6 @@ TallyNote_报销资料_xxxxxxxx.zip ```bash pnpm install -pnpm admin:init pnpm dev ``` @@ -43,7 +42,7 @@ pnpm build:next `build:next` 与 `pnpm build` 一样输出到 `dist/web`,可直接由生产 Fastify 服务提供。 -首次初始化会要求交互式输入管理员密码。也可以使用 `pnpm admin:init -- --username admin --display-name 管理员 --generate` 生成一次性临时密码。 +本地开发首次初始化管理员使用 `pnpm admin:init`。生产安装器会在首次安装时提供管理员初始化向导;如果选择稍后创建,执行 `sudo tallynote-admin-init` 即可。也可以使用 `sudo tallynote-admin-init --username admin --display-name 管理员 --generate` 生成一次性临时密码。 默认地址为 `http://127.0.0.1:3000`,开发界面为 `http://127.0.0.1:5173`。配置项见 `.env.example`。 @@ -51,7 +50,7 @@ pnpm build:next 安装器正式支持 **Linux x86_64(x64)**,脚本和运行时也支持在对应原生 runner 上发布 **aarch64(arm64)**;当前仓库内置 workflow 只生成 x64,arm64 需要在原生 ARM64 runner 上单独构建并发布。ARMv7/ARM32 仅实验性支持;Linux x86 32 位(`i386`、`i686`、`ia32`)明确不支持,因为 Node.js 24 和项目原生依赖没有可维护的官方构建。不要在 32 位系统上强行安装。 -发布包必须包含 `dist/`、生产依赖、匹配架构的 Node runtime、systemd 单元、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本: +发布包必须包含 `dist/`(包括 `dist/server/cli/admin-init.js`)、生产依赖、匹配架构的 Node runtime、systemd 单元、`bin/tallynote-admin-init`、`uninstall.sh`,以及 `SHA256SUMS`。签名文件 `SHA256SUMS.sig` 是可选增强校验,不需要为普通安装准备公钥。安装器默认直接安装最新版本: ```bash curl --proto '=https' --tlsv1.2 -fsSL https://git.awaioi.com/awaioi/TallyNote/raw/branch/main/install.sh | sudo bash @@ -63,6 +62,8 @@ 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` 即可。升级已有安装时,向导会自动识别现有管理员并跳过创建,不会覆盖账号或账目。 + 监听方式有两个选项:`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 状态和最近日志并回滚本次切换。 安装器不会在已有安装的升级过程中反复询问网络配置,并会保留现有环境文件。自动化或无终端环境可使用 `--non-interactive`(默认安全配置 `127.0.0.1:3000`),也可以显式传入 `TALLYNOTE_HOST`、`TALLYNOTE_PORT`、`TALLYNOTE_PUBLIC_ORIGIN` 和 `TALLYNOTE_ALLOW_INSECURE_HTTP` 覆盖配置。 @@ -119,7 +120,7 @@ tallynote installer: 查看服务状态:systemctl status tallynote.service ### 卸载 -安装完成后会提供 `/usr/local/sbin/tallynote-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装: +安装完成后会提供 `/usr/local/sbin/tallynote-admin-init` 和 `/usr/local/sbin/tallynote-uninstall`。普通卸载会停止并禁用 TallyNote 的 systemd 单元,删除当前版本、更新辅助程序、管理员初始化命令和已知配置,但保留 `/var/lib/tallynote` 以及更新备份,方便以后重新安装: ```bash sudo /usr/local/sbin/tallynote-uninstall diff --git a/bin/tallynote-admin-init b/bin/tallynote-admin-init new file mode 100755 index 0000000..27d24f7 --- /dev/null +++ b/bin/tallynote-admin-init @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +# Production entry point for first-admin setup. The installer keeps the +# EnvironmentFile root-readable only, so parse simple KEY=VALUE assignments +# without sourcing arbitrary shell code. +PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin +export PATH +umask 077 + +CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote} +CONFIG_FILE="$CONFIG_DIR/tallynote.env" +PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote} +SERVICE_NAME=${TALLYNOTE_SERVICE_NAME:-tallynote.service} + +die() { printf 'tallynote admin-init: %s\n' "$*" >&2; exit 1; } + +load_environment_file() { + [[ -e "$CONFIG_FILE" ]] || return 0 + [[ -f "$CONFIG_FILE" && ! -L "$CONFIG_FILE" ]] || die '环境文件不是安全的普通文件' + local uid mode_bits line key value + uid=$(stat -c '%u' "$CONFIG_FILE" 2>/dev/null || stat -f '%u' "$CONFIG_FILE") + [[ "$uid" == 0 ]] || die '环境文件必须由 root 拥有' + mode_bits=$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || stat -f '%Lp' "$CONFIG_FILE") + [[ "$mode_bits" =~ ^[0-7]+$ ]] || die '无法读取环境文件权限' + (( (8#$mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入' + + while IFS= read -r line || [[ -n "$line" ]]; do + [[ -z "$line" || "$line" == \#* ]] && continue + [[ "$line" =~ ^([A-Z][A-Z0-9_]*)=(.*)$ ]] || die '环境文件包含无法识别的配置行' + key=${BASH_REMATCH[1]} + value=${BASH_REMATCH[2]} + [[ "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "环境文件中的 $key 包含控制字符" + export "$key=$value" + done < "$CONFIG_FILE" + PREFIX=${TALLYNOTE_INSTALL_PREFIX:-$PREFIX} +} + +resolve_release_root() { + local root prefix_root + [[ "$PREFIX" = /* && "$PREFIX" != *$'\n'* && "$PREFIX" != *$'\r'* ]] || die '安装目录无效' + [[ -L "$PREFIX/current" ]] || die '当前 release 链接不存在' + prefix_root=$(readlink -f -- "$PREFIX" 2>/dev/null || realpath "$PREFIX" 2>/dev/null || true) + [[ -n "$prefix_root" && -d "$prefix_root" && ! -L "$prefix_root" ]] || die '安装目录不安全' + root=$(readlink -f -- "$PREFIX/current" 2>/dev/null || realpath "$PREFIX/current" 2>/dev/null || true) + [[ -n "$root" && "$root" == "$prefix_root/releases/"* && -d "$root" && ! -L "$root" ]] || die '当前 release 链接不安全' + printf '%s' "$root" +} + +run_as_service_user() { + local root=$1 node=$2 cli=$3 + if [[ "${EUID:-$(id -u)}" == 0 ]]; then + local service_uid + service_uid=$(id -u tallynote 2>/dev/null) || die '找不到 tallynote 服务用户,拒绝以 root 身份执行管理员初始化' + [[ "$service_uid" =~ ^[1-9][0-9]*$ ]] || die 'tallynote 服务用户 UID 无效,拒绝以 root 身份执行管理员初始化' + command -v runuser >/dev/null 2>&1 || die '找不到 runuser,无法以 tallynote 用户初始化' + local -a environment=( + "NODE_ENV=production" + "TALLYNOTE_DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}" + "TALLYNOTE_INSTALL_PREFIX=${TALLYNOTE_INSTALL_PREFIX:-$PREFIX}" + "TALLYNOTE_TRUST_PROXY=${TALLYNOTE_TRUST_PROXY:-false}" + "TALLYNOTE_UPDATE_STRATEGY=${TALLYNOTE_UPDATE_STRATEGY:-systemd}" + "TALLYNOTE_HOST=${TALLYNOTE_HOST:-127.0.0.1}" + "TALLYNOTE_PORT=${TALLYNOTE_PORT:-3000}" + "TALLYNOTE_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN:-http://127.0.0.1:3000}" + "TALLYNOTE_COOKIE_SECURE=${TALLYNOTE_COOKIE_SECURE:-false}" + "TALLYNOTE_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP:-false}" + "TALLYNOTE_TIMEZONE=${TALLYNOTE_TIMEZONE:-Asia/Shanghai}" + "TALLYNOTE_UPDATE_METADATA_URL=${TALLYNOTE_UPDATE_METADATA_URL:-https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest}" + "TALLYNOTE_UPDATE_ALLOWED_HOSTS=${TALLYNOTE_UPDATE_ALLOWED_HOSTS:-git.awaioi.com}" + "TALLYNOTE_UPDATE_REQUIRE_SIGNATURE=${TALLYNOTE_UPDATE_REQUIRE_SIGNATURE:-false}" + "TALLYNOTE_UPDATE_MAX_MB=${TALLYNOTE_UPDATE_MAX_MB:-512}" + "TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS=${TALLYNOTE_UPDATE_CHECK_COOLDOWN_SECONDS:-60}" + "TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS=${TALLYNOTE_UPDATE_DOWNLOAD_COOLDOWN_SECONDS:-15}" + "TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS=${TALLYNOTE_UPDATE_APPLY_COOLDOWN_SECONDS:-15}" + "TALLYNOTE_MAX_FILE_MB=${TALLYNOTE_MAX_FILE_MB:-20}" + "TALLYNOTE_MAX_FILES_PER_REQUEST=${TALLYNOTE_MAX_FILES_PER_REQUEST:-20}" + "TALLYNOTE_MAX_RECORD_MB=${TALLYNOTE_MAX_RECORD_MB:-100}" + "TALLYNOTE_MAX_TOTAL_MB=${TALLYNOTE_MAX_TOTAL_MB:-2048}" + "TALLYNOTE_MAX_CONCURRENT_EXPORTS=${TALLYNOTE_MAX_CONCURRENT_EXPORTS:-2}" + "TALLYNOTE_MAX_EXPORT_RECORDS=${TALLYNOTE_MAX_EXPORT_RECORDS:-5000}" + "TALLYNOTE_MAX_EXPORT_MB=${TALLYNOTE_MAX_EXPORT_MB:-1024}" + "TALLYNOTE_MAX_EXPORT_STORAGE_MB=${TALLYNOTE_MAX_EXPORT_STORAGE_MB:-2048}" + "TALLYNOTE_SESSION_IDLE_HOURS=${TALLYNOTE_SESSION_IDLE_HOURS:-24}" + "TALLYNOTE_SESSION_ABSOLUTE_HOURS=${TALLYNOTE_SESSION_ABSOLUTE_HOURS:-168}" + "TALLYNOTE_EXPORT_TTL_MINUTES=${TALLYNOTE_EXPORT_TTL_MINUTES:-15}" + ) + [[ -n "${TALLYNOTE_UPDATE_PUBLIC_KEY:-}" ]] && environment+=("TALLYNOTE_UPDATE_PUBLIC_KEY=$TALLYNOTE_UPDATE_PUBLIC_KEY") + if [[ -n "${TALLYNOTE_UPDATE_PUBLIC_KEY_FILE:-}" ]]; then + environment+=("TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$TALLYNOTE_UPDATE_PUBLIC_KEY_FILE") + fi + if [[ -n "${TALLYNOTE_UPDATE_HELPER_PATH:-}" ]]; then + environment+=("TALLYNOTE_UPDATE_HELPER_PATH=$TALLYNOTE_UPDATE_HELPER_PATH") + fi + runuser -u tallynote -- env -i "${environment[@]}" PATH="$PATH" "$node" "$cli" "${@:4}" + else + "$node" "$cli" "${@:4}" + fi +} + +main() { + load_environment_file + local root node cli systemctl service_was_active=0 result check_only=0 + for argument in "$@"; do + [[ "$argument" == "--check" ]] && check_only=1 + done + root=$(resolve_release_root) + node="$root/runtime/bin/node" + [[ -x "$node" ]] || node=$(command -v node || true) + [[ -n "$node" && -x "$node" ]] || die '找不到 Node.js runtime' + cli="$root/dist/server/cli/admin-init.js" + [[ -f "$cli" && ! -L "$cli" ]] || die '管理员初始化程序不存在' + + # Validate the privilege boundary before stopping an active service. A + # damaged installation must fail closed without causing avoidable downtime. + if [[ "${EUID:-$(id -u)}" == 0 ]]; then + local service_uid + service_uid=$(id -u tallynote 2>/dev/null) || die '找不到 tallynote 服务用户,拒绝以 root 身份执行管理员初始化' + [[ "$service_uid" =~ ^[1-9][0-9]*$ ]] || die 'tallynote 服务用户 UID 无效,拒绝以 root 身份执行管理员初始化' + command -v runuser >/dev/null 2>&1 || die '找不到 runuser,无法以 tallynote 用户初始化' + fi + + # admin-init uses the same instance lock as the web process. Pause an active + # service for the duration, then restore exactly its previous active state. + systemctl=$(command -v systemctl || true) + if (( ! check_only )) && [[ "${EUID:-$(id -u)}" == 0 && -n "$systemctl" && -x "$systemctl" ]] && "$systemctl" is-active --quiet "$SERVICE_NAME"; then + service_was_active=1 + printf 'tallynote admin-init: 暂停服务以完成管理员初始化\n' >&2 + "$systemctl" stop "$SERVICE_NAME" || die '无法暂停 TallyNote 服务' + fi + restore_service() { + local exit_code=$? + if (( service_was_active )); then + printf 'tallynote admin-init: 恢复 TallyNote 服务\n' >&2 + "$systemctl" start "$SERVICE_NAME" || printf 'tallynote admin-init: 警告:服务恢复失败,请执行 systemctl start %s\n' "$SERVICE_NAME" >&2 + fi + return "$exit_code" + } + trap restore_service EXIT + + cd -- "$root" + set +e + run_as_service_user "$root" "$node" "$cli" "$@" + result=$? + set -e + exit "$result" +} + +main "$@" diff --git a/docs/release.md b/docs/release.md index 57c7652..00f53fb 100644 --- a/docs/release.md +++ b/docs/release.md @@ -74,7 +74,7 @@ tallynote installer: 访问地址:http://127.0.0.1:<端口> 已有安装默认拒绝安装不高于当前版本的 release;只有在明确执行 `--allow-downgrade`(或设置 `TALLYNOTE_ALLOW_DOWNGRADE=true`)时才允许回退版本。 -安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`。`--allow-unsigned` 作为旧版本兼容参数保留。 +安装器拒绝预先存在的符号链接、非 root 拥有或对组/其他用户可写的安装、配置和备份目录。发布包同时携带 `uninstall.sh`,安装后会落到 `/usr/local/sbin/tallynote-uninstall`,并提供 `/usr/local/sbin/tallynote-admin-init` 作为生产环境首次管理员初始化入口。`--allow-unsigned` 作为旧版本兼容参数保留。 安装布局: diff --git a/install.sh b/install.sh index f5c8400..fda3422 100755 --- a/install.sh +++ b/install.sh @@ -57,6 +57,7 @@ INSTALL_PREVIOUS_TARGET='' INSTALL_NEW_RELEASE='' INSTALL_WORK_DIR='' INSTALL_BACKUP_DIR='' +INSTALL_BACKUP_COMPLETE=0 INSTALL_WAS_ACTIVE=0 INSTALL_PATH_WAS_ACTIVE=0 INSTALL_UPDATE_WAS_ACTIVE=0 @@ -64,6 +65,8 @@ INSTALL_WAS_ENABLED=0 INSTALL_PATH_WAS_ENABLED=0 INSTALL_UPDATE_WAS_ENABLED=0 INSTALL_SYSTEMD_TOUCHED=0 +ADMIN_INIT_PATH=/usr/local/sbin/tallynote-admin-init +INSTALL_FIRST_INSTALL=0 DATA_DIR_TEMP_ROOT=0 DATA_DIR_ORIGINAL_OWNER='' @@ -222,6 +225,55 @@ check_requested_port() { esac } +run_initial_admin_wizard() { + if (( ! INSTALL_FIRST_INSTALL )); then + return 0 + fi + if (( NON_INTERACTIVE )); then + log '非交互模式:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init' + return 0 + fi + [[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || { + log '未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo tallynote-admin-init' + 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' + return 0 + fi + [[ "$status" == empty ]] || return 0 + + exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请稍后执行 sudo tallynote-admin-init' + { + printf '\n首次安装还差一步:请创建管理员账号。\n' + printf '管理员账号用于登录 TallyNote,首次登录后需要设置正式密码。\n' + } > "$PROMPT_OUTPUT" + while :; do + prompt_value '现在创建管理员?输入 yes 继续,其他内容稍后创建' 'yes' + choice=$PROMPT_REPLY + case "$choice" in + yes|YES|Yes|y|Y) break ;; + no|NO|No|n|N|'') + exec 9<&- + log '已跳过管理员初始化;稍后可执行 sudo tallynote-admin-init' + return 0 + ;; + *) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;; + esac + done + stage '创建首位管理员(密码不会写入安装日志)' + if ! "$ADMIN_INIT_PATH" <&9 > "$PROMPT_OUTPUT"; then + exec 9<&- + log '管理员初始化未完成;基础安装已完成,稍后可执行 sudo tallynote-admin-init' + return 0 + fi + exec 9<&- + stage_done '首位管理员创建完成' +} + wait_for_service_health() { local host=$1 port=$2 health_host health_url attempt health_host=$host @@ -847,13 +899,16 @@ rollback_install_if_needed() { chmod 700 "$DATA_DIR" 2>/dev/null || true DATA_DIR_TEMP_ROOT=0 fi - if (( INSTALL_COMMITTED == 0 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then + if (( INSTALL_COMMITTED == 0 && INSTALL_BACKUP_COMPLETE == 1 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then local backup_name target - for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote-uninstall tallynote.env update-signing-key.pub; do + for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote-update tallynote-update-runner tallynote-uninstall tallynote-admin-init tallynote.env update-signing-key.pub; do case "$backup_name" in tallynote.env) target="$CONFIG_DIR/tallynote.env" ;; update-signing-key.pub) target="$CONFIG_DIR/update-signing-key.pub" ;; tallynote-uninstall) target="/usr/local/sbin/tallynote-uninstall" ;; + tallynote-admin-init) target="$ADMIN_INIT_PATH" ;; + tallynote-update) target="/usr/local/sbin/tallynote-update" ;; + tallynote-update-runner) target="/usr/local/libexec/tallynote-update-runner" ;; *) target="/etc/systemd/system/$backup_name" ;; esac [[ ! -L "$target" ]] || continue @@ -880,28 +935,41 @@ backup_install_files() { local directory=$1 target name mkdir -p "$directory" chmod 700 "$directory" - for name in tallynote.service tallynote-update.service tallynote-update.path; do - target="/etc/systemd/system/$name" - [[ ! -L "$target" ]] || die "现有 systemd 单元不能是符号链接:$target" + # Validate every target before copying any of them. If validation fails, the + # installer has not changed an existing file and rollback must not infer that + # a partial backup is safe to restore from. + for name in tallynote.service tallynote-update.service tallynote-update.path tallynote-update tallynote-update-runner tallynote-uninstall tallynote-admin-init tallynote.env update-signing-key.pub; do + case "$name" in + tallynote.env) target="$CONFIG_DIR/$name" ;; + update-signing-key.pub) target="$CONFIG_DIR/$name" ;; + tallynote-uninstall) target="/usr/local/sbin/$name" ;; + tallynote-admin-init) target="$ADMIN_INIT_PATH" ;; + tallynote-update) target="/usr/local/sbin/$name" ;; + tallynote-update-runner) target="/usr/local/libexec/$name" ;; + *) target="/etc/systemd/system/$name" ;; + esac + [[ ! -L "$target" ]] || die "现有安装文件不能是符号链接:$target" if [[ -e "$target" ]]; then - [[ -f "$target" ]] || die "现有 systemd 单元不是普通文件:$target" - cp -a -- "$target" "$directory/$name" + [[ -f "$target" ]] || die "现有安装文件不是普通文件:$target" fi done - for name in tallynote.env update-signing-key.pub; do - target="$CONFIG_DIR/$name" - [[ ! -L "$target" ]] || die "现有配置不能是符号链接:$target" + + for name in tallynote.service tallynote-update.service tallynote-update.path tallynote-update tallynote-update-runner tallynote-uninstall tallynote-admin-init tallynote.env update-signing-key.pub; do + case "$name" in + tallynote.env) target="$CONFIG_DIR/$name" ;; + update-signing-key.pub) target="$CONFIG_DIR/$name" ;; + tallynote-uninstall) target="/usr/local/sbin/$name" ;; + tallynote-admin-init) target="$ADMIN_INIT_PATH" ;; + tallynote-update) target="/usr/local/sbin/$name" ;; + tallynote-update-runner) target="/usr/local/libexec/$name" ;; + *) target="/etc/systemd/system/$name" ;; + esac if [[ -e "$target" ]]; then - [[ -f "$target" ]] || die "现有配置不是普通文件:$target" - cp -a -- "$target" "$directory/$name" + cp -a -- "$target" "$directory/$name" || die "无法备份现有安装文件:$target" + [[ -f "$directory/$name" ]] || die "现有安装文件备份不完整:$target" fi done - target="/usr/local/sbin/tallynote-uninstall" - [[ ! -L "$target" ]] || die "现有卸载器不能是符号链接:$target" - if [[ -e "$target" ]]; then - [[ -f "$target" ]] || die "现有卸载器不是普通文件:$target" - cp -a -- "$target" "$directory/tallynote-uninstall" - fi + INSTALL_BACKUP_COMPLETE=1 } read_env_value() { @@ -1091,9 +1159,9 @@ install_release() { normalize_release_tree "$tmp/unpacked" [[ -d "$tmp/unpacked/dist" ]] || die 'release archive must contain dist/ at its root' [[ -x "$tmp/unpacked/bin/tallynote" ]] || die 'release archive must contain executable bin/tallynote' - [[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete' + [[ -f "$tmp/unpacked/package.json" && -f "$tmp/unpacked/dist/server/index.js" && -f "$tmp/unpacked/dist/server/cli/admin-init.js" && -f "$tmp/unpacked/dist/web/index.html" ]] || die 'release archive is incomplete' [[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units' - [[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" && -x "$tmp/unpacked/uninstall.sh" ]] || die 'release archive is missing update/uninstall support files' + [[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" && -x "$tmp/unpacked/uninstall.sh" && -x "$tmp/unpacked/bin/tallynote-admin-init" ]] || die 'release archive is missing update/uninstall/admin-init support files' grep -Eq '"version"[[:space:]]*:[[:space:]]*"'"$version"'"([,}]|[[:space:]])' "$tmp/unpacked/package.json" || die 'release package version does not match requested version' ensure_root_directory "$PREFIX" 755 ensure_root_directory "$PREFIX/releases" 755 @@ -1288,6 +1356,11 @@ main() { stage_done '发布包校验通过' [[ "$PREFIX" = /* && "$DATA_DIR" = /* && "$CONFIG_DIR" = /* ]] || die '安装、数据和配置目录必须是绝对路径' [[ ! -L "$DATA_DIR" && ! -L "$PREFIX" && ! -L "$CONFIG_DIR" ]] || die 'installation/data/config paths must not be symlinks' + if [[ -L "$PREFIX/current" || -e "$PREFIX/current" ]]; then + INSTALL_FIRST_INSTALL=0 + else + INSTALL_FIRST_INSTALL=1 + fi stage '停止旧服务并准备安装、配置和数据目录' id tallynote >/dev/null 2>&1 || useradd --system --user-group --home-dir "$DATA_DIR" --shell /usr/sbin/nologin tallynote backup_install_files "$INSTALL_BACKUP_DIR" @@ -1315,17 +1388,19 @@ main() { stage_done "版本 ${VERSION#v} 已切换为当前版本" release_dir="$PREFIX/releases/$VERSION" [[ -f "$release_dir/systemd/tallynote.service" && -f "$release_dir/systemd/tallynote-update.service" && -f "$release_dir/systemd/tallynote-update.path" ]] || die 'release package is missing systemd unit files' - [[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" ]] || die 'release package is missing update/uninstall support files' + [[ -f "$release_dir/systemd/tallynote.env.example" && -f "$release_dir/scripts/tallynote-update-runner.sh" && -x "$release_dir/uninstall.sh" && -x "$release_dir/bin/tallynote-admin-init" && -f "$release_dir/dist/server/cli/admin-init.js" ]] || die 'release package is missing update/uninstall/admin-init support files' stage '安装 systemd 单元、更新辅助程序和卸载器' - install -d -m 755 /usr/local/libexec /etc/systemd/system + install -d -m 755 /usr/local/sbin /usr/local/libexec /etc/systemd/system local unit_tmp unit_tmp=$(mktemp -d) sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.service" > "$unit_tmp/tallynote.service" sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g" "$release_dir/systemd/tallynote-update.service" > "$unit_tmp/tallynote-update.service" sed "s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path" + sed "s#/opt/tallynote#$PREFIX#g; s#/etc/tallynote#$CONFIG_DIR#g" "$release_dir/bin/tallynote-admin-init" > "$unit_tmp/tallynote-admin-init" install -o root -g root -m 644 "$unit_tmp/tallynote.service" /etc/systemd/system/tallynote.service install -o root -g root -m 644 "$unit_tmp/tallynote-update.service" /etc/systemd/system/tallynote-update.service install -o root -g root -m 644 "$unit_tmp/tallynote-update.path" /etc/systemd/system/tallynote-update.path + install -o root -g root -m 755 "$unit_tmp/tallynote-admin-init" "$ADMIN_INIT_PATH" rm -rf "$unit_tmp" install -o root -g root -m 755 "$release_dir/scripts/tallynote-update.sh" /usr/local/sbin/tallynote-update install -o root -g root -m 755 "$release_dir/scripts/tallynote-update-runner.sh" /usr/local/libexec/tallynote-update-runner @@ -1407,10 +1482,10 @@ main() { printf 'TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=%s\n' "$CONFIG_DIR/update-signing-key.pub" >> "$CONFIG_DIR/tallynote.env" fi fi - stage_done 'systemd 单元、更新辅助程序和卸载器已安装' - stage '重新加载 systemd 并启动 TallyNote' chown root:root "$CONFIG_DIR/tallynote.env" chmod 640 "$CONFIG_DIR/tallynote.env" + stage_done 'systemd 单元、更新辅助程序和卸载器已安装' + stage '重新加载 systemd 并启动 TallyNote' systemctl daemon-reload INSTALL_SYSTEMD_TOUCHED=1 systemctl enable --now tallynote.service tallynote-update.path @@ -1443,6 +1518,7 @@ main() { rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true INSTALL_WORK_DIR='' stage_done "安装完成:TallyNote ${VERSION#v}" + run_initial_admin_wizard local access_url access_host access_port configured_host configured_port access_url=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PUBLIC_ORIGIN 2>/dev/null || true) if [[ -z "$access_url" ]]; then diff --git a/package.json b/package.json index ff9ece9..f880366 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tallynote", - "version": "1.1.11", + "version": "1.1.12", "private": true, "type": "module", "packageManager": "pnpm@9.0.6", diff --git a/scripts/build-release.sh b/scripts/build-release.sh index 583ea03..3efa92f 100755 --- a/scripts/build-release.sh +++ b/scripts/build-release.sh @@ -36,7 +36,7 @@ cp uninstall.sh "$stage/uninstall.sh" cp -a systemd/tallynote.service systemd/tallynote-update.service systemd/tallynote-update.path systemd/tallynote.env.example "$stage/systemd/" node_path=$(command -v node) cp -L "$node_path" "$stage/runtime/bin/node" -chmod 755 "$stage/bin/tallynote" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh" +chmod 755 "$stage/bin/tallynote" "$stage/bin/tallynote-admin-init" "$stage/scripts"/*.sh "$stage/runtime/bin/node" "$stage/uninstall.sh" # pnpm's default linker creates symlinks. A release archive is deliberately # symlink-free so the installer can reject traversal links deterministically. diff --git a/scripts/test-installer.sh b/scripts/test-installer.sh index 30f8d0f..6546267 100755 --- a/scripts/test-installer.sh +++ b/scripts/test-installer.sh @@ -156,12 +156,14 @@ bash -c ' # A RETURN trap installed by install_release must be cleared while its local # temporary variables still exist; otherwise set -u fails at the end of main. release_fixture="$tmp/release-fixture" -mkdir -p "$release_fixture/dist/server" "$release_fixture/dist/web" "$release_fixture/bin" \ +mkdir -p "$release_fixture/dist/server/cli" "$release_fixture/dist/web" "$release_fixture/bin" \ "$release_fixture/scripts" "$release_fixture/runtime/bin" "$release_fixture/systemd" printf '%s\n' '{"version":"1.0.0"}' > "$release_fixture/package.json" printf '%s\n' server > "$release_fixture/dist/server/index.js" +printf '%s\n' cli > "$release_fixture/dist/server/cli/admin-init.js" printf '%s\n' web > "$release_fixture/dist/web/index.html" printf '%s\n' '#!/bin/sh' > "$release_fixture/bin/tallynote" +cp "$root/bin/tallynote-admin-init" "$release_fixture/bin/tallynote-admin-init" printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update.sh" printf '%s\n' '#!/bin/sh' > "$release_fixture/scripts/tallynote-update-runner.sh" printf '%s\n' '#!/bin/sh' > "$release_fixture/uninstall.sh" @@ -169,7 +171,7 @@ printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote.service" printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.service" printf '%s\n' '[Unit]' > "$release_fixture/systemd/tallynote-update.path" printf '%s\n' 'TALLYNOTE_HOST=127.0.0.1' > "$release_fixture/systemd/tallynote.env.example" -chmod 755 "$release_fixture/bin/tallynote" "$release_fixture/scripts"/*.sh "$release_fixture/uninstall.sh" +chmod 755 "$release_fixture/bin/tallynote" "$release_fixture/bin/tallynote-admin-init" "$release_fixture/scripts"/*.sh "$release_fixture/uninstall.sh" release_archive="$tmp/release-fixture.tar.gz" tar -C "$release_fixture" -czf "$release_archive" . bash -c ' @@ -185,10 +187,105 @@ bash -c ' if [[ "${1:-}" == -Tf ]]; then shift; /bin/mv -f "$@"; else /bin/mv "$@"; fi } install_release "$archive" 1.0.0 + [[ -x "$PREFIX/releases/1.0.0/bin/tallynote-admin-init" ]] set_env_key() { local key=$1 value=$2 escaped; :; } set_env_key test value ' _ "$installer_lib" "$release_archive" "$tmp/install-release" +# The production admin wrapper must load a release-relative runtime, change to +# the release root, and forward CLI arguments without requiring pnpm. +wrapper_prefix="$tmp/wrapper-prefix" +mkdir -p "$wrapper_prefix/releases/1.0.0/runtime/bin" "$wrapper_prefix/releases/1.0.0/dist/server/cli" +ln -s "$wrapper_prefix/releases/1.0.0" "$wrapper_prefix/current" +printf '%s\n' '#!/usr/bin/env bash' 'pwd -P > "$TALLYNOTE_WRAPPER_LOG"' 'printf "%s\n" "$@" >> "$TALLYNOTE_WRAPPER_LOG"' > "$wrapper_prefix/releases/1.0.0/runtime/bin/node" +chmod 755 "$wrapper_prefix/releases/1.0.0/runtime/bin/node" +printf '%s\n' cli > "$wrapper_prefix/releases/1.0.0/dist/server/cli/admin-init.js" +TALLYNOTE_INSTALL_PREFIX="$wrapper_prefix" TALLYNOTE_CONFIG_DIR="$tmp/no-config" TALLYNOTE_WRAPPER_LOG="$tmp/wrapper.log" \ + bash "$root/bin/tallynote-admin-init" --generate +wrapper_expected_root=$(cd "$wrapper_prefix/releases/1.0.0" && pwd -P) +grep -Fxq "$wrapper_expected_root" "$tmp/wrapper.log" +grep -Fxq -- '--generate' "$tmp/wrapper.log" + +# The first-install prompt is optional and must support an explicit later +# initialization path without blocking the rest of the install. +admin_wizard_dir="$tmp/admin-wizard" +mkdir -p "$admin_wizard_dir" +printf '%s\n' yes remaining-input > "$admin_wizard_dir/input" +: > "$admin_wizard_dir/output" +cat > "$admin_wizard_dir/admin-init" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == --check ]]; then + printf '%s\n' empty +else + printf '%s\n' initialized > "$TALLYNOTE_ADMIN_WIZARD_RESULT" +fi +EOF +chmod 755 "$admin_wizard_dir/admin-init" +bash -c ' + script=$1 + dir=$2 + set -- + source "$script" + INSTALL_FIRST_INSTALL=1 + NON_INTERACTIVE=0 + PROMPT_INPUT="$dir/input" + PROMPT_OUTPUT="$dir/output" + ADMIN_INIT_PATH="$dir/admin-init" + TALLYNOTE_ADMIN_WIZARD_RESULT="$dir/result" + export TALLYNOTE_ADMIN_WIZARD_RESULT + run_initial_admin_wizard + [[ -f "$dir/result" ]] +' _ "$installer_lib" "$admin_wizard_dir" + +# An upgrade must never reopen the first-admin wizard, even if a damaged or +# deliberately empty database would otherwise report an uninitialized state. +printf '%s\n' yes > "$admin_wizard_dir/upgrade-input" +rm -f "$admin_wizard_dir/upgrade-result" +bash -c ' + script=$1 + dir=$2 + set -- + source "$script" + INSTALL_FIRST_INSTALL=0 + NON_INTERACTIVE=0 + PROMPT_INPUT="$dir/upgrade-input" + PROMPT_OUTPUT="$dir/upgrade-output" + ADMIN_INIT_PATH="$dir/admin-init" + TALLYNOTE_ADMIN_WIZARD_RESULT="$dir/upgrade-result" + export TALLYNOTE_ADMIN_WIZARD_RESULT + run_initial_admin_wizard + [[ ! -e "$dir/upgrade-result" ]] +' _ "$installer_lib" "$admin_wizard_dir" + +# Validation or password errors after the base service is committed must leave +# the installation usable and point the operator at the standalone command. +failed_wizard_dir="$tmp/failed-admin-wizard" +mkdir -p "$failed_wizard_dir" +printf '%s\n' yes > "$failed_wizard_dir/input" +: > "$failed_wizard_dir/output" +cat >"$failed_wizard_dir/admin-init" <<'EOF' +#!/usr/bin/env bash +if [[ "${1:-}" == --check ]]; then + printf '%s\n' empty + exit 0 +fi +exit 1 +EOF +chmod 755 "$failed_wizard_dir/admin-init" +bash -c ' + script=$1 + dir=$2 + set -- + source "$script" + INSTALL_FIRST_INSTALL=1 + NON_INTERACTIVE=0 + PROMPT_INPUT="$dir/input" + PROMPT_OUTPUT="$dir/output" + ADMIN_INIT_PATH="$dir/admin-init" + run_initial_admin_wizard + [[ -x "$dir/admin-init" ]] +' _ "$installer_lib" "$failed_wizard_dir" + # Duplicate security-sensitive EnvironmentFile assignments are rejected even # when the first value looks valid (systemd uses the later value). duplicate_env="$tmp/duplicate.env" diff --git a/scripts/test-uninstaller.sh b/scripts/test-uninstaller.sh index a3c9e1e..7f3ba0b 100755 --- a/scripts/test-uninstaller.sh +++ b/scripts/test-uninstaller.sh @@ -34,8 +34,9 @@ make_fixture() { done printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/sbin/tallynote-update" printf '%s\n' '#!/usr/bin/env bash' 'PREFIX=${TALLYNOTE_INSTALL_PREFIX:-/opt/tallynote}' 'echo TallyNote' > "$fixture/usr/local/libexec/tallynote-update-runner" + printf '%s\n' '#!/usr/bin/env bash' 'exec /opt/tallynote/current/runtime/bin/node /opt/tallynote/current/dist/server/cli/admin-init.js' > "$fixture/usr/local/sbin/tallynote-admin-init" cp "$root/uninstall.sh" "$fixture/usr/local/sbin/tallynote-uninstall" - chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-uninstall" + chmod 755 "$fixture/usr/local/sbin/tallynote-update" "$fixture/usr/local/libexec/tallynote-update-runner" "$fixture/usr/local/sbin/tallynote-admin-init" "$fixture/usr/local/sbin/tallynote-uninstall" printf '%s\n' 'sqlite' > "$fixture/var/lib/tallynote/tallynote.db" printf '%s\n' 'backup' > "$fixture/var/lib/tallynote-backups/backup.db" } @@ -73,6 +74,7 @@ run_uninstall "$fixture" [[ ! -e "$fixture/opt/tallynote" || -z "$(find "$fixture/opt/tallynote" -mindepth 1 -print -quit 2>/dev/null)" ]] [[ ! -e "$fixture/etc/systemd/system/tallynote.service" ]] [[ ! -e "$fixture/usr/local/sbin/tallynote-update" ]] +[[ ! -e "$fixture/usr/local/sbin/tallynote-admin-init" ]] grep -n '^is-active.*tallynote-update.path' "$fixture/systemctl.log" >/dev/null grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" >/dev/null path_stop=$(grep -n '^stop tallynote-update.path' "$fixture/systemctl.log" | head -n1 | cut -d: -f1) diff --git a/server/cli/admin-init.ts b/server/cli/admin-init.ts index 24c8c9a..43a6f4d 100644 --- a/server/cli/admin-init.ts +++ b/server/cli/admin-init.ts @@ -1,7 +1,7 @@ import { stdin as input, stdout as output } from "node:process"; -import { mkdirSync } from "node:fs"; import { randomUUID } from "node:crypto"; -import { openDatabase } from "../db/index.js"; +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 { writeAudit } from "../audit.js"; @@ -11,42 +11,120 @@ function arg(name: string): string | undefined { 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 onData = (chunk: Buffer) => { - const text = chunk.toString("utf8"); - if (text === "\u0003") { - cleanup(); - reject(new Error("已取消")); - } else if (text === "\r" || text === "\n") { - cleanup(); - output.write("\n"); - resolve(value); - } else if (text === "\u007f") { - value = value.slice(0, -1); - } else if (!text.includes("\u001b")) { - value += text; - } - }; + 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); + } else { + value += 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); - mkdirSync(config.dataDir, { recursive: true, mode: 0o700 }); const release = acquireInstanceLock(config); const database = openDatabase(config); try { @@ -64,6 +142,9 @@ async function main() { 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(); @@ -74,14 +155,14 @@ async function main() { 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, displayName.trim(), passwordHash, now); + `).run(id, username.normalize("NFKC").trim(), normalized, normalizedDisplayName, passwordHash, now); writeAudit(database.sqlite, { requestId: `cli:${randomUUID()}`, actorUsername: "cli", action: "admin.initialized", targetType: "admin", targetId: id, - after: { username: normalized, displayName: displayName.trim(), status: "active" }, + after: { username: normalized, displayName: normalizedDisplayName, status: "active" }, }); })(); console.log(generate ? `已创建首位管理员。一次性密码:${password}` : "已创建首位管理员。"); diff --git a/server/db/index.ts b/server/db/index.ts index f239392..72cd40f 100644 --- a/server/db/index.ts +++ b/server/db/index.ts @@ -1,6 +1,6 @@ import Database from "better-sqlite3"; import { drizzle, type BetterSQLite3Database } from "drizzle-orm/better-sqlite3"; -import { readdirSync, readFileSync } from "node:fs"; +import { lstatSync, readdirSync, readFileSync } from "node:fs"; import { chmodSync, existsSync } from "node:fs"; import path from "node:path"; import type { AppConfig } from "../config.js"; @@ -44,3 +44,24 @@ export function openDatabase(config: AppConfig): DatabaseContext { if (foreignKeys !== 1) throw new Error("SQLite 外键未启用"); return { sqlite, db: drizzle(sqlite, { schema }) }; } + +/** + * Open an existing database without creating directories, changing journal + * mode, running migrations, or changing file permissions. This is used by + * administrative status checks that must be side-effect free. + */ +export function openDatabaseReadOnly(config: AppConfig): DatabaseContext { + const info = lstatSync(config.dbPath); + if (!info.isFile() || info.isSymbolicLink()) throw new Error(`数据库文件不是安全的普通文件:${config.dbPath}`); + const sqlite = new Database(config.dbPath, { readonly: true, fileMustExist: true }); + sqlite.pragma("foreign_keys = ON"); + sqlite.pragma("busy_timeout = 5000"); + sqlite.pragma("temp_store = MEMORY"); + sqlite.pragma("query_only = ON"); + const foreignKeys = sqlite.pragma("foreign_keys", { simple: true }); + if (foreignKeys !== 1) { + sqlite.close(); + throw new Error("SQLite 外键未启用"); + } + return { sqlite, db: drizzle(sqlite, { schema }) }; +} diff --git a/tests/admin-init.test.ts b/tests/admin-init.test.ts new file mode 100644 index 0000000..fdaa7d5 --- /dev/null +++ b/tests/admin-init.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it } from "vitest"; +import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { tmpdir } from "node:os"; +import path from "node:path"; +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"); + +function runAdmin(dataDir: string, args: string[]) { + return spawnSync(process.execPath, [tsx, cli, ...args], { + 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", + }); +} + +describe("生产管理员初始化 CLI", () => { + it("--check 是只读的,空数据目录不会被创建", () => { + const parent = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-")); + const dataDir = path.join(parent, "data"); + try { + const result = runAdmin(dataDir, ["--check"]); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("empty"); + expect(existsSync(dataDir)).toBe(false); + expect(existsSync(path.join(dataDir, "tallynote.db"))).toBe(false); + } finally { + rmSync(parent, { recursive: true, force: true }); + } + }); + + it("--check 不会执行迁移或创建 schema_migrations", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-check-")); + const database = new Database(path.join(dataDir, "tallynote.db")); + database.exec("CREATE TABLE admins (id TEXT PRIMARY KEY)"); + database.close(); + try { + const result = runAdmin(dataDir, ["--check"]); + expect(result.status).toBe(0); + expect(result.stdout.trim()).toBe("empty"); + const verify = new Database(path.join(dataDir, "tallynote.db"), { readonly: true }); + const schemaMigrations = verify + .prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'") + .get(); + expect(schemaMigrations).toBeUndefined(); + verify.close(); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); + + it("只允许初始化首位管理员,并写入一次性密码和审计记录", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-")); + try { + const first = runAdmin(dataDir, ["--username", "admin", "--display-name", "管理员", "--generate"]); + expect(first.status).toBe(0); + expect(first.stdout).toMatch(/已创建首位管理员。一次性密码:\S+/); + + const database = new Database(path.join(dataDir, "tallynote.db")); + const admin = database.prepare("SELECT username, display_name, must_change_password FROM admins").get() as { username: string; display_name: string; must_change_password: number }; + const audit = database.prepare("SELECT action, actor_username FROM audit_events ORDER BY occurred_at DESC LIMIT 1").get() as { action: string; actor_username: string }; + expect(admin).toEqual({ username: "admin", display_name: "管理员", must_change_password: 1 }); + expect(audit).toEqual({ action: "admin.initialized", actor_username: "cli" }); + database.close(); + + const check = runAdmin(dataDir, ["--check"]); + expect(check.status).toBe(0); + expect(check.stdout.trim()).toBe("initialized"); + + const second = runAdmin(dataDir, ["--username", "other", "--display-name", "其他", "--generate"]); + expect(second.status).not.toBe(0); + expect(`${second.stdout}${second.stderr}`).toContain("INITIAL_ADMIN_EXISTS"); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }, 15_000); + + it("密码输入不是 TTY 时明确拒绝通过管道传入", () => { + const dataDir = mkdtempSync(path.join(tmpdir(), "tallynote-admin-init-")); + try { + const result = spawnSync(process.execPath, [tsx, cli], { + cwd: root, + input: "admin\n管理员\npassword-password\npassword-password\n", + 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).not.toBe(0); + expect(`${result.stdout}${result.stderr}`).toContain("交互式 TTY"); + expect(readFileSync(path.join(dataDir, "tallynote.db"))).toBeTruthy(); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } + }); +}); diff --git a/uninstall.sh b/uninstall.sh index cbdf608..276c934 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -245,14 +245,14 @@ assert_test_scope() { managed_file() { local target=$1 label=$2 case "$label" in - service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|uninstaller) + service\ unit|updater\ unit|path\ unit|update\ helper|update\ runner|admin\ initializer|uninstaller) grep -Eiq 'tallynote|TallyNote' "$target" || return 1 if [[ "$label" == 'path unit' ]]; then grep -Fq "$DATA_DIR" "$target" || return 1 elif [[ "$label" == *unit ]]; then grep -Fq "$PREFIX" "$target" || return 1 else - grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote' "$target" || return 1 + grep -Eq 'TALLYNOTE_INSTALL_PREFIX|/opt/tallynote|admin-init.js' "$target" || return 1 fi ;; environment\ file) @@ -487,6 +487,7 @@ main() { remove_file_if_owned "$UNIT_DIR/tallynote-update.path" 'path unit' remove_file_if_owned "$SBIN_DIR/tallynote-update" 'update helper' remove_file_if_owned "$LIBEXEC_DIR/tallynote-update-runner" 'update runner' + remove_file_if_owned "$SBIN_DIR/tallynote-admin-init" 'admin initializer' remove_file_if_owned "$SBIN_DIR/tallynote-uninstall" 'uninstaller' remove_config remove_data diff --git a/web-next/src/main.tsx b/web-next/src/main.tsx index d1df993..e27887c 100644 --- a/web-next/src/main.tsx +++ b/web-next/src/main.tsx @@ -90,7 +90,7 @@ function App() { if (isSessionBootstrapping(session)) return
; if (session.status === "error") return

无法连接 TallyNote

{session.error || "请确认本地服务正在运行。"}

; if (!session.admin) return setPasswordOpen(false)} />; if (session.admin.mustChangePassword) return notify("密码已更新", "success")} />;