Files
TallyNote/install.sh
T
Qiufeng 0bdc812935
TallyNote release / linux-x64 (push) Failing after 11s
fix: support proxied origins and update progress
2026-09-03 14:54:30 +08:00

1544 lines
70 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 >/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_ALLOWED_ORIGINS+x}" ]]; then set_env_key TALLYNOTE_ALLOWED_ORIGINS "$TALLYNOTE_ALLOWED_ORIGINS"; 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 "$@"