TallyNote release / linux-x64 (push) Successful in 6m11s
- manual admin password no longer forces first-login change - --generate still requires password change on first login - add --mark-password-configured to repair legacy flag - echo interactive username/password input in SSH terminal - installer prints absolute admin-init path (sudo secure_path compat) - use python3 pty helper for CI tests (no expect on Linux) release: 1.2.9
1545 lines
70 KiB
Bash
Executable File
1545 lines
70 KiB
Bash
Executable File
#!/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 $ADMIN_INIT_PATH"
|
||
return 0
|
||
fi
|
||
[[ -r "$PROMPT_INPUT" && -w "$PROMPT_OUTPUT" ]] || {
|
||
log "未检测到交互式终端:跳过管理员初始化;稍后可执行 sudo $ADMIN_INIT_PATH"
|
||
return 0
|
||
}
|
||
[[ -x "$ADMIN_INIT_PATH" ]] || die '管理员初始化命令未安装'
|
||
|
||
local status choice
|
||
if ! status=$("$ADMIN_INIT_PATH" --check 2>/dev/null); then
|
||
log "无法检查管理员初始化状态;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH"
|
||
return 0
|
||
fi
|
||
[[ "$status" == empty ]] || return 0
|
||
|
||
exec 9<"$PROMPT_INPUT" || die "无法打开终端输入;请稍后执行 sudo $ADMIN_INIT_PATH"
|
||
{
|
||
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 $ADMIN_INIT_PATH"
|
||
return 0
|
||
;;
|
||
*) printf '请输入 yes 或 no。\n' > "$PROMPT_OUTPUT" ;;
|
||
esac
|
||
done
|
||
stage '创建首位管理员(密码不会写入安装日志)'
|
||
if ! "$ADMIN_INIT_PATH" <&9 > "$PROMPT_OUTPUT"; then
|
||
exec 9<&-
|
||
log "管理员初始化未完成;基础安装已完成,稍后可执行 sudo $ADMIN_INIT_PATH"
|
||
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 >/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 >/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'
|
||
log "管理员初始化命令:sudo $ADMIN_INIT_PATH"
|
||
log '如 sudo 找不到该命令,请使用上面输出的绝对路径'
|
||
}
|
||
main "$@"
|