Files
TallyNote/install.sh
T
Qiufeng bac10b6fdf
TallyNote release / linux-x64 (push) Successful in 5m54s
feat: add interactive installer network setup
2026-09-02 06:29:29 +08:00

1234 lines
56 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}
NON_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_WAS_ACTIVE=0
INSTALL_PATH_WAS_ACTIVE=0
INSTALL_UPDATE_WAS_ACTIVE=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. 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}
}
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
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
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
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
while :; do
prompt_value '实际访问地址(例如 http://203.0.113.10:3000 或 https://tallynote.example.com)' ''
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
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_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 )) && [[ -n "$INSTALL_BACKUP_DIR" && -d "$INSTALL_BACKUP_DIR" ]]; then
local backup_name target
for backup_name in tallynote.service tallynote-update.service tallynote-update.path tallynote-uninstall tallynote.env update-signing-key.pub; do
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" ;;
*) 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_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"
for name in tallynote.service tallynote-update.service tallynote-update.path; do
target="/etc/systemd/system/$name"
[[ ! -L "$target" ]] || die "现有 systemd 单元不能是符号链接:$target"
if [[ -e "$target" ]]; then
[[ -f "$target" ]] || die "现有 systemd 单元不是普通文件:$target"
cp -a -- "$target" "$directory/$name"
fi
done
for name in tallynote.env update-signing-key.pub; do
target="$CONFIG_DIR/$name"
[[ ! -L "$target" ]] || die "现有配置不能是符号链接:$target"
if [[ -e "$target" ]]; then
[[ -f "$target" ]] || die "现有配置不是普通文件:$target"
cp -a -- "$target" "$directory/$name"
fi
done
target="/usr/local/sbin/tallynote-uninstall"
[[ ! -L "$target" ]] || die "现有卸载器不能是符号链接:$target"
if [[ -e "$target" ]]; then
[[ -f "$target" ]] || die "现有卸载器不是普通文件:$target"
cp -a -- "$target" "$directory/tallynote-uninstall"
fi
}
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
(( 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() {
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 或域名'
[[ "$host" =~ ^[A-Za-z0-9.-]+$ || "$host" =~ ^[0-9A-Fa-f:]+$ ]] || die '公开访问地址主机名无效'
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_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
origin="http://${host}:${port}"
fi
validate_public_origin "$origin"
local origin_host=${origin#*://}
if [[ "$origin_host" == \[*\]* ]]; then
origin_host=${origin_host#\[}
origin_host=${origin_host%%\]*}
else
origin_host=${origin_host%%:*}
fi
if [[ "$origin" == http://* && "$allow_insecure" != true ]]; then
case "$origin_host" 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" 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)
trap 'rm -rf "$tmp" "$current_tmp" 2>/dev/null || true' 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/web/index.html" ]] || die 'release archive is incomplete'
[[ -f "$tmp/unpacked/systemd/tallynote.service" && -f "$tmp/unpacked/systemd/tallynote-update.service" && -f "$tmp/unpacked/systemd/tallynote-update.path" ]] || die 'release archive is missing systemd units'
[[ -f "$tmp/unpacked/systemd/tallynote.env.example" && -x "$tmp/unpacked/scripts/tallynote-update.sh" && -x "$tmp/unpacked/scripts/tallynote-update-runner.sh" && -x "$tmp/unpacked/uninstall.sh" ]] || die 'release archive is missing update/uninstall support files'
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 [[ -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'
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
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" ]] || die 'release package is missing update/uninstall support files'
stage '安装 systemd 单元、更新辅助程序和卸载器'
install -d -m 755 /usr/local/libexec /etc/systemd/system
local unit_tmp
unit_tmp=$(mktemp -d)
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote.service" > "$unit_tmp/tallynote.service"
sed "s#/opt/tallynote#$PREFIX#g; s#/var/lib/tallynote#$DATA_DIR#g; s#/var/lib/tallynote-backups#$(dirname -- "$DATA_DIR")/tallynote-backups#g" "$release_dir/systemd/tallynote-update.service" > "$unit_tmp/tallynote-update.service"
sed "s#/var/lib/tallynote#$DATA_DIR#g" "$release_dir/systemd/tallynote-update.path" > "$unit_tmp/tallynote-update.path"
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
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 tmp
[[ "$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
stage_done 'systemd 单元、更新辅助程序和卸载器已安装'
stage '重新加载 systemd 并启动 TallyNote'
chown root:root "$CONFIG_DIR/tallynote.env"
chmod 640 "$CONFIG_DIR/tallynote.env"
systemctl daemon-reload
systemctl enable --now tallynote.service tallynote-update.path
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}"
log '查看服务状态:systemctl status tallynote.service'
}
main "$@"