#!/usr/bin/env bash set -Eeuo pipefail umask 077 APP_ROOT=/opt/kaidi BAOTA_ROOT=/www/wwwroot/kaidi CONFIG_ROOT=/etc/kaidi STATE_ROOT=/var/lib/kaidi UPDATE_STATE_ROOT=/var/lib/kaidi-update LOG_ROOT=/var/log/kaidi FIRST_LOGIN_FILE=/root/kaidi-first-login.txt BACKUP_ROOT=${KAIDI_PURGE_BACKUP_ROOT:-/root/kaidi-reinstall-backups} CONFIRMATION=${KAIDI_PURGE_CONFIRM:-} REQUIRED_CONFIRMATION=DELETE_LOCAL_KAIDI_INSTALLATION SERVICE_USER=kaidi SERVICE_GROUP=kaidi BACKUP_ARCHIVE= log() { printf '[kaidi-purge] %s\n' "$*"; } die() { printf '[kaidi-purge] ERROR: %s\n' "$*" >&2; exit 1; } systemd_available() { command -v systemctl >/dev/null 2>&1 && [ -d /run/systemd/system ] } validate_inputs() { [ "$(id -u)" -eq 0 ] || die "Run with sudo or as root" [ "$(uname -s)" = Linux ] || die "The purge script only supports Linux" [ "$CONFIRMATION" = "$REQUIRED_CONFIRMATION" ] \ || die "Set KAIDI_PURGE_CONFIRM=$REQUIRED_CONFIRMATION to confirm local removal" case "$BACKUP_ROOT" in /*) ;; *) die "KAIDI_PURGE_BACKUP_ROOT must be an absolute path" ;; esac case "$BACKUP_ROOT/" in "$APP_ROOT/"*|"$BAOTA_ROOT/"*|"$CONFIG_ROOT/"*|"$STATE_ROOT/"*|\ "$UPDATE_STATE_ROOT/"*|"$LOG_ROOT/"*) die "The recovery backup must be outside every managed Kaidi directory" ;; esac [ ! -L "$BACKUP_ROOT" ] || die "The recovery backup root must not be a symbolic link" } stop_systemd_units() { local unit systemd_available || return 0 for unit in kaidi-update.path kaidi-update.service kaidi-finance.service; do if systemctl cat "$unit" >/dev/null 2>&1; then systemctl stop "$unit" >/dev/null 2>&1 \ || die "Failed to stop $unit" systemctl disable "$unit" >/dev/null 2>&1 || true fi done } related_pids() { local proc pid command_line for proc in /proc/[0-9]*; do [ -r "$proc/cmdline" ] || continue pid=${proc##*/} [ "$pid" != "$$" ] && [ "$pid" != "$PPID" ] || continue command_line=$(tr '\000' ' ' < "$proc/cmdline" 2>/dev/null || true) case "$command_line" in *"$APP_ROOT/"*|*"$BAOTA_ROOT/"*) printf '%s\n' "$pid" ;; esac done } managed_service_account() { local entry home shell entry=$(getent passwd "$SERVICE_USER" 2>/dev/null || true) [ -n "$entry" ] || return 1 home=$(printf '%s\n' "$entry" | awk -F: '{print $6}') shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}') case "$home:$shell" in "$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false) return 0 ;; *) return 1 ;; esac } service_user_pids() { local uid=$1 proc pid owner_uid for proc in /proc/[0-9]*; do [ -d "$proc" ] || continue pid=${proc##*/} owner_uid=$(stat -c '%u' "$proc" 2>/dev/null || true) [ "$owner_uid" = "$uid" ] && printf '%s\n' "$pid" done } terminate_uid_processes() { local uid=$1 deadline local -a pids=() mapfile -t pids < <(service_user_pids "$uid") if [ "${#pids[@]}" -gt 0 ]; then log "Stopping processes owned by the dedicated $SERVICE_USER account: ${pids[*]}" kill -TERM "${pids[@]}" 2>/dev/null || true fi deadline=$((SECONDS + 5)) while [ "$SECONDS" -lt "$deadline" ]; do mapfile -t pids < <(service_user_pids "$uid") [ "${#pids[@]}" -gt 0 ] || return 0 sleep 1 done kill -KILL "${pids[@]}" 2>/dev/null || true sleep 1 mapfile -t pids < <(service_user_pids "$uid") [ "${#pids[@]}" -eq 0 ] } all_pids_owned_by_service_user() { local service_uid pid owner_uid managed_service_account || return 1 service_uid=$(id -u "$SERVICE_USER") for pid in "$@"; do [ -d "/proc/$pid" ] || continue owner_uid=$(stat -c '%u' "/proc/$pid" 2>/dev/null || true) [ "$owner_uid" = "$service_uid" ] || return 1 done } stop_managed_processes() { local allow_service_restart=${1:-false} deadline local -a pids=() mapfile -t pids < <(related_pids) if [ "${#pids[@]}" -gt 0 ]; then log "Stopping remaining Kaidi processes: ${pids[*]}" kill -TERM "${pids[@]}" 2>/dev/null || true fi deadline=$((SECONDS + 5)) while [ "$SECONDS" -lt "$deadline" ]; do mapfile -t pids < <(related_pids) [ "${#pids[@]}" -gt 0 ] || return 0 sleep 1 done kill -KILL "${pids[@]}" 2>/dev/null || true sleep 1 mapfile -t pids < <(related_pids) [ "${#pids[@]}" -eq 0 ] && return 0 if [ "$allow_service_restart" = true ] && all_pids_owned_by_service_user "${pids[@]}"; then log "A process manager restarted the dedicated $SERVICE_USER account; forced account cleanup will stop it" return 0 fi die "A process manager is restarting Kaidi; remove the Kaidi project from Baota and run this command again" } create_recovery_backup() { local path relative temporary timestamp local -a paths=() for path in \ "$CONFIG_ROOT" \ "$STATE_ROOT" \ "$UPDATE_STATE_ROOT/backups" \ "$UPDATE_STATE_ROOT/failed" \ "$UPDATE_STATE_ROOT/transactions" \ "$FIRST_LOGIN_FILE"; do if [ -e "$path" ] || [ -L "$path" ]; then relative=${path#/} paths+=("$relative") fi done if [ "${#paths[@]}" -eq 0 ]; then log "No local configuration or data needs a recovery backup" return 0 fi install -d -o root -g root -m 0700 "$BACKUP_ROOT" timestamp=$(date -u +%Y%m%dT%H%M%SZ) BACKUP_ARCHIVE="$BACKUP_ROOT/kaidi-local-state-$timestamp.tar.gz" temporary="$BACKUP_ARCHIVE.next.$$" log "Creating a root-only recovery backup at $BACKUP_ARCHIVE" tar -czf "$temporary" -C / -- "${paths[@]}" tar -tzf "$temporary" >/dev/null chmod 0600 "$temporary" mv -f "$temporary" "$BACKUP_ARCHIVE" } remove_systemd_units() { local unit rm -rf \ /etc/systemd/system/kaidi-finance.service.d \ /etc/systemd/system/kaidi-update.service.d \ /etc/systemd/system/kaidi-update.path.d rm -f \ /etc/systemd/system/kaidi-finance.service \ /etc/systemd/system/kaidi-update.service \ /etc/systemd/system/kaidi-update.path \ /etc/systemd/system/multi-user.target.wants/kaidi-finance.service \ /etc/systemd/system/multi-user.target.wants/kaidi-update.path systemd_available || return 0 systemctl daemon-reload for unit in kaidi-finance.service kaidi-update.service kaidi-update.path; do systemctl reset-failed "$unit" >/dev/null 2>&1 || true done } remove_managed_paths() { rm -rf -- \ "$APP_ROOT" \ "$BAOTA_ROOT" \ "$CONFIG_ROOT" \ "$STATE_ROOT" \ "$UPDATE_STATE_ROOT" \ "$LOG_ROOT" rm -f -- "$FIRST_LOGIN_FILE" } remove_download_archives() { rm -f -- /tmp/kaidi-finance-*.tar.gz } remove_service_identity() { local entry home shell service_uid group_entry gid members primary_users entry=$(getent passwd "$SERVICE_USER" 2>/dev/null || true) if [ -n "$entry" ]; then home=$(printf '%s\n' "$entry" | awk -F: '{print $6}') shell=$(printf '%s\n' "$entry" | awk -F: '{print $7}') case "$home:$shell" in "$STATE_ROOT:"*/nologin|"$STATE_ROOT:"*/false) service_uid=$(id -u "$SERVICE_USER") terminate_uid_processes "$service_uid" || true if ! userdel --force "$SERVICE_USER"; then terminate_uid_processes "$service_uid" || true userdel --force "$SERVICE_USER" \ || die "Failed to remove the dedicated $SERVICE_USER service account" fi terminate_uid_processes "$service_uid" \ || die "Processes owned by the removed $SERVICE_USER account are still running" rm -rf "/run/user/$service_uid" ;; *) log "Keeping pre-existing user $SERVICE_USER because its home or shell is not Kaidi-managed" ;; esac fi group_entry=$(getent group "$SERVICE_GROUP" 2>/dev/null || true) [ -n "$group_entry" ] || return 0 gid=$(printf '%s\n' "$group_entry" | awk -F: '{print $3}') members=$(printf '%s\n' "$group_entry" | awk -F: '{print $4}') primary_users=$(getent passwd | awk -F: -v gid="$gid" '$4 == gid { print $1 }') if [ -z "$members" ] && [ -z "$primary_users" ]; then groupdel "$SERVICE_GROUP" \ || die "Failed to remove the dedicated $SERVICE_GROUP service group" else log "Keeping group $SERVICE_GROUP because another account still uses it" fi } main() { validate_inputs log "External MySQL data and reverse-proxy configuration will not be modified" stop_systemd_units stop_managed_processes true create_recovery_backup remove_systemd_units remove_managed_paths remove_download_archives remove_service_identity stop_managed_processes false log "Local Kaidi installation state has been removed" if [ -n "$BACKUP_ARCHIVE" ]; then log "Recovery backup: $BACKUP_ARCHIVE" fi log "Use a new empty MySQL database for the next installation" } main "$@"