323 lines
12 KiB
Bash
Executable File
323 lines
12 KiB
Bash
Executable File
#!/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_LOCALE=${KAIDI_LOG_LOCALE:-zh-CN}
|
|
DELETE_RECOVERY_BACKUP=${KAIDI_PURGE_DELETE_BACKUP:-false}
|
|
|
|
localize_message() {
|
|
local message=$1
|
|
[ "$LOG_LOCALE" = en ] && { printf '%s' "$message"; return; }
|
|
case "$message" in
|
|
"Run with sudo or as root") printf '请使用 sudo 或 root 运行' ;;
|
|
"The purge script only supports Linux") printf '卸载程序仅支持 Linux' ;;
|
|
"Set KAIDI_PURGE_CONFIRM="*) printf '请设置确认变量:KAIDI_PURGE_CONFIRM=%s' "${message#Set KAIDI_PURGE_CONFIRM=}" ;;
|
|
"KAIDI_PURGE_BACKUP_ROOT must be an absolute path") printf 'KAIDI_PURGE_BACKUP_ROOT 必须是绝对路径' ;;
|
|
"KAIDI_PURGE_DELETE_BACKUP must be true or false") printf 'KAIDI_PURGE_DELETE_BACKUP 只能是 true 或 false' ;;
|
|
"The recovery backup must be outside"*) printf '恢复备份目录必须位于 Kaidi 管理目录之外' ;;
|
|
"The recovery backup root must not be a symbolic link") printf '恢复备份目录不能是符号链接' ;;
|
|
"Failed to stop "*) printf '停止服务失败:%s' "${message#Failed to stop }" ;;
|
|
"Stopping processes owned by"*) printf '正在停止专用服务账号进程:%s' "${message##*: }" ;;
|
|
"Stopping remaining Kaidi processes: "*) printf '正在停止剩余 Kaidi 进程:%s' "${message#Stopping remaining Kaidi processes: }" ;;
|
|
"A process manager restarted"*) printf '检测到宝塔等进程管理器正在重新拉起 Kaidi;请先停止并删除宝塔中的 Kaidi 项目,再重试卸载' ;;
|
|
"No local configuration or data"*) printf '没有需要备份的本地配置或数据' ;;
|
|
"Creating a root-only recovery backup at "*) printf '正在创建仅 root 可读的恢复备份:%s' "${message#Creating a root-only recovery backup at }" ;;
|
|
"Failed to remove the dedicated"*) printf '删除 Kaidi 专用服务账号失败' ;;
|
|
"Processes owned by the removed"*) printf '删除服务账号后仍有进程运行' ;;
|
|
"Keeping pre-existing user"*) printf '保留预先存在的用户:%s' "${message#Keeping pre-existing user }" ;;
|
|
"Keeping group "*) printf '保留仍被其他账号使用的用户组:%s' "${message#Keeping group }" ;;
|
|
"External MySQL data and reverse-proxy configuration will not be modified") printf '不会修改外部 MySQL 数据和反向代理配置' ;;
|
|
"Local Kaidi installation state has been removed") printf '本地 Kaidi 安装文件、服务和运行状态已删除' ;;
|
|
"Recovery backup: "*) printf '恢复备份:%s' "${message#Recovery backup: }" ;;
|
|
"Use a new empty MySQL database for the next installation") printf '重新安装时请使用新的空 MySQL 数据库' ;;
|
|
*) printf '%s' "$message" ;;
|
|
esac
|
|
}
|
|
|
|
log() { printf '[kaidi-purge] %s\n' "$(localize_message "$*")"; }
|
|
die() { printf '[kaidi-purge] 错误:%s\n' "$(localize_message "$*")" >&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"
|
|
case "$DELETE_RECOVERY_BACKUP" in
|
|
true|false) ;;
|
|
*) die "KAIDI_PURGE_DELETE_BACKUP must be true or false" ;;
|
|
esac
|
|
}
|
|
|
|
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 \
|
|
/tmp/kaidi-purge.sh \
|
|
/tmp/kaidi-SHA256SUMS \
|
|
/tmp/kaidi-install.sh
|
|
}
|
|
|
|
verify_managed_paths_removed() {
|
|
local path
|
|
for path in "$APP_ROOT" "$BAOTA_ROOT" "$CONFIG_ROOT" "$STATE_ROOT" \
|
|
"$UPDATE_STATE_ROOT" "$LOG_ROOT" "$FIRST_LOGIN_FILE"; do
|
|
[ ! -e "$path" ] && [ ! -L "$path" ] \
|
|
|| die "卸载后仍发现受管路径:$path"
|
|
done
|
|
}
|
|
|
|
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
|
|
verify_managed_paths_removed
|
|
log "Local Kaidi installation state has been removed"
|
|
if [ -n "$BACKUP_ARCHIVE" ]; then
|
|
log "Recovery backup: $BACKUP_ARCHIVE"
|
|
if [ "$DELETE_RECOVERY_BACKUP" = true ]; then
|
|
rm -f -- "$BACKUP_ARCHIVE"
|
|
rmdir -- "$BACKUP_ROOT" >/dev/null 2>&1 || true
|
|
log "已按 KAIDI_PURGE_DELETE_BACKUP=true 删除恢复备份"
|
|
fi
|
|
fi
|
|
log "Use a new empty MySQL database for the next installation"
|
|
}
|
|
|
|
main "$@"
|