#!/usr/bin/env bash set -euo pipefail GITEA_BASE_URL="${ERP_GITEA_BASE_URL:-}" REPOSITORY="${ERP_UPDATE_REPOSITORY:-awaioi/ERP}" REQUESTED_VERSION="${ERP_INSTALL_VERSION:-}" INSTALL_ROOT="${ERP_INSTALL_ROOT:-}" DB_MODE="${ERP_DB_MODE:-existing}" NON_INTERACTIVE="${ERP_INSTALL_NON_INTERACTIVE:-0}" ALLOW_INSECURE="${ERP_UPDATE_ALLOW_INSECURE_HTTP:-0}" NO_SERVICE="${ERP_INSTALL_NO_SERVICE:-0}" TOKEN="${ERP_GITEA_TOKEN:-${OA_UPDATE_TOKEN:-}}" PUBLIC_KEY='-----BEGIN PUBLIC KEY----- MCowBQYDK2VwAyEAaErhcY8WZIZvPILmYnfjndVBAdOuWkvhaoHIWqNNdxI= -----END PUBLIC KEY-----' TMP_DIR="" say() { printf '[ERP Install] %s\n' "$*"; } fail() { printf '[ERP Install] ERROR: %s\n' "$*" >&2; exit 1; } usage() { cat <<'EOF' Usage: install.sh [options] --gitea-url URL Gitea public base URL --repository O/R Release repository --version VERSION Install one exact release --install-root PATH Override installation directory --db-mode MODE existing or local (local is Linux only) --non-interactive Read all database values from ERP_DB_* variables --allow-insecure Development only: allow plain HTTP release URLs --no-service Install files without registering/starting a service EOF } while [[ $# -gt 0 ]]; do case "$1" in --gitea-url) [[ $# -ge 2 ]] || fail '--gitea-url requires a value'; GITEA_BASE_URL="$2"; shift 2 ;; --repository) [[ $# -ge 2 ]] || fail '--repository requires a value'; REPOSITORY="$2"; shift 2 ;; --version) [[ $# -ge 2 ]] || fail '--version requires a value'; REQUESTED_VERSION="$2"; shift 2 ;; --install-root) [[ $# -ge 2 ]] || fail '--install-root requires a value'; INSTALL_ROOT="$2"; shift 2 ;; --db-mode) [[ $# -ge 2 ]] || fail '--db-mode requires a value'; DB_MODE="$2"; shift 2 ;; --non-interactive) NON_INTERACTIVE=1; shift ;; --allow-insecure) ALLOW_INSECURE=1; shift ;; --no-service) NO_SERVICE=1; shift ;; -h|--help) usage; exit 0 ;; *) fail "unknown option: $1" ;; esac done cleanup() { if [[ -n "$TMP_DIR" && -d "$TMP_DIR" ]]; then rm -rf "$TMP_DIR" fi } trap cleanup EXIT require_command() { command -v "$1" >/dev/null 2>&1 || fail "missing command: $1" } detect_platform() { case "$(uname -s)" in Linux) PLATFORM=linux ;; Darwin) PLATFORM=darwin ;; *) fail "unsupported operating system: $(uname -s)" ;; esac case "$(uname -m)" in x86_64|amd64) ARCH=amd64 ;; arm64|aarch64) ARCH=arm64 ;; *) fail "unsupported CPU architecture: $(uname -m); 32-bit systems are not supported" ;; esac if [[ "$PLATFORM" == "linux" && "$(id -u)" != "0" ]]; then fail 'Linux installation requires root; use: curl ... | sudo -E bash' fi } install_dependencies() { if [[ "$PLATFORM" == "darwin" ]] && command -v brew >/dev/null 2>&1; then local java_prefix="" libpq_prefix="" openssl_prefix="" java_prefix="$(brew --prefix openjdk@17 2>/dev/null || true)" libpq_prefix="$(brew --prefix libpq 2>/dev/null || true)" openssl_prefix="$(brew --prefix openssl@3 2>/dev/null || true)" [[ -z "$java_prefix" ]] || export PATH="$java_prefix/bin:$PATH" [[ -z "$libpq_prefix" ]] || export PATH="$libpq_prefix/bin:$PATH" [[ -z "$openssl_prefix" ]] || export PATH="$openssl_prefix/bin:$PATH" fi local need=0 for command in curl tar python3 psql openssl java; do command -v "$command" >/dev/null 2>&1 || need=1 done if [[ "$need" == "1" && "$PLATFORM" == "linux" ]]; then command -v apt-get >/dev/null 2>&1 || fail 'automatic dependency installation currently supports apt-based Linux only' say 'Installing runtime dependencies...' export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y ca-certificates curl tar python3 openssl openjdk-17-jre-headless postgresql-client elif [[ "$need" == "1" ]]; then command -v brew >/dev/null 2>&1 || fail 'Homebrew is required to install missing macOS dependencies' say 'Installing runtime dependencies...' brew install openjdk@17 libpq openssl@3 export PATH="$(brew --prefix openjdk@17)/bin:$(brew --prefix libpq)/bin:$(brew --prefix openssl@3)/bin:$PATH" fi for command in curl tar python3 psql openssl java; do require_command "$command"; done local java_major java_major="$(java -version 2>&1 | awk -F'[".]' '/version/ {print $2; exit}')" [[ "$java_major" =~ ^[0-9]+$ && "$java_major" -ge 17 ]] || fail 'Java 17 or newer is required' local openssl_version algorithms openssl_version="$(openssl version 2>/dev/null || true)" [[ "$openssl_version" =~ ^OpenSSL[[:space:]]3\. ]] \ || fail 'OpenSSL 3 with Ed25519 support is required' algorithms="$(openssl list -public-key-algorithms 2>/dev/null || true)" grep -qi ED25519 <<< "$algorithms" \ || fail 'OpenSSL 3 with Ed25519 support is required' } validate_https_url() { case "$1" in https://*) return 0 ;; http://*) [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]] && return 0 ;; esac fail 'release server must use HTTPS (use --allow-insecure only for local development)' } prompt_value() { local variable="$1" label="$2" default="$3" secret="${4:-0}" value="${!variable:-}" if [[ -z "$value" && "$NON_INTERACTIVE" != "1" ]]; then [[ -r /dev/tty && -w /dev/tty ]] \ || fail "cannot prompt for $label; use --non-interactive with ERP_DB_* variables" if [[ "$secret" == "1" ]]; then IFS= read -r -s -p "$label: " value /dev/tty else IFS= read -r -p "$label [$default]: " value /dev/null 2>&1; then runuser -u postgres -- psql "$@" elif command -v sudo >/dev/null 2>&1; then sudo -u postgres -- psql "$@" else fail 'cannot switch to the postgres operating-system user (runuser or sudo is required)' fi } configure_local_postgres() { [[ "$PLATFORM" == "linux" ]] || fail 'automatic local PostgreSQL provisioning is currently Linux-only' command -v apt-get >/dev/null 2>&1 || fail 'local PostgreSQL provisioning requires apt' require_command systemctl apt-get update apt-get install -y postgresql postgresql-contrib systemctl enable --now postgresql DB_HOST=127.0.0.1 DB_PORT=5432 DB_NAME="${ERP_DB_NAME:-kaidi_erp}" DB_USER="${ERP_DB_USER:-kaidi_erp}" DB_PASSWORD="${ERP_DB_PASSWORD:-}" if [[ -z "$DB_PASSWORD" ]]; then DB_PASSWORD="$(openssl rand -hex 24)" || fail 'unable to generate a PostgreSQL password' fi DB_SSLMODE=disable [[ "$DB_NAME" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid local database name' [[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid local database user' run_as_postgres -v ON_ERROR_STOP=1 -v role="$DB_USER" -v password="$DB_PASSWORD" <<'SQL' SELECT format('CREATE ROLE %I LOGIN PASSWORD %L', :'role', :'password') WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = :'role') \gexec SELECT format('ALTER ROLE %I LOGIN PASSWORD %L', :'role', :'password') \gexec SQL run_as_postgres -v ON_ERROR_STOP=1 -v db="$DB_NAME" -v role="$DB_USER" <<'SQL' SELECT format('CREATE DATABASE %I OWNER %I', :'db', :'role') WHERE NOT EXISTS (SELECT 1 FROM pg_database WHERE datname = :'db') \gexec SQL } configure_existing_postgres() { prompt_value DB_HOST 'PostgreSQL host' "${ERP_DB_HOST:-127.0.0.1}" prompt_value DB_PORT 'PostgreSQL port' "${ERP_DB_PORT:-5432}" prompt_value DB_NAME 'Database name' "${ERP_DB_NAME:-kaidi_erp}" prompt_value DB_USER 'Database user' "${ERP_DB_USER:-kaidi_erp}" prompt_value DB_PASSWORD 'Database password' "${ERP_DB_PASSWORD:-}" 1 prompt_value DB_SSLMODE 'PostgreSQL SSL mode' "${ERP_DB_SSLMODE:-prefer}" [[ -n "$DB_PASSWORD" ]] || fail 'database password is required' } validate_postgres() { [[ "$DB_HOST" != *[[:space:]]* && "$DB_PORT" =~ ^[0-9]+$ ]] || fail 'invalid PostgreSQL host or port' [[ "$DB_NAME" =~ ^[A-Za-z_][A-Za-z0-9_-]*$ ]] || fail 'invalid database name' [[ "$DB_USER" =~ ^[A-Za-z_][A-Za-z0-9_.-]*$ ]] || fail 'invalid database user' case "$DB_SSLMODE" in disable|allow|prefer|require|verify-ca|verify-full) ;; *) fail 'invalid PostgreSQL SSL mode' ;; esac local version version="$(PGPASSWORD="$DB_PASSWORD" PGSSLMODE="$DB_SSLMODE" psql \ -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -Atqc \ "select current_setting('server_version_num')::int")" \ || fail 'cannot connect to PostgreSQL with the supplied credentials' [[ "$version" =~ ^[0-9]+$ && "$version" -ge 150000 ]] || fail 'PostgreSQL 15 or newer is required' PGPASSWORD="$DB_PASSWORD" PGSSLMODE="$DB_SSLMODE" psql \ -h "$DB_HOST" -p "$DB_PORT" -U "$DB_USER" -d "$DB_NAME" -v ON_ERROR_STOP=1 \ -c 'CREATE EXTENSION IF NOT EXISTS pg_trgm' >/dev/null \ || fail 'database user cannot install the required pg_trgm extension' say "PostgreSQL connection verified (${version:0:2})" } create_curl_config() { CURL_CONFIG="$TMP_DIR/curl.conf" { printf 'silent\nshow-error\nfail\nlocation\nconnect-timeout = 15\nmax-time = 900\n' if [[ "$ALLOW_INSECURE" == "1" || "$ALLOW_INSECURE" == "true" ]]; then printf 'proto = "=http,https"\nproto-redir = "=http,https"\n' else printf 'proto = "=https"\nproto-redir = "=https"\n' fi if [[ -n "$TOKEN" ]]; then [[ "$TOKEN" =~ ^[A-Za-z0-9._-]+$ ]] || fail 'invalid Gitea token format' printf 'header = "Authorization: token %s"\n' "$TOKEN" fi } > "$CURL_CONFIG" chmod 600 "$CURL_CONFIG" } download() { validate_https_url "$1" curl --config "$CURL_CONFIG" --output "$2" "$1" } download_release() { [[ "$REPOSITORY" =~ ^[^/[:space:]]+/[^/[:space:]]+$ ]] || fail 'invalid Gitea repository' local owner="${REPOSITORY%%/*}" repo="${REPOSITORY#*/}" local release_json="$TMP_DIR/release.json" download "${GITEA_BASE_URL%/}/api/v1/repos/$owner/$repo/releases/latest" "$release_json" \ || fail 'unable to read latest Gitea Release' local selection="$TMP_DIR/selection" local selection_error="$TMP_DIR/selection-error" if ! python3 - "$release_json" "$REQUESTED_VERSION" > "$selection" 2> "$selection_error" <<'PY' import json, re, sys try: with open(sys.argv[1], encoding="utf-8") as handle: release = json.load(handle) except (OSError, json.JSONDecodeError): raise SystemExit("invalid release JSON") tag = str(release.get("tag_name") or "").strip() match = re.fullmatch(r"v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?)", tag) if not match or release.get("draft") or release.get("prerelease"): raise SystemExit("invalid stable release") version = match.group(1) if "-" in version.split("+", 1)[0]: raise SystemExit("prerelease rejected by stable installer") requested = sys.argv[2].removeprefix("v") if requested and requested != version: raise SystemExit("requested version does not match latest release") assets = {str(a.get("name")): str(a.get("browser_download_url") or "") for a in release.get("assets", [])} names = [f"kaidi-erp-{version}.tar.gz", "SHA256SUMS", "SHA256SUMS.sig"] urls = [assets.get(name, "") for name in names] if any(not url or any(ch.isspace() for ch in url) for url in urls): raise SystemExit("release assets missing") print("\t".join([version, *urls])) PY then local reason="invalid Release metadata" [[ ! -s "$selection_error" ]] || reason="$(<"$selection_error")" fail "$reason" fi local archive_url sums_url signature_url IFS=$'\t' read -r VERSION archive_url sums_url signature_url < "$selection" ARCHIVE_NAME="kaidi-erp-${VERSION}.tar.gz" ARCHIVE_PATH="$TMP_DIR/$ARCHIVE_NAME" download "$archive_url" "$ARCHIVE_PATH" download "$sums_url" "$TMP_DIR/SHA256SUMS" download "$signature_url" "$TMP_DIR/SHA256SUMS.sig" } verify_release() { printf '%s\n' "$PUBLIC_KEY" > "$TMP_DIR/release-public-key.pem" openssl pkeyutl -verify -rawin -pubin -inkey "$TMP_DIR/release-public-key.pem" \ -sigfile "$TMP_DIR/SHA256SUMS.sig" -in "$TMP_DIR/SHA256SUMS" >/dev/null \ || fail 'Release Ed25519 signature verification failed' local expected actual expected="$(python3 - "$TMP_DIR/SHA256SUMS" "$ARCHIVE_NAME" <<'PY' import re, sys for line in open(sys.argv[1], encoding="utf-8"): m = re.fullmatch(r"([0-9a-fA-F]{64})\s+\*?(.+?)\s*", line) if m and m.group(2) == sys.argv[2]: print(m.group(1).lower()) break PY )" [[ "$expected" =~ ^[0-9a-f]{64}$ ]] || fail 'release checksum is missing' if command -v sha256sum >/dev/null 2>&1; then actual="$(sha256sum "$ARCHIVE_PATH" | awk '{print $1}')" else actual="$(shasum -a 256 "$ARCHIVE_PATH" | awk '{print $1}')" fi [[ "$actual" == "$expected" ]] || fail 'release checksum verification failed' python3 - "$ARCHIVE_PATH" <<'PY' import pathlib, sys, tarfile with tarfile.open(sys.argv[1], "r:gz") as archive: for item in archive.getmembers(): path = pathlib.PurePosixPath(item.name) if path.is_absolute() or ".." in path.parts or item.issym() or item.islnk(): raise SystemExit(f"unsafe archive member: {item.name}") PY } shell_setting() { printf '%s=' "$1" printf '%q\n' "$2" } prepare_layout() { if [[ "$PLATFORM" == "linux" ]]; then ERP_USER="${ERP_SERVICE_USER:-kaidi-erp}" ERP_GROUP="$ERP_USER" id "$ERP_USER" >/dev/null 2>&1 || useradd --system --home-dir "$INSTALL_ROOT" --shell /usr/sbin/nologin "$ERP_USER" CONFIG_ROOT="${ERP_CONFIG_ROOT:-/etc/kaidi-erp}" STATE_ROOT="${ERP_STATE_ROOT:-/var/lib/kaidi-erp}" LOG_ROOT="${ERP_LOG_ROOT:-/var/log/kaidi-erp}" local path for path in "$INSTALL_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT"; do [[ "$path" =~ ^/[A-Za-z0-9._/@:+-]+$ ]] \ || fail "Linux installation paths must be absolute and contain only safe characters: $path" done [[ "$ERP_USER" =~ ^[a-z_][a-z0-9_-]*[$]?$ ]] || fail 'invalid Linux service user name' else ERP_USER="$(id -un)" ERP_GROUP="$(id -gn)" CONFIG_ROOT="${ERP_CONFIG_ROOT:-$INSTALL_ROOT/config}" STATE_ROOT="${ERP_STATE_ROOT:-$INSTALL_ROOT/state}" LOG_ROOT="${ERP_LOG_ROOT:-$HOME/Library/Logs/KaidiERP}" fi mkdir -p "$INSTALL_ROOT/releases" "$INSTALL_ROOT/run" "$INSTALL_ROOT/backups" \ "$CONFIG_ROOT" "$STATE_ROOT" "$LOG_ROOT" if [[ "$PLATFORM" == "linux" ]]; then chown -R "$ERP_USER:$ERP_GROUP" "$INSTALL_ROOT" "$STATE_ROOT" "$LOG_ROOT" fi } install_release_files() { local unpack="$TMP_DIR/unpack" mkdir -p "$unpack" tar -xzf "$ARCHIVE_PATH" -C "$unpack" local source="$unpack/kaidi-erp-${VERSION}" [[ -r "$source/app/kaidi-erp.jar" && -x "$source/bin/erp-run" && -x "$source/bin/erp-update" ]] \ || fail 'release archive is incomplete' [[ "$(<"$source/VERSION")" == "$VERSION" ]] || fail 'release archive version mismatch' local destination="$INSTALL_ROOT/releases/$VERSION" local replaced="" if [[ -e "$destination" ]]; then replaced="${destination}.replaced-$$" mv "$destination" "$replaced" fi if ! mv "$source" "$destination"; then [[ -z "$replaced" || ! -e "$replaced" ]] || mv "$replaced" "$destination" fail 'unable to write the release directory' fi [[ -z "$replaced" ]] || rm -rf "$replaced" python3 - "$INSTALL_ROOT/current" "releases/$VERSION" <<'PY' import os, sys link, target = sys.argv[1:] tmp = f"{link}.new-{os.getpid()}" try: os.symlink(target, tmp) os.replace(tmp, link) finally: if os.path.lexists(tmp): os.unlink(tmp) PY if [[ "$PLATFORM" == "linux" ]]; then chown -R "$ERP_USER:$ERP_GROUP" "$destination" "$INSTALL_ROOT/current" fi } write_configuration() { CONFIG_FILE="$CONFIG_ROOT/erp.env" PUBLIC_KEY_FILE="$CONFIG_ROOT/release-public-key.pem" local update_enabled=true [[ "$NO_SERVICE" != "1" ]] || update_enabled=false printf '%s\n' "$PUBLIC_KEY" > "$PUBLIC_KEY_FILE" { shell_setting ERP_INSTALL_ROOT "$INSTALL_ROOT" shell_setting ERP_CONFIG_FILE "$CONFIG_FILE" shell_setting ERP_RUN_DIR "$INSTALL_ROOT/run" shell_setting ERP_STATE_FILE "$STATE_ROOT/update-state.json" shell_setting ERP_HEALTH_URL "http://127.0.0.1:${ERP_SERVER_PORT:-8091}/api/oa/health" shell_setting ERP_UPDATE_PUBLIC_KEY_FILE "$PUBLIC_KEY_FILE" shell_setting ERP_UPDATE_REQUIRE_SIGNATURE true shell_setting ERP_UPDATE_BACKUP_MODE "${ERP_UPDATE_BACKUP_MODE:-none}" shell_setting ERP_UPDATE_HEALTH_TIMEOUT_SECONDS "${ERP_UPDATE_HEALTH_TIMEOUT_SECONDS:-120}" shell_setting ERP_UPDATE_HEALTH_POLL_SECONDS "${ERP_UPDATE_HEALTH_POLL_SECONDS:-2}" shell_setting ERP_PGHOST "$DB_HOST" shell_setting ERP_PGPORT "$DB_PORT" shell_setting ERP_PGDATABASE "$DB_NAME" shell_setting ERP_PGSSLMODE "$DB_SSLMODE" shell_setting SPRING_PROFILES_ACTIVE postgres shell_setting SERVER_PORT "${ERP_SERVER_PORT:-8091}" shell_setting OA_DB_URL "jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME}?sslmode=${DB_SSLMODE}" shell_setting OA_DB_USERNAME "$DB_USER" shell_setting OA_DB_PASSWORD "$DB_PASSWORD" shell_setting OA_UPDATE_ENABLED "$update_enabled" shell_setting OA_UPDATE_GITEA_BASE_URL "$GITEA_BASE_URL" shell_setting OA_UPDATE_REPOSITORY "$REPOSITORY" shell_setting OA_UPDATE_CHANNEL stable shell_setting OA_UPDATE_TOKEN "$TOKEN" shell_setting OA_UPDATE_HELPER_COMMAND "$INSTALL_ROOT/current/bin/erp-update" shell_setting OA_UPDATE_STATE_FILE "$STATE_ROOT/update-state.json" shell_setting OA_UPDATE_ALLOW_INSECURE_HTTP "$ALLOW_INSECURE" } > "$CONFIG_FILE" if [[ "$PLATFORM" == "linux" ]]; then chown root:"$ERP_GROUP" "$CONFIG_FILE" "$PUBLIC_KEY_FILE" chmod 640 "$CONFIG_FILE" "$PUBLIC_KEY_FILE" else chmod 600 "$CONFIG_FILE" "$PUBLIC_KEY_FILE" fi } install_service() { [[ "$NO_SERVICE" == "1" ]] && return 0 if [[ "$PLATFORM" == "linux" ]]; then cat > /etc/systemd/system/kaidi-erp.service </dev/null 2>&1 || true launchctl bootstrap "gui/$(id -u)" "$plist" fi } wait_for_health() { [[ "$NO_SERVICE" == "1" ]] && return 0 local url="http://127.0.0.1:${ERP_SERVER_PORT:-8091}/api/oa/health" deadline=$((SECONDS + 120)) while (( SECONDS < deadline )); do if curl -fsS --connect-timeout 2 --max-time 5 "$url" >/dev/null 2>&1; then return 0 fi sleep 2 done fail 'ERP service did not become healthy within 120 seconds' } main() { detect_platform if [[ -z "$INSTALL_ROOT" ]]; then if [[ "$PLATFORM" == "linux" ]]; then INSTALL_ROOT=/opt/kaidi-erp else INSTALL_ROOT="$HOME/Library/Application Support/KaidiERP" fi fi [[ -n "$GITEA_BASE_URL" ]] || fail 'Gitea URL is required; use --gitea-url or ERP_GITEA_BASE_URL' validate_https_url "$GITEA_BASE_URL" install_dependencies case "$DB_MODE" in existing) configure_existing_postgres ;; local) configure_local_postgres ;; *) fail 'db mode must be existing or local' ;; esac validate_postgres TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-install.XXXXXX")" create_curl_config say 'Downloading signed release...' download_release verify_release prepare_layout install_release_files write_configuration install_service wait_for_health say "Installed Kaidi ERP $VERSION" say "Local URL: http://127.0.0.1:${ERP_SERVER_PORT:-8091}" } if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then main "$@" fi