1120 lines
52 KiB
Bash
Executable File
1120 lines
52 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
set -Eeuo pipefail
|
||
|
||
# TallyNote native installer. Installs the latest release by default; use
|
||
# --dry-run to preview without changing the host.
|
||
PATH=/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/bin
|
||
export PATH
|
||
umask 077
|
||
|
||
PREFIX=${TALLYNOTE_PREFIX:-/opt/tallynote}
|
||
DATA_DIR=${TALLYNOTE_DATA_DIR:-/var/lib/tallynote}
|
||
CONFIG_DIR=${TALLYNOTE_CONFIG_DIR:-/etc/tallynote}
|
||
REPOSITORY_URL=${TALLYNOTE_REPOSITORY_URL:-https://git.awaioi.com/awaioi/TallyNote}
|
||
RELEASE_API_URL=${TALLYNOTE_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/awaioi/TallyNote/releases/latest}
|
||
RELEASE_BASE_URL=${TALLYNOTE_RELEASE_BASE_URL:-}
|
||
VERSION=${TALLYNOTE_VERSION:-latest}
|
||
RELEASE_FILE=${TALLYNOTE_RELEASE_FILE:-}
|
||
SHA256_URL=${TALLYNOTE_SHA256_URL:-}
|
||
SIGNATURE_URL=${TALLYNOTE_SIGNATURE_URL:-}
|
||
SIGNING_KEY=${TALLYNOTE_SIGNING_KEY:-}
|
||
SIGNATURE_FORMAT=${TALLYNOTE_SIGNATURE_FORMAT:-ed25519}
|
||
SHA256_FILE=${TALLYNOTE_SHA256_FILE:-}
|
||
UPDATE_PUBLIC_KEY_FILE=${TALLYNOTE_UPDATE_PUBLIC_KEY_FILE:-}
|
||
APPLY=1
|
||
KEEP_RELEASES=${TALLYNOTE_KEEP_RELEASES:-3}
|
||
REQUIRE_SIGNATURE=${TALLYNOTE_INSTALL_REQUIRE_SIGNATURE:-false}
|
||
ALLOW_DOWNGRADE=${TALLYNOTE_ALLOW_DOWNGRADE:-false}
|
||
ALLOW_UNSIGNED=0
|
||
MAX_RELEASE_MB=${TALLYNOTE_MAX_RELEASE_MB:-512}
|
||
MAX_EXTRACT_MB=${TALLYNOTE_MAX_EXTRACT_MB:-2048}
|
||
MAX_ARCHIVE_ENTRIES=${TALLYNOTE_MAX_ARCHIVE_ENTRIES:-100000}
|
||
CONNECT_TIMEOUT=${TALLYNOTE_INSTALL_CONNECT_TIMEOUT_SECONDS:-15}
|
||
MAX_TIME=${TALLYNOTE_INSTALL_MAX_TIME_SECONDS:-300}
|
||
RELEASE_ALLOWED_HOSTS=${TALLYNOTE_RELEASE_ALLOWED_HOSTS:-}
|
||
OPENSSL_BIN=${TALLYNOTE_OPENSSL_BIN:-openssl}
|
||
UNAME_BIN=${TALLYNOTE_UNAME_BIN:-uname}
|
||
# Service network settings are written to the systemd EnvironmentFile on a
|
||
# fresh install. Existing values are preserved unless the corresponding
|
||
# TALLYNOTE_* variable is explicitly supplied to the installer.
|
||
INSTALL_HOST=${TALLYNOTE_HOST-127.0.0.1}
|
||
INSTALL_PORT=${TALLYNOTE_PORT-3000}
|
||
INSTALL_PUBLIC_ORIGIN=${TALLYNOTE_PUBLIC_ORIGIN-}
|
||
INSTALL_ALLOW_INSECURE_HTTP=${TALLYNOTE_ALLOW_INSECURE_HTTP-false}
|
||
|
||
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]
|
||
|
||
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. --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 "[完成] $*"; }
|
||
|
||
[[ "$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 ;;
|
||
-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 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 '公开访问地址端口无效'
|
||
port=${BASH_REMATCH[1]}
|
||
fi
|
||
else
|
||
if [[ "$authority" == *:* ]]; then
|
||
[[ "$authority" =~ ^([^:]+):([0-9]+)$ ]] || die '公开访问地址端口无效'
|
||
host=${BASH_REMATCH[1]}
|
||
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 "$port" ]]; then
|
||
[[ "$port" =~ ^[0-9]{1,5}$ && "$port" -ge 1 && "$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
|
||
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 "$@"
|