#!/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 "$@"