feat: add TallyNote local reimbursement ledger
TallyNote release / linux-x64 (push) Failing after 2m41s

This commit is contained in:
Qiufeng
2026-08-29 01:02:29 +08:00
commit 9719429f4a
62 changed files with 29200 additions and 0 deletions
Executable
+880
View File
@@ -0,0 +1,880 @@
#!/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 "$@"