Files
kaidi/deploy/install.sh
T
2026-08-18 22:51:35 +08:00

1222 lines
53 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
set -Eeuo pipefail
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-]+)*)?$' \
<<EOF
$1
EOF
}
configure_database() {
if [ "$SETUP_WIZARD" = true ]; then
[ -z "${KAIDI_DB_URL:-}${KAIDI_DB_USERNAME:-}${KAIDI_DB_PASSWORD:-}" ] \
|| die "Do not pass database credentials when KAIDI_SETUP_WIZARD=true; enter them in the browser wizard"
# First-run mode does not create a datasource. Keep database fields empty
# so hosting panels cannot mistake a placeholder for a local MySQL service.
DB_URL=
DB_USERNAME=
DB_PASSWORD=
return 0
fi
if [ "$REINSTALL" = true ]; then
[ -s "$CONFIG_ROOT/kaidi.env" ] || die "KAIDI_REINSTALL needs the existing $CONFIG_ROOT/kaidi.env"
DB_USERNAME=${KAIDI_DB_USERNAME:-$(read_reinstall_env DB_USERNAME)}
DB_PASSWORD=${KAIDI_DB_PASSWORD:-$(read_reinstall_env DB_PASSWORD)}
DB_URL=${KAIDI_DB_URL:-$(read_reinstall_env DB_URL)}
else
DB_USERNAME=${KAIDI_DB_USERNAME:-}
DB_PASSWORD=${KAIDI_DB_PASSWORD:-}
DB_URL=${KAIDI_DB_URL:-}
fi
if [ -n "$DB_URL" ]; then
[ -n "$DB_USERNAME" ] && [ -n "$DB_PASSWORD" ] \
|| die "KAIDI_DB_USERNAME and KAIDI_DB_PASSWORD are required with KAIDI_DB_URL"
return 0
fi
[ "$REINSTALL" != true ] \
|| die "KAIDI_REINSTALL needs database credentials in the existing installation or command environment"
die "An external MySQL 8.4 database is required; set KAIDI_DB_URL, KAIDI_DB_USERNAME, and KAIDI_DB_PASSWORD"
}
database_host() {
local endpoint host_port
[ -n "$DB_URL" ] || { printf ''; return; }
endpoint=${DB_URL#jdbc:mysql://}
host_port=${endpoint%%/*}
printf '%s' "${KAIDI_DB_HOST:-${host_port%%:*}}"
}
database_port() {
local endpoint host_port candidate
[ -n "$DB_URL" ] || { printf ''; return; }
endpoint=${DB_URL#jdbc:mysql://}
host_port=${endpoint%%/*}
candidate=${host_port##*:}
[ "$candidate" != "$host_port" ] || candidate=3306
printf '%s' "${KAIDI_DB_PORT:-$candidate}"
}
database_name() {
local endpoint name
[ -n "$DB_URL" ] || { printf ''; return; }
endpoint=${DB_URL#jdbc:mysql://}
name=${endpoint#*/}
name=${name%%\?*}
printf '%s' "${KAIDI_DB_NAME:-$name}"
}
validate_database_configuration() {
[ "$SETUP_WIZARD" != true ] || return 0
case "$DB_URL" in
jdbc:mysql://*) ;;
*) die "KAIDI_DB_URL must start with jdbc:mysql://" ;;
esac
[ -n "$DB_USERNAME" ] || die "KAIDI_DB_USERNAME is empty"
[ -n "$DB_PASSWORD" ] || die "KAIDI_DB_PASSWORD is empty"
[ -n "$(database_host)" ] || die "The database host is empty"
[[ "$(database_port)" =~ ^[0-9]{1,5}$ ]] \
&& [ "$(database_port)" -ge 1 ] && [ "$(database_port)" -le 65535 ] \
|| die "The database port is invalid"
[[ "$(database_name)" =~ ^[A-Za-z0-9_$]+$ ]] || die "The database name is invalid"
# The installer deliberately does not invoke mysql/mariadb and does not
# execute DDL/DML. The application performs Flyway migrations only after
# it has connected to the operator-provided schema.
log "已记录外部 MySQL 连接配置;安装器不安装 MySQL、不连接数据库、不执行 SQL"
}
# Kept as a compatibility name for older local fixtures and wrappers. It is
# now a pure configuration check and has no database side effects.
preflight_database() {
validate_database_configuration
}
validate_database_backup_mode() {
case "${KAIDI_DB_BACKUP_MODE:-skip}" in
skip|mysqldump) ;;
*) die "KAIDI_DB_BACKUP_MODE must be skip or mysqldump" ;;
esac
}
write_env_file() {
local output=$1 temporary name value escaped
shift
temporary="${output}.next.$$"
: > "$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 '<!doctype|<html' \
|| die "Application frontend entry point is unavailable"
if [ "$SETUP_WIZARD" != true ]; then
sed -i 's/^FINANCE_BOOTSTRAP_ENABLED="true"$/FINANCE_BOOTSTRAP_ENABLED="false"/' "$CONFIG_ROOT/kaidi.env"
sed -i 's/^FINANCE_BOOTSTRAP_PASSWORD=.*$/FINANCE_BOOTSTRAP_PASSWORD=""/' "$CONFIG_ROOT/kaidi.env"
fi
jq -n --arg version "$VERSION" --arg updatedAt "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
'{state:"CURRENT",message:"初始版本正在运行",targetVersion:$version,updatedAt:$updatedAt}' \
> "$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 <<EOF
反向代理访问地址:http://SERVER_IP/setup
反向代理目标:$PROXY_TARGET
安装码:$SETUP_CODE
版本:$VERSION
EOF
chmod 0600 /root/kaidi-first-login.txt
elif [ "$REINSTALL" != true ]; then
cat > /root/kaidi-first-login.txt <<EOF
反向代理访问地址:http://SERVER_IP/
反向代理目标:$PROXY_TARGET
管理员账号:admin
临时密码:$ADMIN_PASSWORD
版本:$VERSION
EOF
chmod 0600 /root/kaidi-first-login.txt
fi
INSTALL_COMMITTED=true
INSTALL_TRANSACTION_ARMED=false
log "Kaidi Finance $VERSION is installed"
if [ "$SETUP_WIZARD" = true ]; then
log "Configure your reverse proxy to $PROXY_TARGET"
log "Open /setup through your reverse-proxy domain and complete the first-run wizard"
log "Setup code: /root/kaidi-first-login.txt"
else
log "Configure your reverse proxy to $PROXY_TARGET"
log "Open the reverse-proxy domain and sign in as admin"
fi
if [ "$REINSTALL" != true ] && [ "$SETUP_WIZARD" != true ]; then
log "Temporary credentials: /root/kaidi-first-login.txt"
log "Change the temporary password immediately after first sign-in"
fi
}
main "$@"