Files
TallyNote/install.sh
T
Qiufeng 9719429f4a
TallyNote release / linux-x64 (push) Failing after 2m41s
feat: add TallyNote local reimbursement ledger
2026-08-29 01:02:29 +08:00

881 lines
41 KiB
Bash
Executable File
Raw 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. Dry-run by default; pass --apply to mutate 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=0
KEEP_RELEASES=${TALLYNOTE_KEEP_RELEASES:-3}
REQUIRE_SIGNATURE=${TALLYNOTE_INSTALL_REQUIRE_SIGNATURE:-true}
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}
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 [--apply] [--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] [--dry-run]
The default is --dry-run. Network downloads and filesystem changes happen only
with --apply. Production installs require a detached signature (Ed25519 over
SHA256SUMS by default; legacy GPG archive signatures are opt-in); --allow-unsigned
is for isolated development hosts only.
EOF
}
die() { printf 'tallynote installer: %s\n' "$*" >&2; exit 1; }
log() { printf 'tallynote installer: %s\n' "$*"; }
[[ "$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
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 --proto '=https' --tlsv1.2 --fail --silent --show-error --max-redirs 0 \
--connect-timeout "$CONNECT_TIMEOUT" --max-time "$MAX_TIME" --max-filesize "$max_bytes" \
--retry 2 --retry-connrefused --output "$out" --dump-header "$headers" \
--write-out '%{http_code}' "$current" 2>/dev/null) || 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 ]]; then
[[ -n "$signature" && -s "$signature" ]] || die '发布包缺少 SHA256SUMS.sig;生产安装必须使用签名'
[[ -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 disabled by explicit --allow-unsigned'
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"/*; 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.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" ;;
*) 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
}
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_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
[[ ! -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_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 ]] || die '环境文件禁止关闭发布签名校验'
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" ]] || die 'release archive is missing update 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() {
# 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 (( APPLY )) || [[ -n "${TALLYNOTE_OPENSSL_BIN+x}" ]]; then
validate_trusted_tool "$OPENSSL_BIN" 'openssl'
fi
detect_platform
[[ "$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")"
if [[ "$VERSION" == "latest" ]]; then
if (( ! APPLY )); then
[[ -z "$RELEASE_BASE_URL" ]] || require_https "$RELEASE_BASE_URL"
log 'version: latest (release lookup happens with --apply)'
log 'dry-run: pass --version VERSION to preview an exact artifact'
return 0
fi
resolve_latest_version
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
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"
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: pass --apply to download, verify, extract, and configure systemd'; return 0; fi
[[ "$REQUIRE_SIGNATURE" == true || "$ALLOW_UNSIGNED" -eq 1 ]] || die '生产安装必须校验发布签名;仅隔离开发环境可使用 --allow-unsigned'
[[ "$("$UNAME_BIN" -s)" == Linux ]] || die '安装器只允许在 Linux 上执行 --apply'
[[ $EUID -eq 0 ]] || die '--apply must run as 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
command -v "$OPENSSL_BIN" >/dev/null 2>&1 || die 'openssl is required'
work=$(mktemp -d)
INSTALL_WORK_DIR=$work
INSTALL_BACKUP_DIR="$work/original"
trap rollback_install_if_needed EXIT
archive="$work/$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
checksum="$work/SHA256SUMS"
SHA256_URL=${SHA256_URL:-$RELEASE_BASE_URL/SHA256SUMS}
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
signature=''
if [[ "$REQUIRE_SIGNATURE" == true ]]; then
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))
elif [[ -n "$SIGNATURE_URL" ]]; then
signature="$work/SHA256SUMS.sig"
download "$SIGNATURE_URL" "$signature" $((64 * 1024))
fi
SIGNING_KEY=${SIGNING_KEY:-$UPDATE_PUBLIC_KEY_FILE}
verify_archive "$archive" "$checksum" "$signature" "$SIGNING_KEY"
[[ "$PREFIX" = /* && "$DATA_DIR" = /* && "$CONFIG_DIR" = /* ]] || die '安装、数据和配置目录必须是绝对路径'
[[ ! -L "$DATA_DIR" && ! -L "$PREFIX" && ! -L "$CONFIG_DIR" ]] || die 'installation/data/config paths must not be symlinks'
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
install_release "$archive" "$VERSION"
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" ]] || die 'release package is missing update support files'
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
ensure_root_directory "$(dirname -- "$DATA_DIR")/tallynote-backups" 700
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"
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
}
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"
ensure_env_key TALLYNOTE_UPDATE_REQUIRE_SIGNATURE true
# 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
validate_install_path "$UPDATE_PUBLIC_KEY_FILE" '更新公钥路径'
[[ -f "$UPDATE_PUBLIC_KEY_FILE" && ! -L "$UPDATE_PUBLIC_KEY_FILE" ]] || die 'update public key file is invalid'
[[ "$(stat_uid "$UPDATE_PUBLIC_KEY_FILE")" == 0 ]] || die 'update public key file must be root-owned'
install -o root -g tallynote -m 640 "$UPDATE_PUBLIC_KEY_FILE" "$CONFIG_DIR/update-signing-key.pub"
if grep -qE '^TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=' "$CONFIG_DIR/tallynote.env"; then
sed -i "s#^TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=.*#TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=$CONFIG_DIR/update-signing-key.pub#" "$CONFIG_DIR/tallynote.env"
else
printf 'TALLYNOTE_UPDATE_PUBLIC_KEY_FILE=%s\n' "$CONFIG_DIR/update-signing-key.pub" >> "$CONFIG_DIR/tallynote.env"
fi
fi
chown root:root "$CONFIG_DIR/tallynote.env"
chmod 640 "$CONFIG_DIR/tallynote.env"
systemctl daemon-reload
systemctl enable --now tallynote.service tallynote-update.path
prune_releases
INSTALL_COMMITTED=1
trap - EXIT
rm -rf -- "$INSTALL_WORK_DIR" 2>/dev/null || true
INSTALL_WORK_DIR=''
log 'installed; inspect with systemctl status tallynote.service'
}
main "$@"