diff --git a/README.md b/README.md index 7b765c9..e340d22 100644 --- a/README.md +++ b/README.md @@ -59,14 +59,14 @@ PostgreSQL 18。PostgreSQL 兼容开发已冻结,不属于本次 Preview.12 ### 直接 curl 安装 ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.15/install.sh | sudo bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.16/install.sh | sudo bash ``` 这条命令会提示填写 Java 应用端口,直接回车使用 `18080`;随后安装最新签名 Release,并默认进入 `/setup` 安装向导。Java 默认只监听 `127.0.0.1:所选端口`,前端页面、API 和健康检查均由同一端口提供。无人值守安装可直接指定: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.15/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.16/install.sh \ | sudo env KAIDI_APP_PORT=19090 bash ``` @@ -74,8 +74,8 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe 时才设置 `KAIDI_SERVER_ADDRESS=0.0.0.0`,通常应保持默认回环绑定并由本机反向代理访问。需要在执行前独立校验安装脚本时使用: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.15/install.sh -o /tmp/kaidi-install.sh -printf '%s %s\n' 8a75ee99a1c2f426c11ea18c8ef65f4f4ac3f8d52321c358f3ff645baa6ad99c /tmp/kaidi-install.sh | sha256sum -c - +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.16/install.sh -o /tmp/kaidi-install.sh +printf '%s %s\n' bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7 /tmp/kaidi-install.sh | sha256sum -c - sudo bash /tmp/kaidi-install.sh rm -f /tmp/kaidi-install.sh ``` @@ -86,7 +86,7 @@ rm -f /tmp/kaidi-install.sh 包装器会在 `sudo` 前校验 `deploy/install.sh` 的固定 SHA-256,再按同一公钥信任链安装最新签名 Release。 ```bash -git clone --branch v1.0.0-preview.15 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview +git clone --branch v1.0.0-preview.16 --depth 1 https://git.awaioi.com/ERP-Team/kaidi.git kaidi-preview cd kaidi-preview ./deploy/install-from-git.sh ``` @@ -141,7 +141,7 @@ sudo cat /root/kaidi-first-login.txt 32 位服务端镜像,因此 32 位主机需要预先连接一台 MySQL 8.4 数据库,之后仍然只执行一个安装命令: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.15/install.sh | sudo bash +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.16/install.sh | sudo bash ``` 无论主机架构如何,安装器都不会安装 MySQL、数据库客户端或创建数据库容器。在打开向导前,需要预先创建 `kaidi_finance`,并授予安装账号该库的 @@ -161,7 +161,7 @@ GRANT ALL PRIVILEGES ON kaidi_finance.* TO 'kaidi'@'KAIDI_SERVER_IP'; 管理员数据和运维人员新增的环境变量: ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.15/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.16/install.sh \ | sudo env KAIDI_REINSTALL=true KAIDI_SETUP_WIZARD=false bash ``` @@ -171,7 +171,7 @@ curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-previe 如果安装器已完成但向导尚未提交,可执行下面的命令重新生成一次性安装码;该恢复路径只接受仍处于向导模式且未锁定的安装,正式模式不会被覆盖。 ```bash -curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.15/install.sh \ +curl -fsSL https://git.awaioi.com/ERP-Team/kaidi/releases/download/v1.0.0-preview.16/install.sh \ | sudo env KAIDI_REINSTALL=true bash ``` @@ -207,8 +207,8 @@ act_runner 提供 `ubuntu-24.04` 标签,并在 tag 发布时执行后端、前 Preview 属性由 SemVer 版本名表达。之后推送 tag 即会构建、测试、签名并发布: ```bash -git tag v1.0.0-preview.15 -git push origin v1.0.0-preview.15 +git tag v1.0.0-preview.16 +git push origin v1.0.0-preview.16 ``` 在线更新使用独立的 TDesign 页面:隔离的系统管理员进入“系统治理 → 系统更新”。权限与配置页只管理用户、角色、数据范围、表单模板和参数版本,不配置系统名称或域名。 @@ -260,7 +260,7 @@ cat /var/lib/kaidi-update/status.json ```bash KAIDI_RELEASE_SIGNING_KEY=/secure/release-signing-private.pem \ KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ - ./scripts/package-release.sh 1.0.0-preview.15 + ./scripts/package-release.sh 1.0.0-preview.16 KAIDI_TRUSTED_RELEASE_PUBLIC_KEY_SHA256=807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9 \ ./scripts/verify-release.sh dist/release ``` diff --git a/deploy/install-from-git.sh b/deploy/install-from-git.sh index d91443c..f518a51 100755 --- a/deploy/install-from-git.sh +++ b/deploy/install-from-git.sh @@ -5,7 +5,7 @@ umask 077 ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) INSTALLER="$ROOT/deploy/install.sh" -INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-8a75ee99a1c2f426c11ea18c8ef65f4f4ac3f8d52321c358f3ff645baa6ad99c} +INSTALLER_SHA256=${KAIDI_INSTALLER_SHA256:-bc9001197af843dc323907a98023064ccc4f184e000543d2086b8a6f8161dbc7} RELEASE_API_URL=${KAIDI_RELEASE_API_URL:-https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest} PUBLIC_KEY_SHA256=${KAIDI_RELEASE_PUBLIC_KEY_SHA256:-807c6aec1dc3f7ce494db16aa9d763c66f292033c38f328afd0390d2715a8cd9} TOKEN_FILE=${KAIDI_RELEASE_TOKEN_FILE:-} diff --git a/deploy/install.sh b/deploy/install.sh index 78d3e70..b4401b2 100755 --- a/deploy/install.sh +++ b/deploy/install.sh @@ -28,6 +28,9 @@ 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; } @@ -36,14 +39,42 @@ 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 @@ -62,28 +93,31 @@ preflight_host() { [ ! -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" } -[ "$(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" +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 @@ -170,12 +204,26 @@ install_packages() { } 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)" ;; + 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 } @@ -194,6 +242,7 @@ system_java_home() { | 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} ;; @@ -204,12 +253,14 @@ system_java_home() { } prepare_java() { - local api java_metadata package_metadata package_uuid java_url java_sha256 actual_sha256 system_home + 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" - ln -s "$system_home" "$JAVA_STAGED_DIR" - log "Using existing Java 17 runtime at $system_home" - "$JAVA_STAGED_DIR/bin/java" -version + 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" @@ -231,18 +282,88 @@ prepare_java() { [ "$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 + 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" ]; then + 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 - "$APP_ROOT/runtime/java/bin/java" -version +} + +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" +} + +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; } @@ -599,6 +720,12 @@ backup_managed_state() { 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 @@ -631,7 +758,7 @@ 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 + 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)) @@ -639,7 +766,7 @@ rollback_install() { if [ -n "$RELEASE_DIR" ]; then rm -rf -- "$RELEASE_DIR" || rollback_failed=true fi - if [ -d "$JAVA_PREVIOUS_DIR" ]; then + 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 @@ -666,8 +793,10 @@ cleanup() { exit "$result" } +main() { trap cleanup EXIT +validate_inputs configure_app_port preflight_host install_packages @@ -714,25 +843,18 @@ find "$WORK_DIR/extracted" -type l -print -quit | grep -q . \ [ "$(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" +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" -chown -R root:root "$RELEASE_DIR" -chmod -R go-w "$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" @@ -824,7 +946,15 @@ 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 @@ -875,3 +1005,6 @@ 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 "$@" diff --git a/deploy/systemd/kaidi-finance.service b/deploy/systemd/kaidi-finance.service index 5d15e5c..7f9e839 100644 --- a/deploy/systemd/kaidi-finance.service +++ b/deploy/systemd/kaidi-finance.service @@ -2,6 +2,8 @@ Description=Kaidi Finance System After=network-online.target Wants=network-online.target +StartLimitIntervalSec=60 +StartLimitBurst=3 [Service] Type=simple diff --git a/deploy/update.sh b/deploy/update.sh index 456e5af..8dcec5a 100755 --- a/deploy/update.sh +++ b/deploy/update.sh @@ -18,6 +18,8 @@ RELEASE_API_URL=${UPDATE_RELEASE_API_URL:-} RELEASE_TOKEN=${UPDATE_RELEASE_TOKEN:-} CACHE_ROOT=${KAIDI_UPDATE_CACHE_ROOT:-$STATE_ROOT/cache} SERVICE_NAME=${KAIDI_SERVICE_NAME:-kaidi-finance.service} +SERVICE_USER=${KAIDI_SERVICE_USER:-kaidi} +SERVICE_GROUP=${KAIDI_SERVICE_GROUP:-kaidi} HEALTH_URL=${KAIDI_HEALTH_URL:-http://127.0.0.1:18080/actuator/health} APP_INDEX_URL=${KAIDI_APP_INDEX_URL:-http://127.0.0.1:18080/} LOCK_FILE=$STATE_ROOT/update.lock @@ -38,9 +40,10 @@ case "$HEALTH_ATTEMPTS:$HEALTH_INTERVAL_SECONDS" in esac [ "$HEALTH_ATTEMPTS" -ge 1 ] || { printf '%s\n' "Update health-check attempts must be at least 1" >&2; exit 1; } -mkdir -p "$STATE_ROOT/inbox" "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" \ - "$STATE_ROOT/work" "$APP_ROOT/releases" "$LOG_ROOT" "$BACKUP_ROOT" "$CACHE_ROOT" -chmod 0700 "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" "$BACKUP_ROOT" "$CACHE_ROOT" +bootstrap_die() { + printf '%s\n' "$1" >&2 + exit 1 +} status() { state=$1 @@ -77,6 +80,43 @@ fail() { exit 1 } +prepare_update_layout() { + id "$SERVICE_USER" >/dev/null 2>&1 || bootstrap_die "Service user $SERVICE_USER is missing" + id -nG "$SERVICE_USER" | tr ' ' '\n' | grep -Fxq "$SERVICE_GROUP" \ + || bootstrap_die "Service user $SERVICE_USER is not a member of group $SERVICE_GROUP" + command -v runuser >/dev/null 2>&1 || bootstrap_die "runuser is required" + install -d -o root -g "$SERVICE_GROUP" -m 0750 \ + "$APP_ROOT" "$APP_ROOT/releases" "$APP_ROOT/runtime" "$APP_ROOT/bin" "$STATE_ROOT" \ + || bootstrap_die "Managed application or update directories could not be prepared" + install -d -o "$SERVICE_USER" -g "$SERVICE_GROUP" -m 0750 "$STATE_ROOT/inbox" "$LOG_ROOT" \ + || bootstrap_die "Writable update directories could not be prepared" + install -d -o root -g root -m 0700 \ + "$PROCESSING_DIR" "$FAILED_REQUEST_ROOT" "$TRANSACTION_ROOT" "$STATE_ROOT/work" \ + "$BACKUP_ROOT" "$CACHE_ROOT" \ + || bootstrap_die "Private update directories could not be prepared" +} + +secure_release_tree() { + release_dir=$1 + chown -R root:"$SERVICE_GROUP" "$release_dir" || return 1 + find "$release_dir" -type d -exec chmod 0750 {} + || return 1 + find "$release_dir" -type f -exec chmod 0640 {} + || return 1 + chmod 0750 "$release_dir/ops/update.sh" +} + +verify_release_access() { + release_dir=$1 + # 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 "$release_dir" \ + || return 1 + runuser -u "$SERVICE_USER" -- test -x "$APP_ROOT/runtime/java/bin/java" \ + || return 1 + runuser -u "$SERVICE_USER" -- "$APP_ROOT/runtime/java/bin/java" -version >/dev/null 2>&1 +} + +prepare_update_layout + # shellcheck disable=SC2329 # Invoked by the EXIT trap below. cleanup() { rc=$? @@ -329,6 +369,7 @@ rollback_active_transaction() { rollback_ok=false fi restore_operations || rollback_ok=false + systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true systemctl start "$SERVICE_NAME" || rollback_ok=false if [ "$rollback_ok" = true ] && verify_app_surface; then remove_failed_release "$failed_release" @@ -520,8 +561,14 @@ if [ -e "$RELEASE_DIR" ]; then rm -rf "$RELEASE_DIR" || fail "Failed release staging directory could not be cleaned" fi mv "$WORK_DIR/extracted" "$RELEASE_DIR" || fail "Release directory could not be activated" -chown -R root:root "$RELEASE_DIR" || fail "Release ownership could not be secured" -chmod -R go-w "$RELEASE_DIR" || fail "Release permissions could not be secured" +secure_release_tree "$RELEASE_DIR" || fail "Release ownership or permissions could not be secured" +if command -v restorecon >/dev/null 2>&1; then + restorecon -RF "$APP_ROOT" >/dev/null 2>&1 || true +fi +if ! verify_release_access "$RELEASE_DIR"; then + rm -rf "$RELEASE_DIR" + fail "Service user cannot access the release or managed Java runtime" +fi PREVIOUS_TARGET=$(readlink "$APP_ROOT/current" 2>/dev/null || true) mkdir "$ACTIVE_TRANSACTION" @@ -552,6 +599,7 @@ write_transaction_value phase APP_SWITCHED status RUNNING "Starting and verifying release $TARGET_VERSION" "$TARGET_VERSION" write_transaction_value phase HEALTH_CHECKING +systemctl reset-failed "$SERVICE_NAME" >/dev/null 2>&1 || true if systemctl start "$SERVICE_NAME" \ && systemctl is-active --quiet kaidi-update.path \ && verify_app_surface; then diff --git a/scripts/test-install-fixture.sh b/scripts/test-install-fixture.sh index 172d399..3d6146b 100755 --- a/scripts/test-install-fixture.sh +++ b/scripts/test-install-fixture.sh @@ -10,6 +10,10 @@ fail() { exit 1 } +mode_of() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} + # Load only pure helper functions. The installer itself must never run in this fixture. { sed -n '/^decode_env_value()/,/^}/p' "$ROOT/deploy/install.sh" @@ -22,7 +26,9 @@ fail() { sed -n '/^configure_app_port()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^write_env_file_preserving_unknown()/,/^}/p' "$ROOT/deploy/install.sh" + sed -n '/^normalized_host_arch()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^azul_arch()/,/^}/p' "$ROOT/deploy/install.sh" + sed -n '/^java_arch_matches_host()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^system_java_home()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^sha256_file()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^prepare_java()/,/^}/p' "$ROOT/deploy/install.sh" @@ -31,6 +37,8 @@ fail() { sed -n '/^download_release_asset()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^install_packages()/,/^}/p' "$ROOT/deploy/install.sh" sed -n '/^preflight_database()/,/^}/p' "$ROOT/deploy/install.sh" + sed -n '/^secure_release_tree()/,/^}/p' "$ROOT/deploy/install.sh" + sed -n '/^restore_unit_state()/,/^}/p' "$ROOT/deploy/install.sh" } > "$WORK/helpers.sh" # shellcheck disable=SC1090,SC1091 source "$WORK/helpers.sh" @@ -38,6 +46,35 @@ source "$WORK/helpers.sh" export SETUP_WIZARD=true preflight_database || fail 'setup wizard database preflight returned a failure status' +release_permissions="$WORK/release-permissions" +mkdir -p "$release_permissions/public" "$release_permissions/ops" +printf 'jar\n' > "$release_permissions/app.jar" +printf 'html\n' > "$release_permissions/public/index.html" +printf '#!/bin/sh\n' > "$release_permissions/ops/update.sh" +chmod -R 0777 "$release_permissions" +( + # shellcheck disable=SC2329 # Invoked indirectly by the sourced installer helper. + chown() { return 0; } + export SERVICE_GROUP=fixture + secure_release_tree "$release_permissions" +) +[ "$(mode_of "$release_permissions")" = 750 ] || fail 'release root mode is not 0750' +[ "$(mode_of "$release_permissions/public")" = 750 ] || fail 'release directory mode is not 0750' +[ "$(mode_of "$release_permissions/app.jar")" = 640 ] || fail 'release file mode is not 0640' +[ "$(mode_of "$release_permissions/ops/update.sh")" = 750 ] || fail 'release updater mode is not 0750' + +( + # shellcheck disable=SC2329 # Invoked indirectly by the sourced installer helper. + systemctl() { + case "$1" in + cat) return 1 ;; + reset-failed) return 0 ;; + *) return 97 ;; + esac + } + restore_unit_state missing.service false false +) || fail 'rollback treated an absent first-install unit as an incomplete restoration' + # shellcheck disable=SC2034 # Referenced by the extracted installer helper. REINSTALL=true # shellcheck disable=SC2034 # Referenced by the extracted installer helper. @@ -137,10 +174,23 @@ for arch in i386 i486 i586 i686; do ARCH_FIXTURE=$arch [ "$(azul_arch)" = i686 ] || fail "$arch did not map to the Azul i686 runtime" done +( + ARCH_FIXTURE=x86_64 + # shellcheck disable=SC2329 # Invoked indirectly by the sourced architecture helper. + getconf() { printf '32\n'; } + [ "$(normalized_host_arch)" = x86 ] \ + || fail '32-bit userspace on an x86_64 kernel was not normalized to x86' + export HOST_ARCH=x86 + [ "$(azul_arch)" = i686 ] \ + || fail '32-bit userspace on an x86_64 kernel did not select the i686 Java runtime' +) mkdir -p "$WORK/fake-jre/bin" cat > "$WORK/fake-jre/bin/java" <<'JAVA' #!/usr/bin/env sh +if [ "${1:-}" = '-XshowSettings:properties' ]; then + printf ' os.arch = x86\n' >&2 +fi printf 'openjdk version "17-fixture"\n' >&2 JAVA chmod 0755 "$WORK/fake-jre/bin/java" @@ -168,9 +218,10 @@ prepare_java >/dev/null 2>&1 rm -rf "$JAVA_STAGED_DIR" export KAIDI_JAVA_HOME="$WORK/fake-jre" prepare_java >/dev/null 2>&1 -[ -L "$JAVA_STAGED_DIR" ] || fail 'existing Java 17 runtime was not reused' -[ "$(readlink -f "$JAVA_STAGED_DIR")" = "$(readlink -f "$WORK/fake-jre")" ] \ - || fail 'installer linked an unexpected local Java runtime' +[ -d "$JAVA_STAGED_DIR" ] && [ ! -L "$JAVA_STAGED_DIR" ] \ + || fail 'existing Java 17 runtime was not copied into managed storage' +cmp -s "$WORK/fake-jre/bin/java" "$JAVA_STAGED_DIR/bin/java" \ + || fail 'installer staged an unexpected local Java runtime' export RELEASE_API_URL=https://gitea.fixture.invalid/api/v1/repos/ERP-Team/kaidi/releases/latest RELEASE_TOKEN=fixture-read-only-token @@ -237,14 +288,35 @@ grep -Fq 'download "$api" "$java_metadata"' "$ROOT/deploy/install.sh" \ # shellcheck disable=SC2016 # Match literal installer source. grep -Fq 'java_sha256=$(jq -er' "$ROOT/deploy/install.sh" \ || fail 'installer no longer obtains the Java runtime SHA-256' -grep -Fq 'Using existing Java 17 runtime' "$ROOT/deploy/install.sh" \ +grep -Fq 'Copying existing Java 17 runtime' "$ROOT/deploy/install.sh" \ || fail 'installer no longer reuses a local Java 17 runtime' +# shellcheck disable=SC2016 # Match literal installer source. +grep -Fq 'cp -R "$system_home/." "$JAVA_STAGED_DIR/"' "$ROOT/deploy/install.sh" \ + || fail 'installer no longer copies a local Java runtime into managed storage' +# shellcheck disable=SC2016 # Match literal installer source. +! grep -Fq 'ln -s "$system_home"' "$ROOT/deploy/install.sh" \ + || fail 'installer must not link the service to an externally managed Java directory' # shellcheck disable=SC2016 # Match the literal installer command. grep -Fq -- '--connect-timeout 1 --max-time 2 "$HEALTH_URL" 2>/dev/null' "$ROOT/deploy/install.sh" \ || fail 'installer no longer performs a quiet bounded health check' grep -Fq 'restart_count" -ge 3' "$ROOT/deploy/install.sh" \ || fail 'installer no longer stops early after repeated service restarts' # shellcheck disable=SC2016 # Match literal installer source. +grep -Fq 'install -d -o root -g "$SERVICE_GROUP" -m 0750' "$ROOT/deploy/install.sh" \ + || fail 'installer no longer creates traversable root-owned application directories' +# shellcheck disable=SC2016 # Match literal installer source. +grep -Fq 'find "$release_dir" -type d -exec chmod 0750 {} +' "$ROOT/deploy/install.sh" \ + || fail 'installer no longer secures release directory traversal permissions' +# shellcheck disable=SC2016 # Match literal installer source. +grep -Fq 'runuser -u "$SERVICE_USER" -- sh -c' "$ROOT/deploy/install.sh" \ + || fail 'installer no longer validates the release as the service user' +grep -Fq 'systemctl reset-failed kaidi-finance.service' "$ROOT/deploy/install.sh" \ + || fail 'installer no longer resets stale systemd failure state' +[ "$(tail -n 1 "$ROOT/deploy/install.sh")" = 'main "$@"' ] \ + || fail 'installer can execute before the complete curl stream is parsed' +grep -Fqx 'StartLimitBurst=3' "$ROOT/deploy/systemd/kaidi-finance.service" \ + || fail 'application service no longer has a bounded restart burst' +# shellcheck disable=SC2016 # Match literal installer source. grep -Fq '[ "$actual_sha256" = "$java_sha256" ]' "$ROOT/deploy/install.sh" \ || fail 'installer no longer verifies the Java runtime SHA-256' grep -Fq 'https://git.awaioi.com/api/v1/repos/ERP-Team/kaidi/releases/latest' "$ROOT/deploy/install.sh" \ @@ -301,8 +373,8 @@ grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \ grep -Fq 'EnvironmentFile=-/var/lib/kaidi/setup/application.env' \ "$ROOT/deploy/systemd/kaidi-update.service" \ || fail 'update service no longer loads setup database overrides' -# shellcheck disable=SC2016 # Match the literal installer source. -grep -Fq 'chown root:kaidi "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \ +# shellcheck disable=SC2016 # Match literal installer source. +grep -Fq 'install -d -o root -g "$SERVICE_GROUP" -m 0750 "$UPDATE_STATE_ROOT"' "$ROOT/deploy/install.sh" \ || fail 'update state parent is not group-accessible to the application user' grep -Fq 'install.sh | sudo bash' "$ROOT/README.md" \ || fail 'README does not document the public one-line setup-wizard install path' diff --git a/scripts/test-update-fixture.sh b/scripts/test-update-fixture.sh index e85f753..58921f4 100755 --- a/scripts/test-update-fixture.sh +++ b/scripts/test-update-fixture.sh @@ -11,6 +11,10 @@ fail() { exit 1 } +mode_of() { + stat -c '%a' "$1" 2>/dev/null || stat -f '%Lp' "$1" +} + sed -n '/^is_semver()/,/^}/p' "$ROOT/deploy/update.sh" > "$WORK/update-semver.sh" # shellcheck disable=SC1090,SC1091 source "$WORK/update-semver.sh" @@ -21,6 +25,20 @@ for version in 01.2.3 1.02.3 1.2.03 1.2.3-01 1.2.3-alpha..1; do ! is_semver "$version" || fail "updater accepted invalid SemVer $version" done +missing_service_user="kaidi-fixture-missing-$$" +if KAIDI_APP_ROOT="$WORK/bootstrap/app" \ + KAIDI_UPDATE_STATE_ROOT="$WORK/bootstrap/state" \ + KAIDI_LOG_ROOT="$WORK/bootstrap/log" \ + KAIDI_SERVICE_USER="$missing_service_user" \ + KAIDI_SERVICE_GROUP="$missing_service_user" \ + sh "$ROOT/deploy/update.sh" > "$WORK/bootstrap.log" 2>&1; then + fail 'updater accepted a missing service identity during bootstrap' +fi +grep -Fq "Service user $missing_service_user is missing" "$WORK/bootstrap.log" \ + || fail 'updater bootstrap failure did not preserve its diagnostic' +! grep -Eq 'No such file|nonexistent directory|cannot create' "$WORK/bootstrap.log" \ + || fail 'updater bootstrap failure was masked by an unavailable status directory' + write_mock_commands() { local mock_bin=$1 mkdir -p "$mock_bin" @@ -99,6 +117,33 @@ SH cat > "$mock_bin/chown" <<'SH' #!/bin/sh exit 0 +SH + + cat > "$mock_bin/runuser" <<'SH' +#!/bin/sh +[ "${1:-}" = -u ] || exit 2 +shift 2 +[ "${1:-}" = -- ] && shift +exec "$@" +SH + + cat > "$mock_bin/install" <<'SH' +#!/usr/bin/env bash +if [[ " $* " != *" -d "* ]]; then + exec /usr/bin/install "$@" +fi +mode=0755 +paths=() +while [ "$#" -gt 0 ]; do + case "$1" in + -d) shift ;; + -o|-g) shift 2 ;; + -m) mode=$2; shift 2 ;; + *) paths+=("$1"); shift ;; + esac +done +mkdir -p "${paths[@]}" +chmod "$mode" "${paths[@]}" SH cat > "$mock_bin/mv" <<'SH' @@ -170,13 +215,18 @@ prepare_installation() { local fixture=$1 local version=$2 mkdir -p "$fixture/app/releases/1.0.0-preview.1/public" "$fixture/app/bin" \ - "$fixture/state/inbox" "$fixture/systemd" "$fixture/log" + "$fixture/app/runtime/java/bin" "$fixture/state/inbox" "$fixture/systemd" "$fixture/log" printf 'old application\n' > "$fixture/app/releases/1.0.0-preview.1/app.jar" printf '