Files
kaidi/deploy/install.sh
T
2026-08-18 07:19:17 +08:00

1041 lines
39 KiB
Bash
Executable File

#!/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_STAGED_DIR=
JAVA_PREVIOUS_DIR=
JAVA_ACTIVATED=false
SERVICE_USER=kaidi
SERVICE_GROUP=kaidi
HOST_ARCH=
log() { printf '[kaidi-install] %s\n' "$*"; }
die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&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"
[[ "$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
apt-get install -y -qq ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
elif command -v dnf >/dev/null 2>&1; then
dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
elif command -v yum >/dev/null 2>&1; then
yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux
else
die "Supported package managers are apt, dnf, and yum"
fi
}
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
if system_home=$(system_java_home); then
JAVA_STAGED_DIR="$WORK_DIR/java.next"
mkdir -p "$JAVA_STAGED_DIR"
log "Copying existing Java 17 runtime from $system_home into the managed application directory"
COPYFILE_DISABLE=1 cp -R "$system_home/." "$JAVA_STAGED_DIR/"
java_line=$("$JAVA_STAGED_DIR/bin/java" -version 2>&1 | head -n 1)
log "Local Java verified: $java_line"
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)
log "Downloaded Java verified: $java_line"
}
activate_java() {
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"
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"
}
prepare_managed_layout() {
command -v runuser >/dev/null 2>&1 || die "runuser is required"
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"
runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \
|| die "$SERVICE_USER cannot execute the managed Java runtime"
runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1 \
|| die "The managed 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] Application port [%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"
DB_URL='jdbc:mysql://setup.invalid:3306/kaidi_finance'
DB_USERNAME=setup_pending
DB_PASSWORD=setup_pending
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
endpoint=${DB_URL#jdbc:mysql://}
host_port=${endpoint%%/*}
printf '%s' "${KAIDI_DB_HOST:-${host_port%%:*}}"
}
database_port() {
local endpoint host_port candidate
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
endpoint=${DB_URL#jdbc:mysql://}
name=${endpoint#*/}
name=${name%%\?*}
printf '%s' "${KAIDI_DB_NAME:-$name}"
}
preflight_database() {
local client host port name version table
[ "$SETUP_WIZARD" != true ] || return 0
case "$DB_URL" in
jdbc:mysql://*) ;;
*) die "KAIDI_DB_URL must start with jdbc:mysql://" ;;
esac
client=$(command -v mysql || command -v mariadb || true)
[ -n "$client" ] || die "A MySQL command-line client is required"
host=$(database_host)
port=$(database_port)
name=$(database_name)
[ -n "$host" ] || die "The database host is empty"
[[ "$port" =~ ^[0-9]{1,5}$ ]] && [ "$port" -ge 1 ] && [ "$port" -le 65535 ] \
|| die "The database port is invalid"
[[ "$name" =~ ^[A-Za-z0-9_$]+$ ]] || die "The database name is invalid"
version=$(MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \
-h "$host" -P "$port" -u "$DB_USERNAME" "$name" --batch --skip-column-names \
-e 'SELECT VERSION()' 2>/dev/null) || die "Cannot connect to the configured MySQL database"
case "$version" in
8.4.*) ;;
*) die "MySQL 8.4.x is required; server reported $version" ;;
esac
table="kaidi_install_preflight_$$"
if ! MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \
-h "$host" -P "$port" -u "$DB_USERNAME" "$name" >/dev/null <<SQL
DROP TABLE IF EXISTS $table;
CREATE TABLE $table (id INT NOT NULL PRIMARY KEY);
INSERT INTO $table (id) VALUES (1);
UPDATE $table SET id = 2 WHERE id = 1;
DELETE FROM $table WHERE id = 2;
DROP TABLE $table;
SQL
then
MYSQL_PWD="$DB_PASSWORD" "$client" --protocol=TCP --connect-timeout=10 \
-h "$host" -P "$port" -u "$DB_USERNAME" "$name" \
-e "DROP TABLE IF EXISTS $table" >/dev/null 2>&1 || true
die "The database account needs DDL and DML permissions on $name"
fi
log "MySQL $version connectivity and DDL/DML permissions verified"
}
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
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
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
configure_app_port
preflight_host
install_packages
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 \
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_HEALTH_URL "$HEALTH_URL" \
KAIDI_APP_INDEX_URL "$APP_INDEX_URL" \
KAIDI_DB_CONTAINER "${KAIDI_DB_CONTAINER:-}" \
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"
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:"Initial release is running",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
URL after reverse proxy: http://SERVER_IP/setup
Reverse proxy target: $PROXY_TARGET
Setup code: $SETUP_CODE
Version: $VERSION
EOF
chmod 0600 /root/kaidi-first-login.txt
elif [ "$REINSTALL" != true ]; then
cat > /root/kaidi-first-login.txt <<EOF
URL after reverse proxy: http://SERVER_IP/
Reverse proxy target: $PROXY_TARGET
Username: admin
Temporary password: $ADMIN_PASSWORD
Version: $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 "$@"