diff --git a/deploy/purge.sh b/deploy/purge.sh index c4c61b4..e430a8d 100755 --- a/deploy/purge.sh +++ b/deploy/purge.sh @@ -67,8 +67,61 @@ related_pids() { 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 deadline + local allow_service_restart=${1:-false} deadline local -a pids=() mapfile -t pids < <(related_pids) if [ "${#pids[@]}" -gt 0 ]; then @@ -84,8 +137,12 @@ stop_managed_processes() { kill -KILL "${pids[@]}" 2>/dev/null || true sleep 1 mapfile -t pids < <(related_pids) - [ "${#pids[@]}" -eq 0 ] \ - || die "A process manager is restarting Kaidi; remove the Kaidi project from Baota and run this command again" + [ "${#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() { @@ -120,6 +177,10 @@ create_recovery_backup() { 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 \ @@ -149,15 +210,23 @@ remove_download_archives() { } remove_service_identity() { - local entry home shell group_entry gid members primary_users + 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) - userdel "$SERVICE_USER" \ - || die "Failed to remove the dedicated $SERVICE_USER service account" + 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" @@ -182,13 +251,13 @@ main() { validate_inputs log "External MySQL data and reverse-proxy configuration will not be modified" stop_systemd_units - stop_managed_processes + stop_managed_processes true create_recovery_backup remove_systemd_units remove_managed_paths remove_download_archives remove_service_identity - stop_managed_processes + stop_managed_processes false log "Local Kaidi installation state has been removed" if [ -n "$BACKUP_ARCHIVE" ]; then log "Recovery backup: $BACKUP_ARCHIVE" diff --git a/scripts/test-purge-fixture.sh b/scripts/test-purge-fixture.sh index 4fef012..ce6a433 100755 --- a/scripts/test-purge-fixture.sh +++ b/scripts/test-purge-fixture.sh @@ -83,5 +83,13 @@ grep -Fq 'KAIDI_PURGE_CONFIRM=$REQUIRED_CONFIRMATION' "$ROOT/deploy/purge.sh" \ || fail 'destructive removal no longer requires explicit confirmation' grep -Fq 'External MySQL data and reverse-proxy configuration will not be modified' "$ROOT/deploy/purge.sh" \ || fail 'purge boundary is no longer explicit' +# shellcheck disable=SC2016 # Match literal service-account cleanup calls. +grep -Fq 'terminate_uid_processes "$service_uid"' "$ROOT/deploy/purge.sh" \ + || fail 'dedicated service-account processes are not terminated before account removal' +# shellcheck disable=SC2016 # Match literal forced account removal. +grep -Fq 'userdel --force "$SERVICE_USER"' "$ROOT/deploy/purge.sh" \ + || fail 'dedicated service-account removal is not resilient to a restarting panel process' +grep -Fq '/etc/systemd/system/kaidi-update.service.d' "$ROOT/deploy/purge.sh" \ + || fail 'updater systemd drop-ins are not removed' printf 'Local purge and recovery fixture passed\n'