#!/usr/bin/env bash set -Eeuo pipefail # TallyNote native installer. Installs the latest release by default; use # --dry-run to preview without changing the host. PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin export PATH umask 077 PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote} DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote} CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote} REPOSITORY_URL=${TALLYNOTE_REPOSITORY_URL:-https://git.awaioi.com/awaioi/TallyNote} RELEASE_API_URL=${TALLYNOTE_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest} RELEASE_BASE_URL=${TALLYNOTE_RELEASE_BASE_URL:-} VERSION=${TALLYNOTE_VERSION:-latest} RELEASE_FILE=${TALLYNOTE_RELEASE_FILE:-} SHA256_URL=${TALLYNOTE_SHA256_URL:-} SIGNATURE_URL=${TALLYNOTE_SIGNATURE_URL:-} SIGNING_KEY=${TALLYNOTE_SIGNING_KEY:-} SIGNATURE_FORMAT=${TALLYNOTE_SIGNATURE_FORMAT:-ed25519} SHA256_FILE=${TALLYNOTE_SHA256_FILE:-} UPDATE_PUBLIC_KEY_FILE=${TALLYNOTE_UPDATE_PUBLIC_KEY_FILE:-} APPLY=1 KEEP_RELEASES=${TALLYNOTE_KEEP_RELEASES:-3} REQUIRE_SIGNATURE=${TALLYNOTE_INSTALL_REQUIRE_SIGNATURE:-false} ALLOW_DOWNGRADE=${TALLYNOTE_ALLOW_DOWNGRADE:-false} ALLOW_UNSIGNED=0 MAX_RELEASE_MB=${TALLYNOTE_MAX_RELEASE_MB:-512} MAX_EXTRACT_MB=${TALLYNOTE_MAX_EXTRACT_MB:-2048} MAX_ARCHIVE_ENTRIES=${TALLYNOTE_MAX_ARCHIVE_ENTRIES:-100000} CONNECT_TIMEOUT=${TALLYNOTE_INSTALL_CONNECT_TIMEOUT_SECONDS:-15} MAX_TIME=${TALLYNOTE_INSTALL_MAX_TIME_SECONDS:-300} RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-} OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl} UNAME_BIN=${TALLYNOTE_UNAME_BIN:-uname} # Service network settings are written to the systemd EnvironmentFile on a # fresh install. Existing values are preserved unless the corresponding # TALLYNOTE_* variable is explicitly supplied to the installer. INSTALL_HOST=${TALLYNOTE_HOST-127.0.0.1} INSTALL_PORT=${TALLYNOTE_PORT-3000} INSTALL_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN-} INSTALL_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP-false} PUBLIC_IP_URL=${TALLYNOTE_PUBLIC_IP_URL-} NON_INTERACTIVE=0 NETWORK_INTERACTIVE=0 # The production prompt uses the controlling terminal, even when the # installer itself is read from `curl | sudo bash`. PROMPT_INPUT=/dev/tty PROMPT_OUTPUT=/dev/tty PROMPT_REPLY='' INSTALL_SWITCHED=0 INSTALL_COMMITTED=0 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 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='' REPOSITORY_URL=${REPOSITORY_URL%/} RELEASE_API_URL=${RELEASE_API_URL%/} usage() { cat <<'EOF' Usage: install.sh [--dry-run] [--version VERSION] [--release-base-url HTTPS_URL] [--release-file FILE] [--sha256-url HTTPS_URL|--sha256-file FILE] [--signature-url HTTPS_URL] [--signing-key PUBLIC_KEY_FILE] [--signature-format ed25519|gpg] [--update-public-key-file FILE] [--keep-releases N] [--allow-downgrade] [--allow-unsigned] [--apply] [--non-interactive] Without arguments, the installer resolves the latest compatible release and installs it. SHA-256 from SHA256SUMS is always required. Detached signature verification is optional by default; enable it with TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true and provide a public key. Use --dry-run to inspect the selected release without downloading or changing the host. For direct IP access, pass TALLYNOTE_HOST=0.0.0.0 and an actual TALLYNOTE_PUBLIC_ORIGIN such as http://203.0.113.10:3000; HTTP also requires TALLYNOTE_ALLOW_INSECURE_HTTP=true. On a fresh terminal install, the listener and public URL can be selected interactively. The installer checks that the selected TCP port is free, suggests a public IPv4 address when exposing 0.0.0.0, and prints the final access URL after the service starts. Use --non-interactive (or TALLYNOTE_NON_INTERACTIVE=true) for automation. --apply is accepted for backwards compatibility. EOF } die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; } log() { printf 'tallynote installer: %s\n' "$*"; } stage() { log "[阶段] $*"; } stage_done() { log "[完成] $*"; } case "${TALLYNOTE_NON_INTERACTIVE:-false}" in true|1) NON_INTERACTIVE=1 ;; false|0) ;; *) die 'TALLYNOTE_NON_INTERACTIVE 必须是 true 或 false' ;; esac prompt_value() { local label=$1 default=${2-} reply if [[ -n "$default" ]]; then printf '%s [%s]: ' "$label" "$default" > "$PROMPT_OUTPUT" else printf '%s: ' "$label" > "$PROMPT_OUTPUT" fi if ! IFS= read -r reply <&9; then die '无法读取终端输入;请使用 --non-interactive 或通过环境变量配置' fi PROMPT_REPLY=${reply:-$default} } detect_public_ipv4() { local endpoint value octet local -a endpoints=() if [[ -n "$PUBLIC_IP_URL" ]]; then endpoints=("$PUBLIC_IP_URL") else # These services return the caller's address as plain text. HTTPS is # required, and a failure simply falls back to manual address entry. endpoints=( 'https://api.ipify.org' 'https://ifconfig.me/ip' 'https://checkip.amazonaws.com' ) fi command -v curl >/dev/null 2>&1 || return 1 for endpoint in "${endpoints[@]}"; do [[ "$endpoint" == https://* && "$endpoint" != *[[:space:]]* && "$endpoint" != *[[:cntrl:]]* && "$endpoint" != *'@'* ]] || continue value=$(curl -4 --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \ --connect-timeout 4 --max-time 8 --max-filesize 128 "$endpoint" 2>/dev/null \ | tr -d '[:space:]') || continue [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || continue IFS='.' read -r -a _public_ip_octets <<< "$value" for octet in "${_public_ip_octets[@]}"; do (( 10#$octet <= 255 )) || continue 2 done printf '%s' "$value" return 0 done return 1 } port_listener_state() { local port=$1 output status=0 validate_listen_port "$port" >/dev/null 2>&1 || return 2 if command -v ss >/dev/null 2>&1; then if output=$(ss -H -ltn 2>/dev/null); then if awk -v port="$port" '$4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then return 1 fi return 0 fi fi if command -v lsof >/dev/null 2>&1; then output='' status=0 output=$(lsof -nP -iTCP:"$port" -sTCP:LISTEN -t 2>/dev/null) || status=$? [[ -n "$output" ]] && return 1 [[ "$status" == 1 && -z "$output" ]] && return 0 [[ "$status" == 0 ]] && return 0 fi if command -v netstat >/dev/null 2>&1; then if output=$(netstat -lnt 2>/dev/null); then if awk -v port="$port" '$6 == "LISTEN" && $4 ~ (":" port "$") { found=1 } END { exit found ? 0 : 1 }' <<< "$output"; then return 1 fi return 0 fi fi if command -v python3 >/dev/null 2>&1; then python3 - "$port" <<'PY' import errno import socket import sys port = int(sys.argv[1]) for family, address in ((socket.AF_INET, "0.0.0.0"), (socket.AF_INET6, "::")): sock = socket.socket(family, socket.SOCK_STREAM) try: if family == socket.AF_INET6: sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1) sock.bind((address, port)) except OSError as error: if error.errno == errno.EADDRINUSE: sys.exit(1) finally: sock.close() sys.exit(0) PY status=$? case "$status" in 0) return 0 ;; 1) return 1 ;; esac fi return 2 } check_requested_port() { local port=$1 state state=0 port_listener_state "$port" || state=$? case "$state" in 0) return 0 ;; 1) die "端口 ${port} 已被占用,请选择其他端口" ;; *) die "无法检测端口 ${port} 是否被占用,请安装 ss、lsof、netstat 或 Python 3 后重试" ;; 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 case "$health_host" in 0.0.0.0) health_host=127.0.0.1 ;; ::) health_host=::1 ;; esac if [[ "$health_host" == *:* && "$health_host" != \[* ]]; then health_host="[$health_host]"; fi health_url="http://${health_host}:${port}/health" for attempt in 1 2 3 4 5 6 7 8 9 10 11 12; do if curl --proto '=http' --connect-timeout 2 --max-time 3 --fail --silent "$health_url" >/dev/null 2>&1; then return 0 fi (( attempt < 12 )) && sleep 1 done return 1 } has_network_environment() { [[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" || -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" || -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]] } interactive_network_available() { (( APPLY )) || return 1 (( NON_INTERACTIVE == 0 )) || return 1 has_network_environment && return 1 [[ ! -e "$CONFIG_DIR/tallynote.env" && ! -L "$CONFIG_DIR/tallynote.env" ]] || return 1 [[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || return 1 return 0 } configure_network_interactively() { interactive_network_available || return 0 NETWORK_INTERACTIVE=1 exec 9<"$PROMPT_INPUT" || die '无法打开终端输入;请使用 --non-interactive 或通过环境变量配置' stage '配置服务网络监听(可直接回车使用默认值)' { printf '\nTallyNote 服务监听配置\n' printf ' 1) 仅本机访问:127.0.0.1(更安全)\n' printf ' 2) 局域网/公网访问:0.0.0.0(需要填写实际访问地址)\n' } > "$PROMPT_OUTPUT" local choice selected_port origin answer port_state detected_ip default_origin while :; do prompt_value '请选择监听方式 1/2' '1' choice=$PROMPT_REPLY case "$choice" in 1|2) break ;; *) printf '请输入 1 或 2。\n' > "$PROMPT_OUTPUT" ;; esac done while :; do prompt_value '监听端口' "$INSTALL_PORT" selected_port=$PROMPT_REPLY if [[ "$selected_port" =~ ^[1-9][0-9]*$ && "$selected_port" -le 65535 ]]; then # A real terminal can reject an occupied port immediately. The final # check in main() runs again after old services have been stopped. if [[ -t 9 ]]; then port_state=0 port_listener_state "$selected_port" || port_state=$? case "$port_state" in 0) break ;; 1) printf '端口 %s 已被占用,请输入其他端口。\n' "$selected_port" > "$PROMPT_OUTPUT"; continue ;; *) printf '暂时无法预检端口,安装前还会再次检查。\n' > "$PROMPT_OUTPUT"; break ;; esac fi break fi printf '端口必须是 1-65535 的整数,请重试。\n' > "$PROMPT_OUTPUT" done if [[ "$choice" == 1 ]]; then INSTALL_HOST=127.0.0.1 INSTALL_PORT=$selected_port INSTALL_PUBLIC_ORIGIN="http://127.0.0.1:${selected_port}" INSTALL_ALLOW_INSECURE_HTTP=false else INSTALL_HOST=0.0.0.0 INSTALL_PORT=$selected_port detected_ip='' # Test fixtures replace /dev/tty with regular files; avoid making their # behavior depend on an external IP lookup service. if [[ -t 9 ]]; then detected_ip=$(detect_public_ipv4 || true) fi if [[ -n "$detected_ip" ]]; then default_origin="http://${detected_ip}:${selected_port}" printf '已探测公网 IPv4:%s\n' "$detected_ip" > "$PROMPT_OUTPUT" else default_origin='' printf '未能自动获取公网 IPv4,请手动填写访问地址。\n' > "$PROMPT_OUTPUT" fi while :; do prompt_value '实际访问地址(回车使用自动探测地址,也可填写域名)' "$default_origin" origin=$PROMPT_REPLY if validate_env_value "$origin" '公开访问地址' >/dev/null 2>&1 && validate_public_origin "$origin" >/dev/null 2>&1; then INSTALL_PUBLIC_ORIGIN=$origin break fi printf '地址无效:请输入不含路径、凭据或通配监听地址的 http:// 或 https:// 地址。\n' > "$PROMPT_OUTPUT" done INSTALL_ALLOW_INSECURE_HTTP=false if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* ]]; then { printf '\n警告:直连 HTTP 不加密,登录信息和账目数据可能被窃听。\n' printf '仅在受控局域网或你明确接受风险时继续。\n' } > "$PROMPT_OUTPUT" while :; do prompt_value '确认允许公网 HTTP?输入 yes 继续,其他内容取消' 'no' answer=$PROMPT_REPLY case "$answer" in yes|YES|Yes|y|Y) INSTALL_ALLOW_INSECURE_HTTP=true; break ;; no|NO|No|n|N|'') die '已取消:公网 HTTP 必须明确确认;请改用 HTTPS 或重新运行安装器' ;; *) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;; esac done fi fi exec 9<&- stage_done "网络配置已选择:${INSTALL_HOST}:${INSTALL_PORT}" } [[ "$REQUIRE_SIGNATURE" == true || "$REQUIRE_SIGNATURE" == false ]] || die 'TALLYNOTE_INSTALL_REQUIRE_SIGNATURE 必须是 true 或 false' [[ "$ALLOW_DOWNGRADE" == true || "$ALLOW_DOWNGRADE" == false ]] || die 'TALLYNOTE_ALLOW_DOWNGRADE 必须是 true 或 false' [[ "$SIGNATURE_FORMAT" == ed25519 || "$SIGNATURE_FORMAT" == gpg ]] || die '签名格式必须是 ed25519 或 gpg' [[ "$MAX_RELEASE_MB" =~ ^[1-9][0-9]*$ && "$MAX_EXTRACT_MB" =~ ^[1-9][0-9]*$ && "$MAX_ARCHIVE_ENTRIES" =~ ^[1-9][0-9]*$ ]] || die '安装资源限制必须是正整数' [[ "$CONNECT_TIMEOUT" =~ ^[1-9][0-9]*$ && "$MAX_TIME" =~ ^[1-9][0-9]*$ ]] || die '安装超时配置必须是正整数' version_sort_desc() { if sort -V /dev/null 2>&1; then sort -V -r return fi # BSD sort (macOS) and minimal BusyBox builds may lack -V. The installer # targets Linux, but keeping a numeric fallback makes dry-runs deterministic # and avoids deleting a newer 1.10 release before an older 1.9 release. awk -F'[.-]' '{ printf "%020d.%020d.%020d.%s\t%s\n", $1, $2, $3, ($4 == "" ? "~" : $4), $0 }' \ | sort -r | cut -f2- } while (($#)); do case "$1" in --apply) APPLY=1 ;; --dry-run) APPLY=0 ;; --version) VERSION=${2:?missing value for --version}; shift ;; --release-base-url) RELEASE_BASE_URL=${2:?missing value for --release-base-url}; shift ;; --release-file) RELEASE_FILE=${2:?missing value for --release-file}; shift ;; --sha256-url) SHA256_URL=${2:?missing value for --sha256-url}; shift ;; --sha256-file) SHA256_FILE=${2:?missing value for --sha256-file}; shift ;; --signature-url) SIGNATURE_URL=${2:?missing value for --signature-url}; shift ;; --signing-key) SIGNING_KEY=${2:?missing value for --signing-key}; shift ;; --signature-format) SIGNATURE_FORMAT=${2:?missing value for --signature-format}; shift ;; --update-public-key-file) UPDATE_PUBLIC_KEY_FILE=${2:?missing value for --update-public-key-file}; shift ;; --keep-releases) KEEP_RELEASES=${2:?missing value for --keep-releases}; shift ;; --allow-downgrade) ALLOW_DOWNGRADE=true ;; --allow-unsigned) ALLOW_UNSIGNED=1; REQUIRE_SIGNATURE=false ;; --non-interactive) NON_INTERACTIVE=1 ;; -h|--help) usage; exit 0 ;; *) die "unknown option: $1" ;; esac shift done detect_platform() { local machine libc os os=$("$UNAME_BIN" -s) if [[ "$os" != Linux ]]; then (( APPLY )) && die "仅支持 Linux 安装(当前系统:$os);可用 --dry-run 预览" log "dry-run: 当前系统为 ${os},--apply 仅允许 Linux" fi machine=$("$UNAME_BIN" -m) case "$machine" in x86_64|amd64) TALLYNOTE_ARCH=x64 ;; aarch64|arm64) TALLYNOTE_ARCH=arm64 ;; armv7l|armv7|armhf) TALLYNOTE_ARCH=armv7; log 'ARMv7 is experimental; continue only if a matching release exists.' ;; i?86|x86) die '32-bit x86 (ia32) is unsupported' ;; *) die "unsupported CPU architecture: $machine" ;; esac libc=glibc if command -v ldd >/dev/null 2>&1 && ldd --version 2>&1 | grep -qi musl; then libc=musl; fi TALLYNOTE_LIBC=$libc export TALLYNOTE_ARCH TALLYNOTE_LIBC } require_https() { local value=$1 case "$value" in https://*) ;; *) die "release endpoints must use HTTPS: $value" ;; esac [[ "$value" != *[[:cntrl:]]* && "$value" != *[[:space:]]* ]] || die 'release endpoint contains control characters' [[ "$value" != *'@'* ]] || die 'release endpoints must not contain credentials' } url_host() { local authority host require_https "$1" authority=${1#https://} authority=${authority%%/*} [[ -n "$authority" && "$authority" != *'@'* ]] || die 'release endpoint host is invalid' if [[ "$authority" == \[*\]* ]]; then host=${authority#\[} host=${host%%\]*} else host=${authority%%:*} fi [[ "$host" =~ ^[A-Za-z0-9.-]+$ || "$host" =~ ^[0-9A-Fa-f:]+$ ]] || die 'release endpoint host is invalid' if [[ "$authority" != \[*\]* && "$authority" == *:* ]]; then local port=${authority##*:} [[ "$port" =~ ^[0-9]{1,5}$ && "$port" -ge 1 && "$port" -le 65535 ]] || die 'release endpoint port is invalid' fi printf '%s' "$host" | tr '[:upper:]' '[:lower:]' } validate_allowed_hosts() { local candidate [[ -z "$RELEASE_ALLOWED_HOSTS" ]] && return 0 IFS=',' read -r -a _allowed_parts <<< "$RELEASE_ALLOWED_HOSTS" ((${#_allowed_parts[@]} > 0)) || die 'release host allowlist is invalid' for candidate in "${_allowed_parts[@]}"; do [[ "$candidate" =~ ^[A-Za-z0-9.-]+$ || "$candidate" =~ ^[0-9A-Fa-f:]+$ ]] || die 'release host allowlist contains an invalid host' done } append_allowed_host() { local host=$1 candidate [[ -n "$host" ]] || return 0 if [[ -n "$RELEASE_ALLOWED_HOSTS" ]]; then _allowed_parts=() IFS=',' read -r -a _allowed_parts <<< "$RELEASE_ALLOWED_HOSTS" for candidate in "${_allowed_parts[@]}"; do [[ "$(printf '%s' "$candidate" | tr '[:upper:]' '[:lower:]')" == "$host" ]] && return 0 done fi RELEASE_ALLOWED_HOSTS=${RELEASE_ALLOWED_HOSTS:+$RELEASE_ALLOWED_HOSTS,}$host } assert_allowed_url() { local url=$1 host candidate host=$(url_host "$url") [[ -n "$RELEASE_ALLOWED_HOSTS" ]] || die 'release host allowlist is empty' _allowed_parts=() IFS=',' read -r -a _allowed_parts <<< "$RELEASE_ALLOWED_HOSTS" for candidate in "${_allowed_parts[@]}"; do candidate=$(printf '%s' "$candidate" | tr '[:upper:]' '[:lower:]' | sed 's/[[:space:]]//g') [[ -n "$candidate" && "$candidate" == "$host" ]] && return 0 done die "release URL redirected to an untrusted host: $host" } download() { local url=$1 out=$2 max_bytes=${3:-$((MAX_RELEASE_MB * 1024 * 1024))} local current="$url" headers status location actual origin scheme authority local -a curl_args=(--proto '=https' --tlsv1.2 --fail --show-error --max-redirs 0 --connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --max-filesize "$max_bytes" --retry 2 --retry-connrefused) # Keep CI and journal output clean, while showing curl's standard progress # bar during an interactive SSH/terminal installation. if [[ -t 2 ]]; then curl_args+=(--progress-bar) else curl_args+=(--silent) fi require_https "$url" assert_allowed_url "$url" [[ ! -L "$out" && ! -e "$out" ]] || die "download destination already exists: $out" for _redirect in 0 1 2 3; do headers="${out}.headers-${RANDOM}-$$" status=$(curl "${curl_args[@]}" --output "$out" --dump-header "$headers" \ --write-out '%{http_code}' "$current") || status=000 if [[ "$status" =~ ^2[0-9][0-9]$ ]]; then rm -f -- "$headers" break fi if [[ "$status" =~ ^3[0-9][0-9]$ ]]; then location=$(awk 'BEGIN{IGNORECASE=1} /^Location:/ {sub(/^[^:]*:[[:space:]]*/, ""); gsub(/[\r\n]/, ""); value=$0} END{print value}' "$headers") rm -f -- "$headers" [[ -n "$location" ]] || { rm -f -- "$out"; die 'release URL redirect is missing Location'; } case "$location" in https://*) current="$location" ;; /*) scheme=${current%%://*} authority=${current#*://}; authority=${authority%%/*} origin="${scheme}://${authority}" current="${origin}${location}" ;; *) current="${current%/*}/$location" ;; esac require_https "$current" assert_allowed_url "$current" continue fi rm -f -- "$headers" "$out" die "无法下载 release 文件" done [[ "$status" =~ ^2[0-9][0-9]$ ]] || { rm -f -- "$out"; die 'release URL 重定向次数超过限制'; } actual=$(wc -c < "$out" | tr -d '[:space:]') [[ "$actual" =~ ^[0-9]+$ && "$actual" -le "$max_bytes" ]] || { rm -f -- "$out"; die '下载文件超过大小限制'; } chmod 600 "$out" } resolve_latest_version() { local payload tag metadata_file require_https "$RELEASE_API_URL" assert_allowed_url "$RELEASE_API_URL" metadata_file=$(mktemp) rm -f -- "$metadata_file" download "$RELEASE_API_URL" "$metadata_file" $((2 * 1024 * 1024)) payload=$(cat "$metadata_file") rm -f -- "$metadata_file" if command -v jq >/dev/null 2>&1; then tag=$(printf '%s' "$payload" | jq -r '.tag_name // .tagName // empty' 2>/dev/null || true) elif command -v python3 >/dev/null 2>&1; then tag=$(printf '%s' "$payload" | python3 -c 'import json,sys; d=json.load(sys.stdin); print(d.get("tag_name") or d.get("tagName") or "")' 2>/dev/null || true) else tag=$(printf '%s' "$payload" | sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -n 1) fi validate_semver "$tag" || die 'release API 未返回有效版本号' VERSION=${tag#v} } release_urls() { local version_tag="v${VERSION#v}" if [[ -z "$RELEASE_BASE_URL" ]]; then RELEASE_BASE_URL="${REPOSITORY_URL}/releases/download/${version_tag}" elif [[ "$RELEASE_BASE_URL" == *"{version}"* ]]; then RELEASE_BASE_URL=${RELEASE_BASE_URL//\{version\}/$version_tag} fi RELEASE_BASE_URL=${RELEASE_BASE_URL%/} require_https "$RELEASE_BASE_URL" append_allowed_host "$(url_host "$RELEASE_BASE_URL")" } verify_archive() { local archive=$1 checksum=$2 signature=$3 key=$4 expected archive_name [[ -s "$archive" ]] || die 'release archive is empty' [[ -n "$checksum" ]] || die 'SHA-256 checksum is required (use --sha256-url)' archive_name=$(basename -- "$archive") expected=$(awk -v name="$archive_name" 'NF >= 2 { candidate=$2; sub(/^\*/, "", candidate); if (candidate == name || candidate == "./" name) { print $1; exit } }' "$checksum") [[ -n "$expected" ]] || die "checksum file has no entry for $archive_name" [[ "$expected" =~ ^[A-Fa-f0-9]{64}$ ]] || die 'checksum file does not contain a SHA-256 digest' printf '%s %s\n' "$expected" "$archive" | sha256sum -c - >/dev/null || die 'SHA-256 verification failed' if [[ "$REQUIRE_SIGNATURE" == true || ( -n "$signature" && -n "$key" ) ]]; then [[ -n "$signature" && -s "$signature" ]] || die '发布包缺少签名文件(SHA256SUMS.sig 或 .asc)' [[ -n "$key" && -f "$key" && ! -L "$key" ]] || die '签名校验需要有效的公钥文件(--signing-key FILE)' [[ "$(stat_uid "$key")" == 0 ]] || die '更新公钥必须由 root 拥有' [[ "$(wc -c < "$key" | tr -d '[:space:]')" -le 16384 ]] || die '更新公钥文件过大' local key_bits key_bits=$(stat_mode_bits "$key") (( (key_bits & 18) == 0 )) || die '更新公钥不能被组或其他用户写入' if [[ "$SIGNATURE_FORMAT" == gpg ]]; then command -v gpg >/dev/null 2>&1 || die 'gpg is required for --signature-format gpg' local gpg_home gpg_home=$(mktemp -d) if ! ( set -Eeuo pipefail trap 'rm -rf -- "$gpg_home"' EXIT chmod 700 "$gpg_home" gpg --batch --homedir "$gpg_home" --import "$key" >/dev/null 2>&1 gpg --batch --homedir "$gpg_home" --no-auto-key-retrieve --verify "$signature" "$archive" >/dev/null 2>&1 ); then rm -rf -- "$gpg_home" die 'release GPG signature verification failed' fi rm -rf -- "$gpg_home" else "$OPENSSL_BIN" pkey -pubin -in "$key" -noout >/dev/null 2>&1 || die '更新公钥不是有效的 Ed25519 公钥' if ! "$OPENSSL_BIN" pkeyutl -verify -pubin -inkey "$key" -rawin -in "$checksum" -sigfile "$signature" >/dev/null 2>&1; then # Accept a base64-encoded detached signature as a convenience for # operators, while the release workflow emits the safer raw 64 bytes. local decoded decoded=$(mktemp) if ! "$OPENSSL_BIN" base64 -d -A -in "$signature" -out "$decoded" >/dev/null 2>&1 \ || ! "$OPENSSL_BIN" pkeyutl -verify -pubin -inkey "$key" -rawin -in "$checksum" -sigfile "$decoded" >/dev/null 2>&1; then rm -f -- "$decoded" die 'SHA256SUMS 签名校验失败' fi rm -f -- "$decoded" fi fi elif [[ -n "$signature" || -n "$key" ]]; then log 'warning: signature verification skipped; provide both a signature and public key, or enable TALLYNOTE_INSTALL_REQUIRE_SIGNATURE=true' fi } safe_extract() { local archive=$1 dest=$2 entry listing stats count expanded local max_archive_bytes=$((MAX_RELEASE_MB * 1024 * 1024)) local max_extract_bytes=$((MAX_EXTRACT_MB * 1024 * 1024)) local archive_bytes archive_bytes=$(wc -c < "$archive" | tr -d '[:space:]') [[ "$archive_bytes" =~ ^[0-9]+$ && "$archive_bytes" -le "$max_archive_bytes" ]] || die 'release archive exceeds the compressed size limit' # Only regular files and directories are accepted. Device nodes, FIFOs, # sockets, symlinks and hardlinks must never be materialised as root. listing=$(mktemp) if ! LC_ALL=C tar -tvzf "$archive" --numeric-owner > "$listing" 2>/dev/null; then rm -f -- "$listing" die 'release archive is not a valid tar.gz file' fi stats=$(LC_ALL=C awk -v limit="$max_extract_bytes" -v max_entries="$MAX_ARCHIVE_ENTRIES" ' $1 !~ /^[-d]/ { bad=1; exit 3 } { entry_size = 0; for (i = 2; i <= NF; i++) { if ($i ~ /^[0-9]+$/) entry_size = $i + 0; if ($i ~ /^(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)$/) break; } count += 1; size += ($1 ~ /^-/ ? entry_size : 0); if (count > max_entries || size > limit) exit 2 } END { if (bad) exit 3; printf "%d %d\n", count, size } ' "$listing") || { rm -f -- "$listing"; die 'release archive contains too many entries or unsupported special files'; } count=${stats%% *}; expanded=${stats##* } [[ "$count" =~ ^[0-9]+$ && "$expanded" =~ ^[0-9]+$ ]] || { rm -f -- "$listing"; die 'release archive metadata is invalid'; } while IFS= read -r entry; do if [[ "$entry" == /* || "$entry" == ../* || "$entry" == */../* || "$entry" == .. || "$entry" == */.. ]]; then rm -f -- "$listing" die "unsafe archive path: $entry" fi done < <(LC_ALL=C tar -tzf "$archive") rm -f -- "$listing" mkdir -p "$dest" chmod 700 "$dest" LC_ALL=C tar -xzf "$archive" -C "$dest" --no-same-owner --no-same-permissions } normalize_release_tree() { local root=$1 item relative [[ -d "$root" && ! -L "$root" ]] || die 'release extraction directory is invalid' if find "$root" -type l -print -quit | grep -q .; then die 'release archive contains a symbolic link' fi if find "$root" ! -type d ! -type f ! -type l -print -quit | grep -q .; then die 'release archive contains an unsupported file type' fi find "$root" -type d -exec chmod 755 {} + find "$root" -type f -exec chmod 644 {} + for item in "$root/bin"/* "$root/scripts"/*.sh "$root/runtime/bin"/* "$root/uninstall.sh"; do [[ -f "$item" && ! -L "$item" ]] || continue chmod 755 "$item" done } stat_uid() { stat -c '%u' "$1" 2>/dev/null || stat -f '%u' "$1"; } stat_mode() { stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1"; } stat_mode_bits() { local mode mode=$(stat_mode "$1") [[ "$mode" =~ ^[0-7]+$ ]] || die "无法读取路径权限:$1" printf '%d' "$((8#$mode))" } validate_trusted_tool() { local configured=$1 label=$2 resolved uid mode_bits [[ -n "$configured" && "$configured" != *[[:space:]]* && "$configured" != *[[:cntrl:]]* ]] || die "$label 路径无效" resolved=$(command -v "$configured" 2>/dev/null || true) [[ -n "$resolved" && -x "$resolved" && ! -L "$resolved" ]] || die "$label 必须指向可信可执行文件" if (( EUID == 0 )); then uid=$(stat_uid "$resolved") mode_bits=$(stat_mode_bits "$resolved") [[ "$uid" == 0 && $((mode_bits & 18)) -eq 0 ]] || die "$label 必须由 root 拥有且不可被其他用户写入" fi } version_is_newer() { local candidate=$1 current=$2 ordered candidate_core current_core [[ "$candidate" != "$current" ]] || return 1 candidate_core=${candidate%%+*} current_core=${current%%+*} [[ "$candidate_core" != "$current_core" ]] || return 1 if sort -V /dev/null 2>&1; then ordered=$(printf '%s\n' "$current" "$candidate" | sort -V | tail -n 1) [[ "$ordered" == "$candidate" ]] return fi # Linux installs use GNU sort -V; this conservative fallback compares the # numeric core and treats a stable release as newer than its prerelease. local c_core=${candidate%%[-+]*} v_core=${current%%[-+]*} local c_pre='' v_pre='' [[ "$candidate" == *-* ]] && c_pre=${candidate#*-} [[ "$current" == *-* ]] && v_pre=${current#*-} local c_major c_minor c_patch v_major v_minor v_patch IFS='.' read -r c_major c_minor c_patch <<< "$c_core" IFS='.' read -r v_major v_minor v_patch <<< "$v_core" local pair left right for pair in "$c_major $v_major" "$c_minor $v_minor" "$c_patch $v_patch"; do read -r left right <<< "$pair" if (( 10#$left != 10#$right )); then (( 10#$left > 10#$right )); return; fi done [[ -z "$c_pre" && -n "$v_pre" ]] && return 0 [[ -n "$c_pre" && -z "$v_pre" ]] && return 1 [[ "$candidate" > "$current" ]] } assert_path_chain() { local target=$1 allowed_uid=${2:-0} current component relative uid mode_bits [[ "$target" = /* && "$target" != *$'\n'* && "$target" != *$'\r'* ]] || die "路径必须是绝对路径:$target" relative=${target#/} current=/ IFS='/' read -r -a _path_parts <<< "$relative" for component in "${_path_parts[@]}"; do [[ -n "$component" && "$component" != . && "$component" != .. ]] || continue current="${current%/}/$component" if [[ -L "$current" ]]; then die "路径不能包含符号链接:$current"; fi if [[ -e "$current" ]]; then [[ -d "$current" ]] || die "路径不是目录:$current" uid=$(stat_uid "$current") [[ "$uid" == 0 || "$uid" == "$allowed_uid" ]] || die "路径目录必须由 root 拥有:$current" mode_bits=$(stat_mode_bits "$current") # A root-owned sticky directory (for example a hardened /tmp) is fine, # but ownership is always required before traversing an existing parent. (( (mode_bits & 18) == 0 || (mode_bits & 512) != 0 )) || die "路径目录权限过宽:$current" else mkdir "$current" chmod 700 "$current" fi done } ensure_root_directory() { local directory=$1 mode=${2:-755} uid mode_bits assert_path_chain "$directory" [[ -d "$directory" && ! -L "$directory" ]] || die "安装目录无效:$directory" uid=$(stat_uid "$directory") [[ "$uid" == 0 ]] || die "安装目录必须由 root 拥有:$directory" mode_bits=$(stat_mode_bits "$directory") (( (mode_bits & 18) == 0 )) || die "安装目录不能被组或其他用户写入:$directory" chmod "$mode" "$directory" chown root:root "$directory" } ensure_data_directory() { local directory=$1 owner_uid mode_bits owner_uid=$(id -u tallynote) # The service owns its private data tree. Permit that one explicit owner # while keeping every installation/configuration path root-owned. assert_path_chain "$directory" "$owner_uid" [[ -d "$directory" && ! -L "$directory" ]] || die "数据目录无效:$directory" mode_bits=$(stat_mode_bits "$directory") (( (mode_bits & 18) == 0 )) || die "数据目录不能被组或其他用户写入:$directory" # A root-owned directory from an earlier manual install is safe to adopt; # an unrelated non-root owner is not. local current_uid current_uid=$(stat_uid "$directory") [[ "$current_uid" == 0 || "$current_uid" == "$owner_uid" ]] || die "数据目录由不受信用户拥有:$directory" DATA_DIR_ORIGINAL_OWNER=$(stat -c '%u:%g' "$directory" 2>/dev/null || stat -f '%u:%g' "$directory") # Temporarily make the parent root-owned while its children are checked and # repaired. This prevents the service account from swapping a checked child # for a symlink between the lstat and the privileged chown/chmod calls. chown root:root "$directory" chmod 700 "$directory" DATA_DIR_TEMP_ROOT=1 for child in files staging exports; do local child_path="$directory/$child" assert_path_chain "$child_path" "$owner_uid" [[ -d "$child_path" && ! -L "$child_path" ]] || die "数据子目录无效:$child_path" chown tallynote:tallynote "$child_path" chmod 700 "$child_path" done chown tallynote:tallynote "$directory" chmod 700 "$directory" DATA_DIR_TEMP_ROOT=0 } stop_existing_services() { command -v systemctl >/dev/null 2>&1 || return 0 local unit # Stop the path trigger first so it cannot launch the privileged updater while # the data tree is being repaired. for unit in tallynote-update.path tallynote-update.service tallynote.service; do case "$unit" in tallynote.service) if systemctl is-enabled --quiet "$unit"; then INSTALL_WAS_ENABLED=1; fi ;; tallynote-update.path) if systemctl is-enabled --quiet "$unit"; then INSTALL_PATH_WAS_ENABLED=1; fi ;; tallynote-update.service) if systemctl is-enabled --quiet "$unit"; then INSTALL_UPDATE_WAS_ENABLED=1; fi ;; esac if systemctl is-active --quiet "$unit"; then case "$unit" in tallynote.service) INSTALL_WAS_ACTIVE=1 ;; tallynote-update.path) INSTALL_PATH_WAS_ACTIVE=1 ;; tallynote-update.service) INSTALL_UPDATE_WAS_ACTIVE=1 ;; esac systemctl stop "$unit" || die "无法停止现有服务:$unit" fi done } rollback_install_if_needed() { local result=$? rollback_tmp if (( INSTALL_COMMITTED == 0 && INSTALL_SYSTEMD_TOUCHED == 1 )) && command -v systemctl >/dev/null 2>&1; then # The failed install may have started units that were inactive before the # attempt. Stop them before restoring files so systemd never keeps running # code from a release directory that rollback is about to remove. for unit in tallynote-update.path tallynote-update.service tallynote.service; do systemctl stop "$unit" >/dev/null 2>&1 || true done if (( INSTALL_WAS_ENABLED == 0 )); then systemctl disable tallynote.service >/dev/null 2>&1 || true; fi if (( INSTALL_PATH_WAS_ENABLED == 0 )); then systemctl disable tallynote-update.path >/dev/null 2>&1 || true; fi if (( INSTALL_UPDATE_WAS_ENABLED == 0 )); then systemctl disable tallynote-update.service >/dev/null 2>&1 || true; fi fi if (( INSTALL_SWITCHED == 1 && INSTALL_COMMITTED == 0 )); then if [[ -n "$INSTALL_PREVIOUS_TARGET" && -d "$INSTALL_PREVIOUS_TARGET" ]]; then rollback_tmp="$PREFIX/.current-rollback-$$-${RANDOM}.tmp" if [[ ! -e "$rollback_tmp" ]] && ln -s -- "$INSTALL_PREVIOUS_TARGET" "$rollback_tmp" && mv -Tf -- "$rollback_tmp" "$PREFIX/current"; then : else rm -f -- "$rollback_tmp" 2>/dev/null || true fi else rm -f -- "$PREFIX/current" 2>/dev/null || true fi if [[ -n "$INSTALL_NEW_RELEASE" && -d "$INSTALL_NEW_RELEASE" ]]; then rm -rf -- "$INSTALL_NEW_RELEASE" 2>/dev/null || true fi fi if (( DATA_DIR_TEMP_ROOT == 1 )) && [[ -n "$DATA_DIR_ORIGINAL_OWNER" && -d "$DATA_DIR" && ! -L "$DATA_DIR" ]]; then chown -- "$DATA_DIR_ORIGINAL_OWNER" "$DATA_DIR" 2>/dev/null || true chmod 700 "$DATA_DIR" 2>/dev/null || true DATA_DIR_TEMP_ROOT=0 fi 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-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 if [[ -f "$INSTALL_BACKUP_DIR/$backup_name" ]]; then cp -a -- "$INSTALL_BACKUP_DIR/$backup_name" "$target" 2>/dev/null || true else rm -f -- "$target" 2>/dev/null || true fi done fi if command -v systemctl >/dev/null 2>&1; then if (( INSTALL_SYSTEMD_TOUCHED == 1 )); then systemctl daemon-reload >/dev/null 2>&1 || true; fi if (( INSTALL_WAS_ACTIVE == 1 )); then systemctl start tallynote.service 2>/dev/null || true; fi if (( INSTALL_UPDATE_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.service 2>/dev/null || true; fi if (( INSTALL_PATH_WAS_ACTIVE == 1 )); then systemctl start tallynote-update.path 2>/dev/null || true; fi fi if [[ -n "$INSTALL_WORK_DIR" && -d "$INSTALL_WORK_DIR" ]]; then rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true fi return "$result" } backup_install_files() { local directory=$1 target name mkdir -p "$directory" chmod 700 "$directory" # 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 "现有安装文件不是普通文件:$target" fi done 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 cp -a -- "$target" "$directory/$name" || die "无法备份现有安装文件:$target" [[ -f "$directory/$name" ]] || die "现有安装文件备份不完整:$target" fi done INSTALL_BACKUP_COMPLETE=1 } read_env_value() { local file=$1 key=$2 sed -n "s/^${key}=//p" "$file" | head -n 1 } env_key_count() { local file=$1 key=$2 awk -v key="$key" 'index($0, key "=") == 1 { count += 1 } END { print count + 0 }' "$file" } validate_env_value() { local value=$1 label=$2 [[ "$value" != *[[:cntrl:]]* ]] || die "$label 不能包含控制字符" [[ ${#value} -le 4096 ]] || die "$label 过长" } validate_listen_host() { local value=$1 label=${2:-监听地址} validate_env_value "$value" "$label" if [[ "$value" == *:* ]]; then [[ "$value" =~ ^[0-9A-Fa-f:]+$ ]] || die "$label 必须是有效的 IPv6 地址或主机名" elif [[ "$value" =~ ^[0-9.]+$ ]]; then [[ "$value" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] || die "$label 必须是有效的 IPv4 地址或主机名" local octet IFS='.' read -r -a _host_octets <<< "$value" for octet in "${_host_octets[@]}"; do (( 10#$octet <= 255 )) || die "$label 必须是有效的 IPv4 地址或主机名" done else [[ "$value" =~ ^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$ ]] || die "$label 必须是有效的 IPv4、IPv6 地址或主机名" [[ "$value" != *..* && "$value" != *.-* && "$value" != *-.* ]] || die "$label 包含不受支持的主机名" fi } validate_listen_port() { local value=$1 label=${2:-监听端口} [[ "$value" =~ ^[1-9][0-9]*$ && "$value" -le 65535 ]] || die "$label 必须是 1-65535 的整数" } validate_public_origin() { # Keep the optional origin port defined under `set -u`. Origins without an # explicit port (for example https://example.test) are valid and should # proceed to the default-port handling below. local value=$1 authority host path_part origin_port='' suffix case "$value" in http://*|https://*) ;; *) die '公开访问地址必须是 http:// 或 https:// 地址' ;; esac [[ "$value" != *[[:space:]]* && "$value" != *[[:cntrl:]]* && "$value" != *'@'* && "$value" != *'?'* && "$value" != *'#'* ]] || die '公开访问地址包含不受支持的字符' authority=${value#*://} authority=${authority%%/*} [[ -n "$authority" ]] || die '公开访问地址缺少主机名' if [[ "$authority" == \[*\]* ]]; then host=${authority#\[}; host=${host%%\]*} suffix=${authority#*\]} if [[ -n "$suffix" ]]; then [[ "$suffix" =~ ^:([0-9]+)$ ]] || die '公开访问地址端口无效' origin_port=${BASH_REMATCH[1]} fi else if [[ "$authority" == *:* ]]; then [[ "$authority" =~ ^([^:]+):([0-9]+)$ ]] || die '公开访问地址端口无效' host=${BASH_REMATCH[1]} origin_port=${BASH_REMATCH[2]} else host=$authority fi fi [[ -n "$host" ]] || die '公开访问地址缺少主机名' [[ "$host" != 0.0.0.0 && "$host" != :: && "$host" != \* ]] || die '公开访问地址不能使用通配监听地址,请填写服务器 IP 或域名' validate_listen_host "$host" '公开访问地址主机' if [[ -n "$origin_port" ]]; then [[ "$origin_port" =~ ^[0-9]{1,5}$ && "$origin_port" -ge 1 && "$origin_port" -le 65535 ]] || die '公开访问地址端口必须是 1-65535 的整数' fi path_part=${value#*://} path_part=${path_part#"$authority"} [[ -z "$path_part" || "$path_part" == "/" ]] || die '公开访问地址不能包含路径' } validate_semver() { local value=$1 prerelease part [[ "$value" =~ ^v?(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$ ]] || return 1 [[ "$value" == *-* ]] || return 0 prerelease=${value#*-} prerelease=${prerelease%%+*} IFS='.' read -r -a _prerelease_parts <<< "$prerelease" for part in "${_prerelease_parts[@]}"; do [[ ! "$part" =~ ^0[0-9]+$ ]] || return 1 done } validate_install_path() { local value=$1 label=$2 [[ "$value" = /* && "$value" != *$'\n'* && "$value" != *$'\r'* ]] || die "$label 必须是绝对路径" [[ "$value" =~ ^/[A-Za-z0-9._/-]+$ && "$value" != *"/../"* && "$value" != */.. && "$value" != *"//"* ]] || die "$label 包含不受支持的路径字符" } validate_existing_env() { local file=$1 value metadata_host host port origin allow_insecure cookie_secure [[ ! -L "$file" && -f "$file" ]] || die '现有环境文件不是普通文件' [[ "$(stat_uid "$file")" == 0 ]] || die '现有环境文件必须由 root 拥有' local mode_bits mode_bits=$(stat_mode_bits "$file") (( (mode_bits & 18) == 0 )) || die '环境文件不能被组或其他用户写入' local key key_count for key in TALLYNOTE_INSTALL_PREFIX TALLYNOTE_DATA_DIR TALLYNOTE_HOST TALLYNOTE_PORT TALLYNOTE_PUBLIC_ORIGIN TALLYNOTE_ALLOWED_ORIGINS TALLYNOTE_ALLOW_INSECURE_HTTP TALLYNOTE_COOKIE_SECURE TALLYNOTE_UPDATE_REQUIRE_SIGNATURE TALLYNOTE_UPDATE_METADATA_URL TALLYNOTE_UPDATE_ALLOWED_HOSTS TALLYNOTE_UPDATE_PUBLIC_KEY_FILE; do key_count=$(env_key_count "$file" "$key") [[ "$key_count" =~ ^[0-9]+$ && "$key_count" -le 1 ]] || die "环境文件包含重复配置:$key" done value=$(read_env_value "$file" TALLYNOTE_INSTALL_PREFIX) [[ -z "$value" || "${value%/}" == "${PREFIX%/}" ]] || die '环境文件中的安装目录与本次安装不一致' value=$(read_env_value "$file" TALLYNOTE_DATA_DIR) [[ -z "$value" || "${value%/}" == "${DATA_DIR%/}" ]] || die '环境文件中的数据目录与本次安装不一致' value=$(read_env_value "$file" TALLYNOTE_UPDATE_REQUIRE_SIGNATURE) [[ -z "$value" || "$value" == true || "$value" == false ]] || die '环境文件中的签名校验配置必须是 true 或 false' if (( $(env_key_count "$file" TALLYNOTE_HOST) )); then host=$(read_env_value "$file" TALLYNOTE_HOST) validate_listen_host "$host" '环境文件中的监听地址' else host=127.0.0.1 fi if (( $(env_key_count "$file" TALLYNOTE_PORT) )); then port=$(read_env_value "$file" TALLYNOTE_PORT) validate_listen_port "$port" '环境文件中的监听端口' else port=3000 fi if (( $(env_key_count "$file" TALLYNOTE_ALLOW_INSECURE_HTTP) )); then allow_insecure=$(read_env_value "$file" TALLYNOTE_ALLOW_INSECURE_HTTP) [[ "$allow_insecure" == true || "$allow_insecure" == false ]] || die '环境文件中的公网 HTTP 开关必须是 true 或 false' else allow_insecure=false fi if (( $(env_key_count "$file" TALLYNOTE_COOKIE_SECURE) )); then cookie_secure=$(read_env_value "$file" TALLYNOTE_COOKIE_SECURE) [[ "$cookie_secure" == true || "$cookie_secure" == false ]] || die '环境文件中的安全 Cookie 配置必须是 true 或 false' else cookie_secure='' fi if (( $(env_key_count "$file" TALLYNOTE_PUBLIC_ORIGIN) )); then origin=$(read_env_value "$file" TALLYNOTE_PUBLIC_ORIGIN) validate_env_value "$origin" '环境文件中的公开访问地址' else local origin_host=$host [[ "$origin_host" == *:* && "$origin_host" != \[* ]] && origin_host="[$origin_host]" origin="http://${origin_host}:${port}" fi validate_public_origin "$origin" local origin_host_for_policy=${origin#*://} if [[ "$origin_host_for_policy" == \[*\]* ]]; then origin_host_for_policy=${origin_host_for_policy#\[} origin_host_for_policy=${origin_host_for_policy%%\]*} else origin_host_for_policy=${origin_host_for_policy%%:*} fi if [[ "$origin" == http://* && "$allow_insecure" != true ]]; then case "$origin_host_for_policy" in 127.0.0.1|localhost|::1) ;; *) die '环境文件中的公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;; esac fi if [[ "$origin" == http://* && "$cookie_secure" == true ]]; then case "$origin_host_for_policy" in 127.0.0.1|localhost|::1) ;; *) die '环境文件中的公网 HTTP 公开地址不能启用安全 Cookie' ;; esac fi if [[ "$origin" == https://* && "$cookie_secure" == false ]]; then die '环境文件中的 HTTPS 公开地址必须启用安全 Cookie' fi value=$(read_env_value "$file" TALLYNOTE_UPDATE_METADATA_URL) if [[ -n "$value" ]]; then validate_env_value "$value" '环境文件更新源' metadata_host=$(url_host "$value") assert_allowed_url "$value" [[ -n "$metadata_host" ]] || die '环境文件更新源无效' fi } install_release() { local archive=$1 version=$2 tmp release_dir current_tmp='' tmp=$(mktemp -d) # RETURN traps survive the function that installs them. Clear the trap from # inside its first invocation so a later function cannot evaluate the local # temporary path after it has gone out of scope under `set -u`. trap 'trap - RETURN; if [[ -n "${tmp-}" ]]; then rm -rf -- "$tmp" 2>/dev/null || true; fi; if [[ -n "${current_tmp-}" ]]; then rm -f -- "$current_tmp" 2>/dev/null || true; fi' RETURN safe_extract "$archive" "$tmp/unpacked" 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/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" && -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 release_dir="$PREFIX/releases/$version" [[ ! -e "$release_dir" ]] || die "release already exists: $release_dir" if [[ -L "$PREFIX/current" ]]; then current_target=$(readlink -f -- "$PREFIX/current") [[ "$current_target" == "$PREFIX/releases/"* && -d "$current_target" ]] || die 'current 符号链接指向安装目录之外' INSTALL_PREVIOUS_TARGET=$current_target elif [[ -e "$PREFIX/current" ]]; then die "$PREFIX/current exists and is not a symlink" fi mv "$tmp/unpacked" "$release_dir" INSTALL_NEW_RELEASE=$release_dir chown -R root:root "$release_dir" chmod 755 "$release_dir" current_tmp="$PREFIX/.current.$$.tmp" ln -s "$release_dir" "$current_tmp" mv -Tf "$current_tmp" "$PREFIX/current" INSTALL_SWITCHED=1 } prune_releases() { local current_target current_name version kept=0 current_target=$(readlink -f -- "$PREFIX/current" 2>/dev/null || true) current_name=$(basename -- "$current_target") [[ "$current_name" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$ ]] || return 0 mapfile -t versions < <( find "$PREFIX/releases" -mindepth 1 -maxdepth 1 -type d -printf '%f\n' \ | awk '/^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$/' \ | version_sort_desc ) # KEEP_RELEASES counts the active release. Always retain current even when # a distro's version sort has unusual prerelease ordering. for version in "${versions[@]}"; do if [[ "$version" == "$current_name" ]]; then kept=$((kept + 1)) continue fi if (( kept < KEEP_RELEASES )); then kept=$((kept + 1)) else rm -rf -- "$PREFIX/releases/$version" fi done } main() { stage '检查运行环境、权限和目标架构' # These variables are useful for isolated tests, but a root install must # never execute an untrusted PATH entry supplied through sudo's environment. if (( APPLY )) || [[ -n "${TALLYNOTE_UNAME_BIN+x}" ]]; then validate_trusted_tool "$UNAME_BIN" 'uname' fi if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" || -n "$UPDATE_PUBLIC_KEY_FILE" || -n "${TALLYNOTE_OPENSSL_BIN+x}" ]]; then validate_trusted_tool "$OPENSSL_BIN" 'openssl' fi detect_platform configure_network_interactively validate_listen_host "$INSTALL_HOST" validate_listen_port "$INSTALL_PORT" if (( APPLY && NETWORK_INTERACTIVE )); then check_requested_port "$INSTALL_PORT" fi if [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" && -z "$INSTALL_PUBLIC_ORIGIN" ]]; then die 'TALLYNOTE_PUBLIC_ORIGIN 不能是空值;省略该变量以使用默认 Origin' fi [[ "$INSTALL_ALLOW_INSECURE_HTTP" == true || "$INSTALL_ALLOW_INSECURE_HTTP" == false ]] || die 'TALLYNOTE_ALLOW_INSECURE_HTTP 必须是 true 或 false' if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then validate_env_value "$INSTALL_PUBLIC_ORIGIN" '公开访问地址' validate_public_origin "$INSTALL_PUBLIC_ORIGIN" if [[ "$INSTALL_PUBLIC_ORIGIN" == http://* && "$INSTALL_ALLOW_INSECURE_HTTP" != true ]]; then public_host=${INSTALL_PUBLIC_ORIGIN#http://} if [[ "$public_host" == \[*\]* ]]; then public_host=${public_host#\[} public_host=${public_host%%\]*} else public_host=${public_host%%:*} fi case "$public_host" in 127.0.0.1|localhost|::1) ;; *) die '公网 HTTP 访问必须显式设置 TALLYNOTE_ALLOW_INSECURE_HTTP=true' ;; esac fi elif [[ "$INSTALL_HOST" != 127.0.0.1 && "$INSTALL_HOST" != localhost && "$INSTALL_HOST" != ::1 ]]; then die '监听非本机地址时必须提供 TALLYNOTE_PUBLIC_ORIGIN(例如 http://服务器IP:3000)' fi [[ "$KEEP_RELEASES" =~ ^[1-9][0-9]*$ ]] || die '--keep-releases must be a positive integer' validate_install_path "$PREFIX" '安装目录' validate_install_path "$DATA_DIR" '数据目录' validate_install_path "$CONFIG_DIR" '配置目录' validate_env_value "$REPOSITORY_URL" '仓库地址' validate_env_value "$RELEASE_API_URL" 'Release API 地址' validate_env_value "$RELEASE_BASE_URL" 'Release 地址' validate_allowed_hosts # Bind every network request to the configured release service before any # redirect is followed. A CDN can be added explicitly through # TALLYNOTE_RELEASE_ALLOWED_HOSTS when the operator has reviewed it. append_allowed_host "$(url_host "$RELEASE_API_URL")" append_allowed_host "$(url_host "$REPOSITORY_URL")" stage_done "运行环境可用:${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}" if [[ "$VERSION" == "latest" ]]; then if (( ! APPLY )); then [[ -z "$RELEASE_BASE_URL" ]] || require_https "$RELEASE_BASE_URL" stage '预览最新版本解析(dry-run 不访问 Release)' log 'version: latest (release lookup skipped in dry-run)' log 'dry-run: pass --version VERSION to preview an exact artifact' stage_done 'dry-run 预览完成:不会下载、解包或修改 systemd' return 0 fi stage '从 Release API 获取最新版本' resolve_latest_version stage_done "已解析最新版本:${VERSION#v}" else stage "使用指定版本:${VERSION#v}" fi validate_semver "$VERSION" || die 'version must be a semantic version (for example 1.2.3)' VERSION=${VERSION#v} if [[ -L "$PREFIX/current" ]]; then current_target=$(readlink -f -- "$PREFIX/current" 2>/dev/null || true) current_version=$(basename -- "$current_target") if validate_semver "$current_version" >/dev/null 2>&1 && [[ "$ALLOW_DOWNGRADE" != true ]] && ! version_is_newer "$VERSION" "$current_version"; then die "拒绝安装不高于当前版本的 release:当前 $current_version,候选 $VERSION(如确需降级请使用 --allow-downgrade)" fi fi stage '准备 Release 下载地址和发布包' release_urls local artifact archive checksum signature artifact_url work release_dir artifact=${RELEASE_FILE:+$(basename -- "$RELEASE_FILE")} artifact=${artifact:-tallynote-${VERSION}-linux-${TALLYNOTE_ARCH}-${TALLYNOTE_LIBC}.tar.gz} [[ "$artifact" =~ ^[A-Za-z0-9][A-Za-z0-9._+\-]*\.(tar\.gz|tgz|tar)$ ]] || die 'release 文件名无效' artifact_url="$RELEASE_BASE_URL/$artifact" stage_done 'Release 下载地址已准备' log "platform: ${TALLYNOTE_ARCH}/${TALLYNOTE_LIBC}; release: ${VERSION#v}" log "layout: $PREFIX/releases + atomic $PREFIX/current; data: $DATA_DIR" if (( ! APPLY )); then log 'dry-run: no download, extraction, or systemd changes' stage_done 'dry-run 预览完成:不会下载、解包或修改 systemd' return 0 fi [[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行' [[ $EUID -eq 0 ]] || die '安装必须以 root 运行' for command_name in curl sha256sum tar install sed awk find systemctl; do command -v "$command_name" >/dev/null 2>&1 || die "$command_name is required" done if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" || -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required when signature verification is enabled' fi work=$(mktemp -d) INSTALL_WORK_DIR=$work INSTALL_BACKUP_DIR="$work/original" trap rollback_install_if_needed EXIT archive="$work/$artifact" stage "获取发布包:$artifact" if [[ -n "$RELEASE_FILE" && -f "$RELEASE_FILE" && ! -L "$RELEASE_FILE" ]]; then cp -- "$RELEASE_FILE" "$archive" chmod 600 "$archive" [[ "$(wc -c < "$archive" | tr -d '[:space:]')" -le $((MAX_RELEASE_MB * 1024 * 1024)) ]] || die '本地 release 文件超过大小限制' else [[ -z "$RELEASE_FILE" ]] || die '本地 release 文件不存在或是符号链接' download "$artifact_url" "$archive" fi stage_done '发布包已下载并通过大小限制' checksum="$work/SHA256SUMS" SHA256_URL=${SHA256_URL:-$RELEASE_BASE_URL/SHA256SUMS} stage '获取 SHA-256 校验清单' if [[ -n "$SHA256_FILE" && -f "$SHA256_FILE" && ! -L "$SHA256_FILE" ]]; then cp -- "$SHA256_FILE" "$checksum" chmod 600 "$checksum" [[ "$(wc -c < "$checksum" | tr -d '[:space:]')" -le $((2 * 1024 * 1024)) ]] || die '本地 SHA256SUMS 文件过大' else [[ -z "$SHA256_FILE" ]] || die '本地 SHA256SUMS 文件不存在或是符号链接' download "$SHA256_URL" "$checksum" $((2 * 1024 * 1024)) fi stage_done 'SHA-256 校验清单已准备' SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE} signature='' if [[ "$REQUIRE_SIGNATURE" == true || -n "$SIGNATURE_URL" || -n "$SIGNING_KEY" ]]; then stage '获取发布签名' if [[ "$SIGNATURE_FORMAT" == gpg ]]; then SIGNATURE_URL=${SIGNATURE_URL:-$RELEASE_BASE_URL/$artifact.asc} signature="$work/$artifact.asc" else SIGNATURE_URL=${SIGNATURE_URL:-$RELEASE_BASE_URL/SHA256SUMS.sig} signature="$work/SHA256SUMS.sig" fi download "$SIGNATURE_URL" "$signature" $((64 * 1024)) stage_done '发布签名已准备' fi stage '校验 SHA-256 和发布签名' verify_archive "$archive" "$checksum" "$signature" "$SIGNING_KEY" 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" stop_existing_services ensure_root_directory "$PREFIX" 755 ensure_root_directory "$PREFIX/releases" 755 ensure_root_directory "$PREFIX/.update-work" 700 ensure_root_directory "$CONFIG_DIR" 755 ensure_data_directory "$DATA_DIR" if [[ -e "$CONFIG_DIR/tallynote.env" ]]; then validate_existing_env "$CONFIG_DIR/tallynote.env" fi # During upgrades, the existing environment remains authoritative unless a # new port was explicitly supplied. Check the effective listener port after # stopping the old service so an unrelated process cannot claim it. local effective_port=$INSTALL_PORT if [[ -z "${TALLYNOTE_PORT+x}" && -f "$CONFIG_DIR/tallynote.env" ]]; then effective_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true) effective_port=${effective_port:-3000} fi check_requested_port "$effective_port" stage_done '目录、权限和旧服务状态已准备' stage "解包、校验包结构并原子切换到版本 ${VERSION#v}" install_release "$archive" "$VERSION" 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" && -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/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#/opt/tallynote#$PREFIX#g; 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 install -o root -g root -m 755 "$release_dir/uninstall.sh" /usr/local/sbin/tallynote-uninstall ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700 local env_created=0 if [[ ! -f "$CONFIG_DIR/tallynote.env" ]]; then sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.env.example" > "$CONFIG_DIR/tallynote.env" chown root:root "$CONFIG_DIR/tallynote.env" chmod 640 "$CONFIG_DIR/tallynote.env" env_created=1 fi ensure_env_key() { local key=$1 value=$2 [[ "$key" =~ ^[A-Z0-9_]+$ ]] || die '环境变量名无效' validate_env_value "$value" "$key" if ! grep -qE "^${key}=" "$CONFIG_DIR/tallynote.env"; then if [[ -s "$CONFIG_DIR/tallynote.env" && "$(tail -c 1 "$CONFIG_DIR/tallynote.env")" != $'\n' ]]; then printf '\n' >> "$CONFIG_DIR/tallynote.env" fi printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env" fi } set_env_key() { local key=$1 value=$2 escaped [[ "$key" =~ ^[A-Z0-9_]+$ ]] || die '环境变量名无效' validate_env_value "$value" "$key" escaped=${value//\\/\\\\} escaped=${escaped//&/\\&} escaped=${escaped//|/\\|} if grep -qE "^${key}=" "$CONFIG_DIR/tallynote.env"; then sed -i "s|^${key}=.*|${key}=${escaped}|" "$CONFIG_DIR/tallynote.env" else if [[ -s "$CONFIG_DIR/tallynote.env" && "$(tail -c 1 "$CONFIG_DIR/tallynote.env")" != $'\n' ]]; then printf '\n' >> "$CONFIG_DIR/tallynote.env" fi printf '%s=%s\n' "$key" "$value" >> "$CONFIG_DIR/tallynote.env" fi } # A fresh install gets the requested network settings. On upgrades, only # explicitly supplied values change the existing administrator config. if (( env_created )) || [[ -n "${TALLYNOTE_HOST+x}" ]]; then set_env_key TALLYNOTE_HOST "$INSTALL_HOST"; fi if (( env_created )) || [[ -n "${TALLYNOTE_PORT+x}" ]]; then set_env_key TALLYNOTE_PORT "$INSTALL_PORT"; fi if (( env_created )); then if [[ -n "$INSTALL_PUBLIC_ORIGIN" ]]; then set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN" elif [[ -n "${TALLYNOTE_HOST+x}" || -n "${TALLYNOTE_PORT+x}" ]]; then local generated_origin_host=$INSTALL_HOST [[ "$generated_origin_host" == *:* && "$generated_origin_host" != \[* ]] && generated_origin_host="[$generated_origin_host]" set_env_key TALLYNOTE_PUBLIC_ORIGIN "http://${generated_origin_host}:${INSTALL_PORT}" fi if [[ "$INSTALL_PUBLIC_ORIGIN" == https://* ]]; then set_env_key TALLYNOTE_COOKIE_SECURE true; fi set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP" elif [[ -n "${TALLYNOTE_PUBLIC_ORIGIN+x}" ]]; then set_env_key TALLYNOTE_PUBLIC_ORIGIN "$INSTALL_PUBLIC_ORIGIN" fi if [[ -n "${TALLYNOTE_ALLOW_INSECURE_HTTP+x}" ]]; then set_env_key TALLYNOTE_ALLOW_INSECURE_HTTP "$INSTALL_ALLOW_INSECURE_HTTP"; fi ensure_env_key TALLYNOTE_INSTALL_PREFIX "$PREFIX" ensure_env_key TALLYNOTE_DATA_DIR "$DATA_DIR" ensure_env_key TALLYNOTE_UPDATE_STRATEGY systemd ensure_env_key TALLYNOTE_UPDATE_METADATA_URL "$RELEASE_API_URL" ensure_env_key TALLYNOTE_UPDATE_ALLOWED_HOSTS "$RELEASE_ALLOWED_HOSTS" # The bootstrap verification key is also the key used by the privileged # updater unless the operator already configured a separate one. UPDATE_PUBLIC_KEY_FILE=${UPDATE_PUBLIC_KEY_FILE:-$SIGNING_KEY} if [[ -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE true else ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE false fi if [[ -n "$UPDATE_PUBLIC_KEY_FILE" ]]; then validate_install_path "$UPDATE_PUBLIC_KEY_FILE" '更新公钥路径' [[ -f "$UPDATE_PUBLIC_KEY_FILE" && ! -L "$UPDATE_PUBLIC_KEY_FILE" ]] || die 'update public key file is invalid' [[ "$(stat_uid "$UPDATE_PUBLIC_KEY_FILE")" == 0 ]] || die 'update public key file must be root-owned' install -o root -g tallynote -m 640 "$UPDATE_PUBLIC_KEY_FILE" "$CONFIG_DIR/update-signing-key.pub" if grep -qE '^TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=' "$CONFIG_DIR/tallynote.env"; then sed -i "s#^TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=.*#TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$CONFIG_DIR/update-signing-key.pub#" "$CONFIG_DIR/tallynote.env" else printf 'TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=%s\n' "$CONFIG_DIR/update-signing-key.pub" >> "$CONFIG_DIR/tallynote.env" fi fi 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 local health_host health_port health_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true) health_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true) health_host=${health_host:-$INSTALL_HOST} health_port=${health_port:-$INSTALL_PORT} stage "检查本机健康接口(${health_host}:${health_port})" if ! wait_for_service_health "$health_host" "$health_port"; then log "本机健康检查失败:http://${health_host}:${health_port}/health" systemctl status tallynote.service --no-pager -l || true if command -v journalctl >/dev/null 2>&1; then journalctl -u tallynote.service -n 30 --no-pager || true fi die 'TallyNote 服务未通过健康检查;安装未完成,请根据上面的 systemd 日志修复后重试' fi stage_done '本机健康检查通过,服务正在监听' if [[ "$health_host" == 127.0.0.1 || "$health_host" == localhost || "$health_host" == ::1 ]]; then log '当前监听仅限本机;公网或其他设备无法直接访问,请重新安装并选择 0.0.0.0,或配置 HTTPS 反向代理' else log '当前监听已绑定非本机地址;若外部仍无法连接,请检查云安全组、主机防火墙和公网 IP/NAT' fi stage_done 'TallyNote 服务已启用并启动' stage '清理旧版本并完成安装' prune_releases stage_done '旧版本清理完成' INSTALL_COMMITTED=1 trap - EXIT 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 configured_host=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_HOST 2>/dev/null || true) configured_port=$(read_env_value "$CONFIG_DIR/tallynote.env" TALLYNOTE_PORT 2>/dev/null || true) access_host=${configured_host:-$INSTALL_HOST} access_port=${configured_port:-$INSTALL_PORT} if [[ "$access_host" == 0.0.0.0 ]]; then access_host=$(detect_public_ipv4 || true) fi [[ -n "$access_host" ]] || access_host=$INSTALL_HOST [[ "$access_host" == *:* && "$access_host" != \[* ]] && access_host="[$access_host]" access_url="http://${access_host}:${access_port}" fi log "访问地址:$access_url" log '查看服务状态:systemctl status tallynote.service' } main "$@"