#!/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} SETUP_WIZARD=${KAIDI_SETUP_WIZARD:-true} 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 log() { printf '[kaidi-install] %s\n' "$*"; } die() { printf '[kaidi-install] ERROR: %s\n' "$*" >&2; exit 1; } sha256_file() { sha256sum "$1" | awk '{print $1}' } preflight_host() { [ "$(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" [ -d /run/systemd/system ] || die "systemd is not running as PID 1" [ -d /etc/systemd/system ] || die "/etc/systemd/system is missing" [[ "$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 } [ "$(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://*) curl --fail --silent --show-error --location --proto '=https' --proto-redir '=https' \ --tlsv1.2 "$url" -o "$output" ;; *) [ "${KAIDI_ALLOW_INSECURE_UPDATE:-false}" = "true" ] || die "Release URLs must use HTTPS" curl --fail --silent --show-error --location --proto '=http,https' \ --proto-redir '=http,https' "$url" -o "$output" ;; 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 default-mysql-client elif command -v dnf >/dev/null 2>&1; then dnf install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux mysql elif command -v yum >/dev/null 2>&1; then yum install -y ca-certificates coreutils curl findutils gzip jq openssl tar util-linux mysql else die "Supported package managers are apt, dnf, and yum" fi } azul_arch() { case "$(uname -m)" in x86_64|amd64) printf x86 ;; i386|i486|i586|i686) printf i686 ;; aarch64|arm64) printf arm_64 ;; armv7l|armv6l) printf arm ;; *) die "Unsupported CPU architecture: $(uname -m)" ;; esac } prepare_java() { local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 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_STAGED_DIR/bin/java" -version } activate_java() { mkdir -p "$APP_ROOT/runtime" JAVA_PREVIOUS_DIR="$WORK_DIR/java.previous" if [ -e "$APP_ROOT/runtime/java" ]; then mv "$APP_ROOT/runtime/java" "$JAVA_PREVIOUS_DIR" fi mv "$JAVA_STAGED_DIR" "$APP_ROOT/runtime/java" JAVA_ACTIVATED=true "$APP_ROOT/runtime/java/bin/java" -version } 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-]+)*)?$' \ </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 </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 while [ "$attempts" -lt 60 ]; do if curl -fsS "$HEALTH_URL" | jq -e '.status == "UP"' >/dev/null 2>&1; then return fi attempts=$((attempts + 1)) 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 && PREVIOUS_APP_ACTIVE=true || PREVIOUS_APP_ACTIVE=false systemctl is-enabled --quiet kaidi-finance.service && PREVIOUS_APP_ENABLED=true || PREVIOUS_APP_ENABLED=false systemctl is-active --quiet kaidi-update.path && PREVIOUS_UPDATE_ACTIVE=true || PREVIOUS_UPDATE_ACTIVE=false systemctl is-enabled --quiet kaidi-update.path && 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 [ "$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 || rollback_failed=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" ]; 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" } trap cleanup EXIT 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" prepare_java configure_database preflight_database id kaidi >/dev/null 2>&1 || useradd --system --home "$STATE_ROOT" --shell /usr/sbin/nologin kaidi mkdir -p "$APP_ROOT/releases" "$APP_ROOT/bin" "$STATE_ROOT/files" "$STATE_ROOT/tmp" \ "$STATE_ROOT/setup" "$UPDATE_STATE_ROOT/inbox" "$CONFIG_ROOT" /var/log/kaidi chown -R kaidi:kaidi "$STATE_ROOT" "$UPDATE_STATE_ROOT/inbox" /var/log/kaidi chown root:kaidi "$UPDATE_STATE_ROOT" chmod 0750 "$STATE_ROOT" "$STATE_ROOT/files" "$STATE_ROOT/tmp" "$UPDATE_STATE_ROOT" "$UPDATE_STATE_ROOT/inbox" chmod 0700 "$STATE_ROOT/setup" 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" chown -R root:root "$RELEASE_DIR" chmod -R go-w "$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 systemctl daemon-reload 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 <