#!/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 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 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 "$@"