#!/usr/bin/env bash set -Eeuo pipefail umask 077 APP_ROOT=${KAIDI_APP_ROOT:-/opt/kaidi} STATE_ROOT=${KAIDI_STATE_ROOT:-/var/lib/kaidi} UPDATE_STATE_ROOT=${KAIDI_UPDATE_STATE_ROOT:-/var/lib/kaidi-update} CONFIG_ROOT=${KAIDI_CONFIG_ROOT:-/etc/kaidi} RELEASE_BASE_URL=${KAIDI_RELEASE_BASE_URL:-} RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest} RELEASE_TOKEN=${KAIDI_RELEASE_TOKEN:-} RELEASE_TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-} HEALTH_URL=${KAIDI_HEALTH_URL:-} APP_INDEX_URL=${KAIDI_APP_INDEX_URL:-} APP_PORT=${KAIDI_APP_PORT:-} SERVER_ADDRESS=${KAIDI_SERVER_ADDRESS:-127.0.0.1} PROXY_TARGET= TRUSTED_PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9} REINSTALL=${KAIDI_REINSTALL:-false} if [ "${KAIDI_SETUP_WIZARD+x}" = x ]; then SETUP_WIZARD=$KAIDI_SETUP_WIZARD elif [ "$REINSTALL" = true ]; then SETUP_WIZARD=false else SETUP_WIZARD=true fi WORK_DIR= RELEASE_AUTH_HEADER_FILE= INSTALL_TRANSACTION_ARMED=false INSTALL_COMMITTED=false RELEASE_DIR= BACKUP_DIR= JAVA_BIN= JAVA_STAGED_DIR= JAVA_PREVIOUS_DIR= JAVA_ACTIVATED=false SERVICE_USER=kaidi SERVICE_GROUP=kaidi HOST_ARCH= LOG_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN} SERVICE_USER_CREATED=false SERVICE_GROUP_CREATED=false CREATED_LAYOUT_PATHS=() localize_message() { local message=$1 [ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; } case "$message" in "Unsupported CPU architecture: "*) printf '不支持的 CPU 架构:%s' "${message#Unsupported CPU architecture: }" ;; "The installer only supports Linux") printf '安装程序仅支持 Linux' ;; "Custom installation roots"*) printf '打包版 systemd 不支持自定义安装目录' ;; "systemd is required") printf '需要 systemd' ;; "systemd-analyze is required") printf '需要 systemd-analyze' ;; "getconf is required") printf '需要 getconf' ;; "systemd is not running as PID 1") printf 'systemd 当前不是 PID 1,无法托管服务' ;; "/etc/systemd/system is missing") printf '缺少 /etc/systemd/system' ;; "CPU architecture and userspace word size"*) printf 'CPU 架构与用户空间位数不匹配:%s' "${message#*: }" ;; "32-bit Linux deployment requires glibc"*) printf '32 位 Linux 需要 glibc;当前系统不兼容 i686 Java' ;; "32-bit Linux deployment requires the glibc loader"*) printf '32 位 Linux 缺少 glibc 加载器 /lib/ld-linux.so.2' ;; "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256"*) printf '请设置受信任的发布公钥 SHA-256:KAIDI_RELEASE_PUBLIC_KEY_SHA256' ;; "Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;; "Set only one of KAIDI_RELEASE_TOKEN"*) printf 'KAIDI_RELEASE_TOKEN 与 KAIDI_RELEASE_TOKEN_FILE 只能设置一个' ;; "KAIDI_RELEASE_TOKEN_FILE must be a regular file") printf 'KAIDI_RELEASE_TOKEN_FILE 必须是普通文件' ;; "KAIDI_RELEASE_TOKEN_FILE is too large") printf 'KAIDI_RELEASE_TOKEN_FILE 过大' ;; "KAIDI_REINSTALL must be true or false") printf 'KAIDI_REINSTALL 只能是 true 或 false' ;; "KAIDI_SETUP_WIZARD must be true or false") printf 'KAIDI_SETUP_WIZARD 只能是 true 或 false' ;; "A setup-wizard repair needs"*) printf '安装向导修复需要已有配置文件:%s' "${message#A setup-wizard repair needs the existing }" ;; "The existing installation is already in production mode"*) printf '现有安装已是正式模式,请执行普通修复重装' ;; "The setup wizard is already locked"*) printf '安装向导已锁定,请执行普通修复重装' ;; "Kaidi Finance is already installed"*) printf 'Kaidi 财务系统已安装,请从系统更新页面执行更新或先卸载' ;; "Set KAIDI_RELEASE_BASE_URL"*) printf '请设置 Gitea Release 源:KAIDI_RELEASE_BASE_URL 或 KAIDI_RELEASE_API_URL' ;; "KAIDI_RELEASE_TOKEN is invalid") printf 'KAIDI_RELEASE_TOKEN 无效' ;; "Download failed: "*) printf '下载失败:%s' "${message#Download failed: }" ;; "Release URLs must use HTTPS") printf '发布地址必须使用 HTTPS' ;; "Release authentication header is unavailable") printf '发布认证请求头不可用' ;; "Release asset "*" is missing") printf '缺少发布资产:%s' "${message#Release asset }" ;; "Gitea Release tag is invalid") printf 'Gitea Release 标签无效' ;; "KAIDI_RELEASE_API_URL must be a Gitea"*) printf 'KAIDI_RELEASE_API_URL 必须是 Gitea Release API 地址' ;; "Required command is missing"*) printf '依赖命令缺失:%s' "${message#*: }" ;; "Supported package managers"*) printf '仅支持 apt、dnf 或 yum' ;; "Using existing Java"*) printf '使用现有 Java:%s' "${message#Using existing Java 17+ runtime at }" ;; "Local Java verified: "*) printf '本机 Java 校验通过:%s' "${message#Local Java verified: }" ;; "No local Java"*) printf '未找到可用 Java 17,正在下载 Azul Java 17 运行时' ;; "Java 17 runtime download URL"*) printf 'Java 17 运行时下载地址无效' ;; "Downloaded Java verified: "*) printf '下载的 Java 校验通过:%s' "${message#Downloaded Java verified: }" ;; "The existing Java runtime cannot start"*) printf '现有 Java 运行时无法启动,请检查架构和权限' ;; "No Java 17 runtime is published"*) printf '当前 Linux 架构没有可用的 Java 17 运行时' ;; "Java 17 runtime SHA-256 verification failed") printf 'Java 17 运行时 SHA-256 校验失败' ;; "Downloaded Java runtime architecture"*) printf '下载的 Java 运行时架构与主机不匹配' ;; "The downloaded Java runtime cannot start"*) printf '下载的 Java 运行时无法启动,请检查 glibc 和主机架构' ;; "Java 17 runtime SHA-256 is unavailable") printf 'Java 17 运行时缺少 SHA-256 摘要' ;; "Existing user "*" has unexpected home directory "*) printf '现有用户目录不符合 Kaidi 约定:%s' "${message#Existing user }" ;; "Service-user filesystem and Java access checks passed") printf '服务用户文件系统与 Java 访问检查通过' ;; *" cannot traverse or read the active release") printf '服务用户无法进入或读取当前发布目录' ;; "The selected Java runtime is unavailable"*) printf '选定的 Java 运行时不可用:%s' "${message#The selected Java runtime is unavailable at }" ;; "The selected Java runtime cannot execute"*) printf '服务用户无法执行选定的 Java 运行时' ;; *" cannot execute the selected Java runtime") printf '服务用户无法执行选定的 Java 运行时' ;; *" cannot execute as "*) printf '服务用户无法启动 Java 运行时' ;; *" cannot write the file-storage directory") printf '服务用户无法写入文件存储目录' ;; *" cannot write the update inbox") printf '服务用户无法写入更新请求目录' ;; "SELinux context restoration reported"*) printf 'SELinux 上下文恢复有提示,将继续进行服务访问检查' ;; "No interactive terminal detected"*) printf '未检测到交互终端,使用应用端口 %s' "${message##*port }" ;; "Application port "*" is already held"*) printf '应用端口已由现有 Kaidi 服务占用:%s' "${message#Application port }" ;; "Application port "*" is already in use"*) printf '应用端口已被占用,请使用 KAIDI_APP_PORT 更换端口' ;; "Application will bind "*) printf '应用监听地址:%s' "${message#Application will bind }" ;; "Reverse proxy target: "*) printf '反向代理目标:%s' "${message#Reverse proxy target: }" ;; "KAIDI_SERVER_ADDRESS contains"*) printf 'KAIDI_SERVER_ADDRESS 含有不支持的字符' ;; "KAIDI_APP_PORT must be an integer"*) printf 'KAIDI_APP_PORT 必须是 1024 到 65535 的整数' ;; "Do not pass database credentials"*) printf '向导模式不要在命令行传数据库账号密码,请在浏览器向导中填写' ;; "KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD"*) printf '设置 KAIDI_DB_URL 时必须同时提供数据库用户名和密码' ;; "An external MySQL 8.4 database is required"*) printf '需要连接用户预先准备的 MySQL 8.4 数据库:请设置 KAIDI_DB_URL、KAIDI_DB_USERNAME、KAIDI_DB_PASSWORD' ;; "KAIDI_REINSTALL needs"*) printf 'KAIDI_REINSTALL 需要读取已有安装配置或提供数据库配置' ;; "The database host is empty") printf '数据库主机不能为空' ;; "The database port is invalid") printf '数据库端口无效' ;; "The database name is invalid") printf '数据库名无效,只能包含字母、数字、下划线和美元符号' ;; "KAIDI_DB_URL must start with jdbc:mysql://") printf 'KAIDI_DB_URL 必须以 jdbc:mysql:// 开头' ;; "KAIDI_DB_USERNAME is empty") printf 'KAIDI_DB_USERNAME 不能为空' ;; "KAIDI_DB_PASSWORD is empty") printf 'KAIDI_DB_PASSWORD 不能为空' ;; "KAIDI_DB_BACKUP_MODE must be skip or mysqldump") printf 'KAIDI_DB_BACKUP_MODE 只能是 skip 或 mysqldump' ;; "Waiting for application startup"*) printf '正在等待应用启动:%s' "${message#Waiting for application startup at }" ;; "Application health check is UP") printf '应用健康检查通过(UP)' ;; "Application is still starting"*) printf '应用仍在启动:%s' "${message#Application is still starting }" ;; "Application service failed"*) printf '应用服务启动失败:%s' "${message#Application service failed during startup }" ;; "Application health check did not become UP") printf '应用健康检查未变为 UP,请查看服务日志' ;; "Application frontend entry point is unavailable") printf '应用前端入口不可访问,请检查服务和端口' ;; "Installation failed; restoring"*) printf '安装失败,正在恢复原有运行状态' ;; "ERROR: rollback was incomplete"*) printf '错误:回滚未完成,请检查 systemd 状态' ;; *" contains a line break") printf '配置项包含换行符,已拒绝:%s' "${message%% contains a line break*}" ;; "Kaidi Finance "*" is installed") printf 'Kaidi 财务系统 %s 已安装' "${message#Kaidi Finance }" ;; "Configure your reverse proxy"*) printf '请将反向代理指向:%s' "${message#Configure your reverse proxy to }" ;; "Open /setup"*) printf '请通过反向代理域名打开 /setup,完成首次安装向导' ;; "Setup code: "*) printf '安装码位置:%s' "${message#Setup code: }" ;; "Open the reverse-proxy domain"*) printf '请打开反向代理域名并使用管理员账号登录' ;; "Temporary credentials: "*) printf '临时凭据位置:%s' "${message#Temporary credentials: }" ;; "Change the temporary password"*) printf '首次登录后请立即修改临时密码' ;; "Release public-key SHA-256 does not match"*) printf '发布公钥 SHA-256 与受信指纹不一致' ;; "Release manifest signature verification failed") printf '发布清单签名校验失败' ;; "Release version is invalid") printf '发布版本无效' ;; "Release artifact name is invalid") printf '发布包文件名无效' ;; "Release SHA-256 is invalid") printf '发布包 SHA-256 无效' ;; "Release artifact SHA-256 verification failed") printf '发布包 SHA-256 校验失败' ;; "Release archive contains an unsafe path") printf '发布归档包含不安全路径,已拒绝' ;; "Release archive must not contain symbolic links") printf '发布归档不能包含符号链接,已拒绝' ;; "Release app.jar is missing") printf '发布包缺少 app.jar' ;; "Release frontend is missing") printf '发布包缺少前端文件' ;; "Release version mismatch") printf '发布包版本与清单不一致' ;; "Release updater is missing") printf '发布包缺少更新脚本' ;; "Baota Spring Boot launcher is missing") printf '发布包缺少宝塔启动脚本' ;; "Baota Spring Boot launcher is invalid") printf '宝塔启动脚本语法无效' ;; "The existing FIELD_ENCRYPTION_KEY is missing") printf '已有安装缺少 FIELD_ENCRYPTION_KEY' ;; "Installed systemd units failed validation") printf '已安装的 systemd 单元校验失败' ;; "Environment verified: "*) printf '环境检查通过:%s' "${message#Environment verified: }" ;; *) printf '%s' "$message" ;; esac } log() { printf '[kaidi-install] %s\n' "$(localize_message "$*")"; } die() { printf '[kaidi-install] 错误:%s\n' "$(localize_message "$*")" >&2; exit 1; } sha256_file() { sha256sum "$1" | awk '{print $1}' } normalized_host_arch() { local machine host_bits machine=$(uname -m) host_bits=$(getconf LONG_BIT 2>/dev/null || true) case "$machine" in x86_64|amd64) [ "$host_bits" = 32 ] && printf x86 || printf x86_64 ;; i386|i486|i586|i686) printf x86 ;; aarch64|arm64) [ "$host_bits" = 32 ] && printf arm || printf aarch64 ;; armv7l|armv6l) printf arm ;; *) die "Unsupported CPU architecture: $machine" ;; esac } preflight_host() { local host_bits expected_bits [ "$(uname -s)" = Linux ] || die "The installer only supports Linux" [ "$APP_ROOT" = /opt/kaidi ] && [ "$STATE_ROOT" = /var/lib/kaidi ] \ && [ "$UPDATE_STATE_ROOT" = /var/lib/kaidi-update ] && [ "$CONFIG_ROOT" = /etc/kaidi ] \ || die "Custom installation roots are not supported by the packaged systemd configuration" command -v systemctl >/dev/null 2>&1 || die "systemd is required" command -v systemd-analyze >/dev/null 2>&1 || die "systemd-analyze is required" command -v getconf >/dev/null 2>&1 || die "getconf is required" [ -d /run/systemd/system ] || die "systemd is not running as PID 1" [ -d /etc/systemd/system ] || die "/etc/systemd/system is missing" HOST_ARCH=$(normalized_host_arch) host_bits=$(getconf LONG_BIT 2>/dev/null || true) case "$HOST_ARCH" in x86_64|aarch64) expected_bits=64 ;; x86|arm) expected_bits=32 ;; esac [ "$host_bits" = "$expected_bits" ] \ || die "CPU architecture and userspace word size do not match: $HOST_ARCH/$host_bits-bit" if [ "$HOST_ARCH" = x86 ]; then getconf GNU_LIBC_VERSION >/dev/null 2>&1 \ || die "32-bit Linux deployment requires glibc; the downloaded i686 Java runtime is not compatible with musl" if ! for loader in /lib/ld-linux.so.2 /lib32/ld-linux.so.2 /lib/i386-linux-gnu/ld-linux.so.2; do [ -x "$loader" ] && break done; then die "32-bit Linux deployment requires the glibc loader /lib/ld-linux.so.2" fi fi [[ "$TRUSTED_PUBLIC_KEY_SHA256" =~ ^[0-9A-Fa-f]{64}$ ]] \ || die "Set KAIDI_RELEASE_PUBLIC_KEY_SHA256 to the trusted release public-key SHA-256" case "$REINSTALL" in true|false) ;; *) die "KAIDI_REINSTALL must be true or false" ;; esac case "$SETUP_WIZARD" in true|false) ;; *) die "KAIDI_SETUP_WIZARD must be true or false" ;; esac if [ "$SETUP_WIZARD" = true ] && [ "$REINSTALL" = true ]; then [ -s "$CONFIG_ROOT/kaidi.env" ] \ || die "A setup-wizard repair needs the existing $CONFIG_ROOT/kaidi.env" [ "$(read_existing_env FINANCE_SETUP_ENABLED)" = true ] \ || die "The existing installation is already in production mode; use a normal repair reinstall" [ ! -e "$STATE_ROOT/setup/locked" ] \ || die "The setup wizard is already locked; use a normal repair reinstall" fi log "Environment verified: $(uname -sr), architecture=$HOST_ARCH, userspace=${host_bits}-bit, systemd" } validate_inputs() { [ "$(id -u)" -eq 0 ] || die "Run with sudo or as root" [ -z "$RELEASE_TOKEN" ] || [ -z "$RELEASE_TOKEN_FILE" ] \ || die "Set only one of KAIDI_RELEASE_TOKEN or KAIDI_RELEASE_TOKEN_FILE" if [ -n "$RELEASE_TOKEN_FILE" ]; then [ -f "$RELEASE_TOKEN_FILE" ] && [ ! -L "$RELEASE_TOKEN_FILE" ] \ || die "KAIDI_RELEASE_TOKEN_FILE must be a regular file" [ "$(wc -c < "$RELEASE_TOKEN_FILE" | tr -d '[:space:]')" -le 512 ] \ || die "KAIDI_RELEASE_TOKEN_FILE is too large" RELEASE_TOKEN=$(cat "$RELEASE_TOKEN_FILE") fi [ "$REINSTALL" = "true" ] || [ ! -e "$APP_ROOT/current" ] \ || die "Kaidi Finance is already installed; use the system update page" if [ -z "$RELEASE_API_URL" ]; then case "$RELEASE_BASE_URL" in *OWNER/REPO*) die "Set KAIDI_RELEASE_BASE_URL or KAIDI_RELEASE_API_URL to the Git release source" ;; esac fi [ -z "$RELEASE_TOKEN" ] || { [ "${#RELEASE_TOKEN}" -le 512 ] \ && ! printf '%s' "$RELEASE_TOKEN" | grep -q '[[:cntrl:]]'; } \ || die "KAIDI_RELEASE_TOKEN is invalid" } download() { local url=$1 output=$2 case "$url" in https://*) if ! curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ --tlsv1.2 --connect-timeout 15 --max-time 600 "$url" -o "$output"; then die "Download failed: $url" fi ;; *) [ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || die "Release URLs must use HTTPS" if ! curl --fail --silent --show-error --location --proto '=http,https' \ --proto-redir '=http,https' --connect-timeout 15 --max-time 600 "$url" -o "$output"; then die "Download failed: $url" fi ;; esac } download_release_url() { local url=$1 output=$2 case "$url" in https://*) if [ -n "$RELEASE_TOKEN" ]; then [ -s "$RELEASE_AUTH_HEADER_FILE" ] || die "Release authentication header is unavailable" curl --fail --silent --show-error --proto '=https' --tlsv1.2 \ --header "@$RELEASE_AUTH_HEADER_FILE" "$url" -o "$output" else download "$url" "$output" fi ;; *) [ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || die "Release URLs must use HTTPS" if [ -n "$RELEASE_TOKEN" ]; then [ -s "$RELEASE_AUTH_HEADER_FILE" ] || die "Release authentication header is unavailable" curl --fail --silent --show-error --proto '=http,https' \ --header "@$RELEASE_AUTH_HEADER_FILE" "$url" -o "$output" else download "$url" "$output" fi ;; esac } release_asset_url() { local asset_name=$1 api_origin repo_path release_tag encoded_tag encoded_asset if [ -n "$RELEASE_API_URL" ]; then jq -e --arg name "$asset_name" 'any(.assets[]?; .name == $name)' \ "$WORK_DIR/release-api.json" >/dev/null || die "Release asset $asset_name is missing" release_tag=$(jq -er '.tag_name | strings | select(test("^v[0-9A-Za-z][0-9A-Za-z._+-]{0,127}$"))' \ "$WORK_DIR/release-api.json") || die "Gitea Release tag is invalid" api_origin=$(printf '%s' "$RELEASE_API_URL" | sed -E 's#^(https?://[^/]+).*$#\1#') repo_path=$(printf '%s' "$RELEASE_API_URL" | sed -nE \ 's#^https?://[^/]+/api/v1/repos/([A-Za-z0-9._-]+/[A-Za-z0-9._-]+)/releases/(latest|tags/[^/?#]+)$#\1#p') [ -n "$repo_path" ] || die "KAIDI_RELEASE_API_URL must be a Gitea repository Release API endpoint" encoded_tag=$(jq -rn --arg value "$release_tag" '$value | @uri') encoded_asset=$(jq -rn --arg value "$asset_name" '$value | @uri') printf '%s/%s/releases/download/%s/%s\n' "$api_origin" "$repo_path" "$encoded_tag" "$encoded_asset" else printf '%s/%s\n' "${RELEASE_BASE_URL%/}" "$asset_name" fi } download_release_asset() { local asset_name=$1 output=$2 url url=$(release_asset_url "$asset_name") download_release_url "$url" "$output" } install_packages() { if command -v apt-get >/dev/null 2>&1; then export DEBIAN_FRONTEND=noninteractive apt-get update -qq >/dev/null 2>&1 \ || die "系统软件源更新失败,请检查网络和 apt 配置" apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \ >/dev/null 2>&1 \ || die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker" elif command -v dnf >/dev/null 2>&1; then dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \ >/dev/null 2>&1 \ || die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker" elif command -v yum >/dev/null 2>&1; then yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux \ >/dev/null 2>&1 \ || die "系统依赖安装失败;安装器不会安装 MySQL 或 Docker" else die "Supported package managers are apt, dnf, and yum" fi log "系统依赖检查完成(未安装 MySQL、MariaDB、Docker)" } preflight_runtime_commands() { local command_name for command_name in \ awk bash chmod chown cp curl cut date find getconf getent grep gzip head id install jq journalctl \ ln mktemp mv nologin openssl readlink rm runuser sed sha256sum sleep sort systemctl \ systemd-analyze tar tr useradd usermod groupadd wc flock setsid; do command -v "$command_name" >/dev/null 2>&1 \ || die "Required command is missing after dependency preparation: $command_name" done } azul_arch() { case "${HOST_ARCH:-$(normalized_host_arch)}" in x86_64) printf x86 ;; x86) printf i686 ;; aarch64) printf arm_64 ;; arm) printf arm ;; esac } java_arch_matches_host() { local java_bin=$1 java_arch host_arch host_arch=${HOST_ARCH:-$(normalized_host_arch)} java_arch=$( "$java_bin" -XshowSettings:properties -version 2>&1 \ | sed -n 's/^[[:space:]]*os\.arch = [[:space:]]*//p' \ | head -n 1 ) case "$host_arch:$java_arch" in x86_64:amd64|x86_64:x86_64|x86:x86|x86:i386|x86:i486|x86:i586|x86:i686|\ aarch64:aarch64|aarch64:arm64|arm:arm) return 0 ;; *) return 1 ;; esac } java_home_from_bin() { local java_bin=$1 resolved_java java_major home [ -x "$java_bin" ] || return 1 java_major=$( "$java_bin" -version 2>&1 \ | sed -n 's/.*version "\([0-9][0-9]*\).*/\1/p' \ | head -n 1 ) [[ "$java_major" =~ ^[0-9]+$ ]] && [ "$java_major" -ge 17 ] || return 1 java_arch_matches_host "$java_bin" || return 1 resolved_java=$(readlink -f "$java_bin" 2>/dev/null || printf '%s' "$java_bin") case "$resolved_java" in */bin/java) home=${resolved_java%/bin/java} ;; *) return 1 ;; esac [ -x "$home/bin/java" ] || return 1 printf '%s\n' "$home" } system_java_home() { local candidate home if [ -n "${KAIDI_JAVA_HOME:-}" ]; then candidate="${KAIDI_JAVA_HOME%/}/bin/java" java_home_from_bin "$candidate" && return 0 return 1 fi candidate=$(command -v java 2>/dev/null || true) if [ -n "$candidate" ] && home=$(java_home_from_bin "$candidate"); then printf '%s\n' "$home" return 0 fi # Baota commonly keeps JDKs outside PATH. Prefer an existing Java 17 there # before consulting the remote Azul metadata endpoint. for candidate in \ /www/server/java/*/bin/java \ /www/server/java/*/jre/bin/java \ /usr/lib/jvm/*/bin/java \ /usr/java/*/bin/java; do [ -x "$candidate" ] || continue if home=$(java_home_from_bin "$candidate"); then printf '%s\n' "$home" return 0 fi done return 1 } prepare_java() { local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home java_line java_version JAVA_BIN= JAVA_STAGED_DIR= if system_home=$(system_java_home); then JAVA_BIN="$system_home/bin/java" java_line=$("$JAVA_BIN" -version 2>&1 | head -n 1) \ || die "The existing Java runtime cannot start on this host" java_version=$(printf '%s\n' "$java_line" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p') log "使用现有 Java 运行时:$system_home(版本 ${java_version:-未知})" return 0 fi log "No local Java 17 runtime found; downloading the official Azul Java 17 runtime" api="https://api.azul.com/metadata/v1/zulu/packages/?java_version=17&os=linux&arch=$(azul_arch)&archive_type=tar.gz&java_package_type=jre&release_status=ga&availability_types=CA&latest=true" java_metadata="$WORK_DIR/zulu-metadata.json" download "$api" "$java_metadata" package_uuid=$(jq -er '[.[] | select(.name | contains("-fx-") | not)][0].package_uuid' \ "$java_metadata") \ || die "No Java 17 runtime is published for this Linux architecture" package_metadata="$WORK_DIR/zulu-package.json" download "https://api.azul.com/metadata/v1/zulu/packages/$package_uuid" "$package_metadata" java_url=$(jq -er '.download_url | strings | select(startswith("https://cdn.azul.com/"))' \ "$package_metadata") || die "Java 17 runtime download URL is invalid" java_sha256=$(jq -er '.sha256_hash | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \ "$package_metadata") || die "Java 17 runtime SHA-256 is unavailable" JAVA_STAGED_DIR="$WORK_DIR/java.next" download "$java_url" "$WORK_DIR/java.tar.gz" actual_sha256=$(sha256_file "$WORK_DIR/java.tar.gz") [ "$actual_sha256" = "$java_sha256" ] || die "Java 17 runtime SHA-256 verification failed" mkdir -p "$JAVA_STAGED_DIR" tar -xzf "$WORK_DIR/java.tar.gz" --strip-components=1 -C "$JAVA_STAGED_DIR" java_arch_matches_host "$JAVA_STAGED_DIR/bin/java" \ || die "Downloaded Java runtime architecture does not match $HOST_ARCH" java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1) \ || die "The downloaded Java runtime cannot start; verify glibc and the host architecture" JAVA_BIN="$APP_ROOT/runtime/java/bin/java" java_version=$(printf '%s\n' "$java_line" | sed -n 's/.*version "\([0-9][0-9.]*\).*/\1/p') log "下载的 Java 运行时校验通过(版本 ${java_version:-未知})" } activate_java() { [ -n "$JAVA_STAGED_DIR" ] || return 0 mkdir -p "$APP_ROOT/runtime" JAVA_PREVIOUS_DIR="$WORK_DIR/java.previous" if [ -e "$APP_ROOT/runtime/java" ] || [ -L "$APP_ROOT/runtime/java" ]; then mv "$APP_ROOT/runtime/java" "$JAVA_PREVIOUS_DIR" fi mv "$JAVA_STAGED_DIR" "$APP_ROOT/runtime/java" chown -R root:"$SERVICE_GROUP" "$APP_ROOT/runtime/java" chmod -R u=rwX,g=rX,o= "$APP_ROOT/runtime/java" JAVA_ACTIVATED=true } ensure_service_identity() { local existing_home nologin_path command -v getent >/dev/null 2>&1 || die "getent is required" if ! getent group "$SERVICE_GROUP" >/dev/null 2>&1; then groupadd --system "$SERVICE_GROUP" SERVICE_GROUP_CREATED=true fi if id "$SERVICE_USER" >/dev/null 2>&1; then existing_home=$(getent passwd "$SERVICE_USER" | awk -F: '{print $6}') [ "$existing_home" = "$STATE_ROOT" ] \ || die "Existing user $SERVICE_USER has unexpected home directory $existing_home" if ! id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP"; then usermod --append --groups "$SERVICE_GROUP" "$SERVICE_USER" fi return 0 fi nologin_path=$(command -v nologin 2>/dev/null || true) [ -n "$nologin_path" ] || nologin_path=/usr/sbin/nologin [ -x "$nologin_path" ] || die "A nologin shell is required" useradd --system --gid "$SERVICE_GROUP" --home-dir "$STATE_ROOT" --shell "$nologin_path" "$SERVICE_USER" SERVICE_USER_CREATED=true } record_layout_path() { [ -e "$1" ] || [ -L "$1" ] || CREATED_LAYOUT_PATHS+=("$1") } prepare_managed_layout() { command -v runuser >/dev/null 2>&1 || die "runuser is required" record_layout_path "$APP_ROOT" record_layout_path "$APP_ROOT/releases" record_layout_path "$APP_ROOT/runtime" record_layout_path "$APP_ROOT/bin" record_layout_path "$STATE_ROOT" record_layout_path "$STATE_ROOT/files" record_layout_path "$STATE_ROOT/tmp" record_layout_path /var/log/kaidi record_layout_path "$STATE_ROOT/setup" record_layout_path "$UPDATE_STATE_ROOT" record_layout_path "$UPDATE_STATE_ROOT/inbox" record_layout_path "$CONFIG_ROOT" install -d -o root -g "$SERVICE_GROUP" -m 0750 \ "$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 \ "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" /var/log/kaidi install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0700 "$STATE_ROOT/setup" install -d -o root -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT" install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT/inbox" install -d -o root -g root -m 0700 "$CONFIG_ROOT" } secure_release_tree() { local release_dir=$1 chown -R root:"$SERVICE_GROUP" "$release_dir" find "$release_dir" -type d -exec chmod 0750 {} + find "$release_dir" -type f -exec chmod 0640 {} + chmod 0750 "$release_dir/ops/update.sh" "$release_dir/ops/baota-start.sh" } restore_security_contexts() { command -v restorecon >/dev/null 2>&1 || return 0 restorecon -RF "$APP_ROOT" "$STATE_ROOT" "$UPDATE_STATE_ROOT" /var/log/kaidi \ /etc/systemd/system/kaidi-finance.service \ /etc/systemd/system/kaidi-update.service \ /etc/systemd/system/kaidi-update.path >/dev/null 2>&1 \ || log "SELinux context restoration reported a warning; service access checks will decide whether installation can continue" } verify_service_access() { # shellcheck disable=SC2016 # Positional parameters are expanded by the child shell. runuser -u "$SERVICE_USER" -- sh -c \ 'cd "$1" && test -r app.jar && test -r public/index.html' sh "$APP_ROOT/current" \ || die "$SERVICE_USER cannot traverse or read the active release" [ -n "$JAVA_BIN" ] && [ -x "$JAVA_BIN" ] \ || die "The selected Java runtime is unavailable at $JAVA_BIN" runuser -u "$SERVICE_USER" -- test -x "$JAVA_BIN" \ || die "$SERVICE_USER cannot execute the selected Java runtime" runuser -u "$SERVICE_USER" -- "$JAVA_BIN" -version >/dev/null 2>&1 \ || die "The selected Java runtime cannot execute as $SERVICE_USER" runuser -u "$SERVICE_USER" -- test -w "$STATE_ROOT/files" \ || die "$SERVICE_USER cannot write the file-storage directory" runuser -u "$SERVICE_USER" -- test -w "$UPDATE_STATE_ROOT/inbox" \ || die "$SERVICE_USER cannot write the update inbox" log "Service-user filesystem and Java access checks passed" } random_secret() { openssl rand -base64 36 | tr -d '\n/+=' | cut -c1-36; } decode_env_value() { local raw=$1 result='' char next index=0 length if [[ "$raw" == \"*\" && ${#raw} -ge 2 ]]; then raw=${raw:1:${#raw}-2} length=${#raw} while [ "$index" -lt "$length" ]; do char=${raw:index:1} if [ "$char" = "\\" ] && [ $((index + 1)) -lt "$length" ]; then next=${raw:index+1:1} if [ "$next" = "\\" ] || [ "$next" = '"' ]; then result+="$next" index=$((index + 2)) continue fi fi result+="$char" index=$((index + 1)) done printf '%s' "$result" elif [[ "$raw" == \'*\' && ${#raw} -ge 2 ]]; then printf '%s' "${raw:1:${#raw}-2}" else printf '%s' "$raw" fi } read_existing_env() { read_env_file "$CONFIG_ROOT/kaidi.env" "$1" } read_env_file() { local file=$1 name=$2 line value='' found=false [ -s "$file" ] || return 0 while IFS= read -r line || [ -n "$line" ]; do case "$line" in "$name="*) value=${line#*=} found=true ;; esac done < "$file" if [ "$found" = true ]; then decode_env_value "$value" fi } read_setup_env() { read_env_file "$STATE_ROOT/setup/application.env" "$1" } read_reinstall_env() { local value value=$(read_setup_env "$1") if [ -n "$value" ]; then printf '%s' "$value" else read_existing_env "$1" fi } port_is_listening() { local port_hex files=(/proc/net/tcp) port_hex=$(printf '%04X' "$APP_PORT") [ -r /proc/net/tcp ] || return 1 [ ! -r /proc/net/tcp6 ] || files+=(/proc/net/tcp6) awk -v port="$port_hex" ' toupper($2) ~ (":" port "$") && $4 == "0A" { found=1 } END { exit(found ? 0 : 1) } ' "${files[@]}" 2>/dev/null } valid_app_port() { [[ "$1" =~ ^[0-9]{1,5}$ ]] && [ "$1" -ge 1024 ] && [ "$1" -le 65535 ] } configure_app_port() { local default_port=18080 existing_port entered probe_host probe_authority if [ -z "$APP_PORT" ]; then default_port=$(read_reinstall_env SERVER_PORT || true) [[ "$default_port" =~ ^[0-9]{1,5}$ ]] || default_port=18080 if [ -t 1 ] && [ -r /dev/tty ]; then printf '[kaidi-install] 应用端口 [%s]:' "$default_port" > /dev/tty if IFS= read -r entered < /dev/tty; then APP_PORT=${entered:-$default_port} else APP_PORT=$default_port fi else APP_PORT=$default_port log "No interactive terminal detected; using application port $APP_PORT" fi fi valid_app_port "$APP_PORT" \ || die "KAIDI_APP_PORT must be an integer between 1024 and 65535" case "$SERVER_ADDRESS" in ''|*[!A-Za-z0-9_.:-]*) die "KAIDI_SERVER_ADDRESS contains unsupported characters" ;; esac if port_is_listening; then existing_port=$(read_existing_env SERVER_PORT || true) if [ "$REINSTALL" = true ] && [ "$existing_port" = "$APP_PORT" ] \ && systemctl is-active --quiet kaidi-finance.service; then log "Application port $APP_PORT is already held by the existing Kaidi service" else die "Application port $APP_PORT is already in use; choose another port with KAIDI_APP_PORT" fi fi probe_host=${KAIDI_HEALTH_HOST:-$SERVER_ADDRESS} case "$probe_host" in 0.0.0.0) probe_host=127.0.0.1 ;; ::) probe_host=::1 ;; esac case "$probe_host" in *:*) probe_authority="[$probe_host]" ;; *) probe_authority="$probe_host" ;; esac if [ -z "$HEALTH_URL" ]; then HEALTH_URL="http://${probe_authority}:${APP_PORT}/actuator/health" fi if [ -z "$APP_INDEX_URL" ]; then APP_INDEX_URL="http://${probe_authority}:${APP_PORT}/" fi PROXY_TARGET="http://${probe_authority}:${APP_PORT}" log "Application will bind ${SERVER_ADDRESS}:${APP_PORT}" log "Reverse proxy target: $PROXY_TARGET" } is_semver() { [ "${#1}" -le 128 ] \ && LC_ALL=C grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)(\.(0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*)?(\+[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$' \ < "$temporary" while [ "$#" -gt 0 ]; do name=$1 value=$2 shift 2 case "$value" in *$'\n'*|*$'\r'*) die "$name contains a line break" ;; esac escaped=${value//\\/\\\\} escaped=${escaped//\"/\\\"} printf '%s="%s"\n' "$name" "$escaped" >> "$temporary" done mv -f "$temporary" "$output" } write_env_file_preserving_unknown() { local output=$1 existing='' temporary name value escaped line key managed_keys='|' shift if [ "$REINSTALL" = true ] && [ -f "$output" ]; then existing="$WORK_DIR/existing-kaidi.env" cp -p "$output" "$existing" fi local arguments=("$@") while [ "$#" -gt 0 ]; do managed_keys+="$1|" shift 2 done temporary="${output}.next.$$" : > "$temporary" if [ -n "$existing" ]; then while IFS= read -r line || [ -n "$line" ]; do if [[ "$line" =~ ^([A-Za-z_][A-Za-z0-9_]*)= ]]; then key=${BASH_REMATCH[1]} case "$managed_keys" in *"|$key|"*) continue ;; esac fi printf '%s\n' "$line" >> "$temporary" done < "$existing" fi set -- "${arguments[@]}" while [ "$#" -gt 0 ]; do name=$1 value=$2 shift 2 case "$value" in *$'\n'*|*$'\r'*) die "$name contains a line break" ;; esac escaped=${value//\\/\\\\} escaped=${escaped//\"/\\\"} printf '%s="%s"\n' "$name" "$escaped" >> "$temporary" done mv -f "$temporary" "$output" } wait_for_health() { local attempts=0 active_state restart_count log "Waiting for application startup at $HEALTH_URL" while [ "$attempts" -lt 60 ]; do if curl --fail --silent --connect-timeout 1 --max-time 2 "$HEALTH_URL" 2>/dev/null \ | jq -e '.status == "UP"' >/dev/null 2>&1; then log "Application health check is UP" return 0 fi active_state=$(systemctl show kaidi-finance.service --property=ActiveState --value 2>/dev/null || printf unknown) restart_count=$(systemctl show kaidi-finance.service --property=NRestarts --value 2>/dev/null || printf 0) [[ "$restart_count" =~ ^[0-9]+$ ]] || restart_count=0 if [ "$active_state" = failed ] || [ "$restart_count" -ge 3 ]; then journalctl -u kaidi-finance.service -n 80 --no-pager >&2 || true die "Application service failed during startup (state=$active_state, restarts=$restart_count)" fi attempts=$((attempts + 1)) if [ $((attempts % 5)) -eq 0 ]; then log "Application is still starting (state=$active_state, elapsed=$((attempts * 2))s)" fi sleep 2 done journalctl -u kaidi-finance.service -n 80 --no-pager >&2 || true die "Application health check did not become UP" } backup_managed_state() { local index=0 path MANAGED_PATHS=( "$APP_ROOT/current" "$APP_ROOT/bin/update.sh" "$CONFIG_ROOT/kaidi.env" "$CONFIG_ROOT/update.env" "$CONFIG_ROOT/release-public.pem" "$UPDATE_STATE_ROOT/status.json" /root/kaidi-first-login.txt /etc/systemd/system/kaidi-finance.service /etc/systemd/system/kaidi-update.service /etc/systemd/system/kaidi-update.path ) BACKUP_DIR="$WORK_DIR/rollback" mkdir -p "$BACKUP_DIR/files" for path in "${MANAGED_PATHS[@]}"; do if [ -e "$path" ] || [ -L "$path" ]; then cp -a -- "$path" "$BACKUP_DIR/files/$index" : > "$BACKUP_DIR/files/$index.exists" fi index=$((index + 1)) done systemctl is-active --quiet kaidi-finance.service >/dev/null 2>&1 \ && PREVIOUS_APP_ACTIVE=true || PREVIOUS_APP_ACTIVE=false systemctl is-enabled --quiet kaidi-finance.service >/dev/null 2>&1 \ && PREVIOUS_APP_ENABLED=true || PREVIOUS_APP_ENABLED=false systemctl is-active --quiet kaidi-update.path >/dev/null 2>&1 \ && PREVIOUS_UPDATE_ACTIVE=true || PREVIOUS_UPDATE_ACTIVE=false systemctl is-enabled --quiet kaidi-update.path >/dev/null 2>&1 \ && PREVIOUS_UPDATE_ENABLED=true || PREVIOUS_UPDATE_ENABLED=false INSTALL_TRANSACTION_ARMED=true } restore_unit_state() { local unit=$1 was_enabled=$2 was_active=$3 if ! systemctl cat "$unit" >/dev/null 2>&1; then systemctl reset-failed "$unit" >/dev/null 2>&1 || true [ "$was_enabled" = false ] && [ "$was_active" = false ] return fi systemctl reset-failed "$unit" >/dev/null 2>&1 || true if [ "$was_enabled" = true ]; then systemctl enable "$unit" >/dev/null 2>&1 else systemctl disable "$unit" >/dev/null 2>&1 fi if [ "$was_active" = true ]; then systemctl start "$unit" >/dev/null 2>&1 else systemctl stop "$unit" >/dev/null 2>&1 fi } restore_managed_path() { local path=$1 index=$2 temporary if [ -e "$BACKUP_DIR/files/$index.exists" ]; then mkdir -p "$(dirname "$path")" temporary="${path}.rollback.$$" rm -rf -- "$temporary" cp -a -- "$BACKUP_DIR/files/$index" "$temporary" || return 1 mv -Tf -- "$temporary" "$path" || { rm -rf -- "$temporary" return 1 } else rm -rf -- "$path" fi } rollback_install() { local index=0 path rollback_failed=false layout_path service_uid set +e log "Installation failed; restoring the previous managed state" systemctl stop kaidi-update.path kaidi-finance.service >/dev/null 2>&1 || true for path in "${MANAGED_PATHS[@]}"; do restore_managed_path "$path" "$index" || rollback_failed=true index=$((index + 1)) done if [ -n "$RELEASE_DIR" ]; then rm -rf -- "$RELEASE_DIR" || rollback_failed=true fi if [ -d "$JAVA_PREVIOUS_DIR" ] || [ -L "$JAVA_PREVIOUS_DIR" ]; then rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true mkdir -p "$APP_ROOT/runtime" || rollback_failed=true mv "$JAVA_PREVIOUS_DIR" "$APP_ROOT/runtime/java" || rollback_failed=true elif [ "$JAVA_ACTIVATED" = true ]; then rm -rf -- "$APP_ROOT/runtime/java" || rollback_failed=true fi if [ "$SERVICE_USER_CREATED" = true ]; then service_uid=$(id -u "$SERVICE_USER" 2>/dev/null || true) if [ -n "$service_uid" ]; then userdel --force "$SERVICE_USER" >/dev/null 2>&1 || rollback_failed=true fi fi if [ "$SERVICE_GROUP_CREATED" = true ] && getent group "$SERVICE_GROUP" >/dev/null 2>&1; then groupdel "$SERVICE_GROUP" >/dev/null 2>&1 || rollback_failed=true fi for ((index=${#CREATED_LAYOUT_PATHS[@]} - 1; index >= 0; index--)); do layout_path=${CREATED_LAYOUT_PATHS[$index]} rmdir -- "$layout_path" >/dev/null 2>&1 || true done systemctl daemon-reload >/dev/null 2>&1 || rollback_failed=true restore_unit_state kaidi-finance.service "$PREVIOUS_APP_ENABLED" "$PREVIOUS_APP_ACTIVE" \ || rollback_failed=true restore_unit_state kaidi-update.path "$PREVIOUS_UPDATE_ENABLED" "$PREVIOUS_UPDATE_ACTIVE" \ || rollback_failed=true set -e [ "$rollback_failed" = false ] } cleanup() { local result=$? trap - EXIT if [ "$result" -ne 0 ] && [ "$INSTALL_TRANSACTION_ARMED" = true ] \ && [ "$INSTALL_COMMITTED" != true ]; then rollback_install || log "ERROR: rollback was incomplete; inspect the managed systemd state" fi [ -z "$WORK_DIR" ] || rm -rf "$WORK_DIR" exit "$result" } main() { trap cleanup EXIT validate_inputs validate_database_backup_mode configure_app_port preflight_host install_packages preflight_runtime_commands BASE=${RELEASE_BASE_URL%/} WORK_DIR=$(mktemp -d) if [ -n "$RELEASE_TOKEN" ]; then RELEASE_AUTH_HEADER_FILE="$WORK_DIR/release-auth-header" printf 'Authorization: token %s\n' "$RELEASE_TOKEN" > "$RELEASE_AUTH_HEADER_FILE" chmod 0600 "$RELEASE_AUTH_HEADER_FILE" fi if [ -n "$RELEASE_API_URL" ]; then download_release_url "$RELEASE_API_URL" "$WORK_DIR/release-api.json" fi download_release_asset release-public.pem "$WORK_DIR/release-public.pem" ACTUAL_PUBLIC_KEY_SHA256=$(sha256_file "$WORK_DIR/release-public.pem" | tr '[:upper:]' '[:lower:]') EXPECTED_PUBLIC_KEY_SHA256=$(printf '%s' "$TRUSTED_PUBLIC_KEY_SHA256" | tr '[:upper:]' '[:lower:]') [ "$ACTUAL_PUBLIC_KEY_SHA256" = "$EXPECTED_PUBLIC_KEY_SHA256" ] \ || die "Release public-key SHA-256 does not match the trusted fingerprint" download_release_asset release-manifest.json "$WORK_DIR/release-manifest.json" download_release_asset release-manifest.sig "$WORK_DIR/release-manifest.sig" openssl dgst -sha256 -verify "$WORK_DIR/release-public.pem" -signature "$WORK_DIR/release-manifest.sig" \ "$WORK_DIR/release-manifest.json" >/dev/null 2>&1 || die "Release manifest signature verification failed" VERSION=$(jq -er '.version | strings | select(length > 0 and length <= 128)' \ "$WORK_DIR/release-manifest.json") || die "Release version is invalid" is_semver "$VERSION" || die "Release version is invalid" ARTIFACT=$(jq -er '.artifact | strings | select(test("^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}\\.tar\\.gz$"))' \ "$WORK_DIR/release-manifest.json") || die "Release artifact name is invalid" EXPECTED_SHA=$(jq -er '.sha256 | strings | ascii_downcase | select(test("^[0-9a-f]{64}$"))' \ "$WORK_DIR/release-manifest.json") || die "Release SHA-256 is invalid" download_release_asset "$ARTIFACT" "$WORK_DIR/release.tar.gz" ACTUAL_SHA=$(sha256sum "$WORK_DIR/release.tar.gz" | awk '{print $1}') [ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] || die "Release artifact SHA-256 verification failed" tar -tzf "$WORK_DIR/release.tar.gz" > "$WORK_DIR/archive.list" grep -Eq '(^/|(^|/)\.\.(/|$))' "$WORK_DIR/archive.list" \ && die "Release archive contains an unsafe path" mkdir "$WORK_DIR/extracted" tar -xzf "$WORK_DIR/release.tar.gz" -C "$WORK_DIR/extracted" find "$WORK_DIR/extracted" -type l -print -quit | grep -q . \ && die "Release archive must not contain symbolic links" [ -s "$WORK_DIR/extracted/app.jar" ] || die "Release app.jar is missing" [ -s "$WORK_DIR/extracted/public/index.html" ] || die "Release frontend is missing" [ "$(cat "$WORK_DIR/extracted/VERSION")" = "$VERSION" ] || die "Release version mismatch" [ -x "$WORK_DIR/extracted/ops/update.sh" ] || die "Release updater is missing" [ -x "$WORK_DIR/extracted/ops/baota-start.sh" ] || die "Baota Spring Boot launcher is missing" bash -n "$WORK_DIR/extracted/ops/baota-start.sh" || die "Baota Spring Boot launcher is invalid" configure_database preflight_database ensure_service_identity prepare_managed_layout prepare_java RELEASE_DIR="$APP_ROOT/releases/$VERSION" [ ! -e "$RELEASE_DIR" ] || RELEASE_DIR="$APP_ROOT/releases/${VERSION}-reinstall-$(date -u +%Y%m%dT%H%M%SZ)" backup_managed_state activate_java mv "$WORK_DIR/extracted" "$RELEASE_DIR" secure_release_tree "$RELEASE_DIR" ln -sfn "$RELEASE_DIR" "$APP_ROOT/current.next" mv -Tf "$APP_ROOT/current.next" "$APP_ROOT/current" install -m 0755 "$RELEASE_DIR/ops/update.sh" "$APP_ROOT/bin/update.sh" install -m 0644 "$WORK_DIR/release-public.pem" "$CONFIG_ROOT/release-public.pem" if [ "$REINSTALL" = true ]; then FIELD_KEY=$(read_existing_env FIELD_ENCRYPTION_KEY) [ -n "$FIELD_KEY" ] || die "The existing FIELD_ENCRYPTION_KEY is missing" ADMIN_PASSWORD= SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-$(read_existing_env SESSION_COOKIE_SECURE)} FILE_STORAGE_ROOT_VALUE=$(read_existing_env FILE_STORAGE_ROOT) FILE_STORAGE_TEMP_VALUE=$(read_existing_env FILE_STORAGE_TEMP) FILE_SCANNER_ENABLED_VALUE=$(read_existing_env FILE_SCANNER_ENABLED) [ -n "$SESSION_COOKIE_SECURE" ] || SESSION_COOKIE_SECURE=false [ -n "$FILE_STORAGE_ROOT_VALUE" ] || FILE_STORAGE_ROOT_VALUE="$STATE_ROOT/files" [ -n "$FILE_STORAGE_TEMP_VALUE" ] || FILE_STORAGE_TEMP_VALUE="$STATE_ROOT/tmp" [ -n "$FILE_SCANNER_ENABLED_VALUE" ] || FILE_SCANNER_ENABLED_VALUE=false else FIELD_KEY=$(openssl rand -base64 32 | tr -d '\n') if [ "$SETUP_WIZARD" = true ]; then ADMIN_PASSWORD= else ADMIN_PASSWORD="Kaidi!$(random_secret)Aa1" fi SESSION_COOKIE_SECURE=${KAIDI_SESSION_COOKIE_SECURE:-false} FILE_STORAGE_ROOT_VALUE="$STATE_ROOT/files" FILE_STORAGE_TEMP_VALUE="$STATE_ROOT/tmp" FILE_SCANNER_ENABLED_VALUE=${KAIDI_FILE_SCANNER_ENABLED:-false} fi BOOTSTRAP_ENABLED=true SETUP_CODE= SETUP_TOKEN_SHA256= if [ "$REINSTALL" = true ] || [ "$SETUP_WIZARD" = true ]; then BOOTSTRAP_ENABLED=false fi if [ "$SETUP_WIZARD" = true ]; then SETUP_CODE="KD-$(random_secret)" SETUP_TOKEN_SHA256=$(printf '%s' "$SETUP_CODE" | sha256sum | awk '{print $1}') fi write_env_file_preserving_unknown "$CONFIG_ROOT/kaidi.env" \ SPRING_PROFILES_ACTIVE production \ KAIDI_JAVA_BIN "$JAVA_BIN" \ SERVER_PORT "$APP_PORT" \ SERVER_ADDRESS "$SERVER_ADDRESS" \ SESSION_COOKIE_SECURE "$SESSION_COOKIE_SECURE" \ DB_URL "$DB_URL" \ DB_USERNAME "$DB_USERNAME" \ DB_PASSWORD "$DB_PASSWORD" \ FIELD_ENCRYPTION_KEY "$FIELD_KEY" \ FILE_STORAGE_ROOT "$FILE_STORAGE_ROOT_VALUE" \ FILE_STORAGE_TEMP "$FILE_STORAGE_TEMP_VALUE" \ FILE_SCANNER_ENABLED "$FILE_SCANNER_ENABLED_VALUE" \ FINANCE_BOOTSTRAP_ENABLED "$BOOTSTRAP_ENABLED" \ FINANCE_BOOTSTRAP_PASSWORD "$ADMIN_PASSWORD" \ FINANCE_SETUP_ENABLED "$SETUP_WIZARD" \ FINANCE_SETUP_TOKEN_SHA256 "$SETUP_TOKEN_SHA256" \ FINANCE_SETUP_ENV_FILE "$STATE_ROOT/setup/application.env" \ FINANCE_SETUP_MARKER_FILE "$STATE_ROOT/setup/locked" \ FINANCE_SETUP_RESTART_AFTER_COMPLETE true \ APP_VERSION "$VERSION" \ UPDATE_CURRENT_VERSION_FILE "$APP_ROOT/current/VERSION" \ FINANCE_UPDATE_ENABLED true \ UPDATE_RELEASE_BASE_URL "$([ -n "$RELEASE_API_URL" ] && printf '' || printf '%s/' "$BASE")" \ UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \ UPDATE_RELEASE_TOKEN "$RELEASE_TOKEN" \ UPDATE_REQUEST_FILE "$UPDATE_STATE_ROOT/inbox/request.json" \ UPDATE_STATUS_FILE "$UPDATE_STATE_ROOT/status.json" chmod 0600 "$CONFIG_ROOT/kaidi.env" write_env_file "$CONFIG_ROOT/update.env" \ UPDATE_RELEASE_BASE_URL "$([ -n "$RELEASE_API_URL" ] && printf '' || printf '%s/' "$BASE")" \ UPDATE_RELEASE_API_URL "$RELEASE_API_URL" \ UPDATE_RELEASE_TOKEN "$RELEASE_TOKEN" \ UPDATE_REQUEST_FILE "$UPDATE_STATE_ROOT/inbox/request.json" \ UPDATE_STATUS_FILE "$UPDATE_STATE_ROOT/status.json" \ UPDATE_PUBLIC_KEY "$CONFIG_ROOT/release-public.pem" \ KAIDI_APP_ROOT "$APP_ROOT" \ KAIDI_UPDATE_STATE_ROOT "$UPDATE_STATE_ROOT" \ KAIDI_SERVICE_NAME kaidi-finance.service \ KAIDI_JAVA_BIN "$JAVA_BIN" \ KAIDI_HEALTH_URL "$HEALTH_URL" \ KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \ KAIDI_DB_HOST "$(database_host)" \ KAIDI_DB_PORT "$(database_port)" \ KAIDI_DB_NAME "$(database_name)" \ KAIDI_DB_USERNAME "$DB_USERNAME" \ KAIDI_DB_PASSWORD "$DB_PASSWORD" \ KAIDI_MYSQLDUMP_BIN "${KAIDI_MYSQLDUMP_BIN:-}" \ KAIDI_DB_BACKUP_MODE "${KAIDI_DB_BACKUP_MODE:-skip}" chmod 0600 "$CONFIG_ROOT/update.env" install -m 0644 "$RELEASE_DIR/ops/kaidi-finance.service" /etc/systemd/system/kaidi-finance.service install -m 0644 "$RELEASE_DIR/ops/kaidi-update.service" /etc/systemd/system/kaidi-update.service install -m 0644 "$RELEASE_DIR/ops/kaidi-update.path" /etc/systemd/system/kaidi-update.path restore_security_contexts verify_service_access systemd-analyze verify \ /etc/systemd/system/kaidi-finance.service \ /etc/systemd/system/kaidi-update.service \ /etc/systemd/system/kaidi-update.path >/dev/null \ || die "Installed systemd units failed validation" systemctl daemon-reload systemctl reset-failed kaidi-finance.service >/dev/null 2>&1 || true systemctl enable kaidi-finance.service systemctl restart kaidi-finance.service wait_for_health curl -fsS "$APP_INDEX_URL" | grep -Eiq ' "$UPDATE_STATE_ROOT/status.json" chmod 0644 "$UPDATE_STATE_ROOT/status.json" systemctl enable --now kaidi-update.path if [ "$SETUP_WIZARD" = true ]; then cat > /root/kaidi-first-login.txt < /root/kaidi-first-login.txt <