Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
344985f514 |
@@ -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
|
||||
|
||||
Executable
+149
@@ -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 "$@"
|
||||
+1
-1
@@ -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` 作为旧版本兼容参数保留。
|
||||
|
||||
安装布局:
|
||||
|
||||
|
||||
+100
-24
@@ -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
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "tallynote",
|
||||
"version": "1.1.11",
|
||||
"version": "1.1.12",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@9.0.6",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
+101
-20
@@ -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<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 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}` : "已创建首位管理员。");
|
||||
|
||||
+22
-1
@@ -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 }) };
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
+3
-2
@@ -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
|
||||
|
||||
@@ -90,7 +90,7 @@ function App() {
|
||||
if (isSessionBootstrapping(session)) return <main className="tn-auth-shell" role="status" aria-live="polite"><Loading text="正在连接本地账本…" /></main>;
|
||||
if (session.status === "error") return <main className="tn-auth-shell"><div className="tn-auth-panel"><h1 className="tn-page-title">无法连接 TallyNote</h1><p className="tn-page-subtitle">{session.error || "请确认本地服务正在运行。"}</p><Button theme="primary" onClick={() => void dispatch(bootstrapSession())}>重新连接</Button></div></main>;
|
||||
if (!session.admin) return <LoginPage
|
||||
notice={session.initialized ? undefined : "尚未初始化管理员,请先在服务器执行 pnpm admin:init。"}
|
||||
notice={session.initialized ? undefined : "首次安装还差一步:请在服务器执行 sudo tallynote-admin-init 创建管理员账号。"}
|
||||
onSuccess={() => setPasswordOpen(false)}
|
||||
/>;
|
||||
if (session.admin.mustChangePassword) return <ChangePasswordPage admin={session.admin} firstLogin onSuccess={() => notify("密码已更新", "success")} />;
|
||||
|
||||
Reference in New Issue
Block a user