#!/usr/bin/env bash
# diskcheck.sh - Linux 磁盘空间只读排查采集脚本
# 分析页：https://xvuln.com/diskcheck/ （浏览器本地解析，不上传）
#
# 只读：不删除、不修改任何已有文件，不连接数据库，不重启服务，不挂载。
# 唯一的写入：在输出目录（默认 /tmp）建一个临时目录写采集结果，
# 结束时打包成 diskcheck_<IP>_<时间>.tar.gz，再删掉这个自己建的临时目录。
# du / find 以 nice + ionice 低优先级运行，并有单步超时。
# 配置文件、命令行、crontab 里的口令类参数在落盘前打码。
#
# 适用：RHEL/CentOS 7+、银河麒麟 / 统信、Ubuntu / Debian。建议 root 运行。

[ -n "${BASH_VERSION:-}" ] || exec bash "$0" "$@"

VERSION="1.0.0"
export LC_ALL=C
umask 077

OUTDIR=/tmp
DEPTH=6
MINMB=1
STEP_TIMEOUT=1800

usage() {
    cat <<'EOF'
用法: bash diskcheck.sh [-o 输出目录] [-d du深度] [-m 文件阈值MB] [-t 单步超时秒]
  -o  结果包存放目录，默认 /tmp（空间不足时自动改用 /dev/shm）
  -d  du 统计深度，默认 6
  -m  文件清单收录阈值（MB），默认 1；越大越快、清单越短
  -t  du/find 单步超时（秒），默认 1800
  -h  显示本帮助
结果：<输出目录>/diskcheck_<IP>_<时间>.tar.gz，拷回本机后用
      https://xvuln.com/diskcheck/ 打开分析（本地解析，不上传）。
EOF
}

while getopts ":o:d:m:t:h" opt; do
    case $opt in
        o) OUTDIR=$OPTARG ;;
        d) DEPTH=$OPTARG ;;
        m) MINMB=$OPTARG ;;
        t) STEP_TIMEOUT=$OPTARG ;;
        h) usage; exit 0 ;;
        *) usage; exit 2 ;;
    esac
done
for v in "$DEPTH" "$MINMB" "$STEP_TIMEOUT"; do
    case $v in ''|*[!0-9]*) echo "参数必须是正整数: $v" >&2; exit 2 ;; esac
done

have() { command -v "$1" >/dev/null 2>&1; }
log()  { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*" >&2; }

# ---------- 输出位置 ----------
avail_kb() { df -kP "$1" 2>/dev/null | awk 'NR==2{print $4}'; }
[ -d "$OUTDIR" ] || { echo "输出目录不存在: $OUTDIR" >&2; exit 2; }
a=$(avail_kb "$OUTDIR")
if [ "${a:-0}" -lt 102400 ]; then
    for alt in /dev/shm /run; do
        b=$(avail_kb "$alt")
        if [ -d "$alt" ] && [ -w "$alt" ] && [ "${b:-0}" -ge 102400 ]; then
            log "$OUTDIR 剩余不足 100MB，结果改存到 $alt"
            OUTDIR=$alt
            break
        fi
    done
fi

get_ip() {
    local ip=""
    if have ip; then
        ip=$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<NF;i++) if($i=="src"){print $(i+1); exit}}')
    fi
    [ -n "$ip" ] || ip=$(hostname -I 2>/dev/null | awk '{print $1}')
    [ -n "$ip" ] || ip=$(hostname 2>/dev/null)
    [ -n "$ip" ] || ip=unknown
    printf '%s' "$ip" | tr -c 'A-Za-z0-9._-' '_'
}

IP=$(get_ip)
TS=$(date +%Y%m%d_%H%M%S)
BASE="diskcheck_${IP}_${TS}"
TMPD=$(mktemp -d "$OUTDIR/.diskcheck.XXXXXX") || { echo "无法在 $OUTDIR 建临时目录" >&2; exit 1; }
W="$TMPD/$BASE"
mkdir "$W" || exit 1
ERR="$W/99_errors.log"
SIZES="$W/sizes.tsv"
: > "$ERR"; : > "$SIZES"

cleanup() {
    # 只删本脚本自己 mktemp 出来的目录
    case $TMPD in "$OUTDIR"/.diskcheck.*) rm -rf -- "$TMPD" ;; esac
}
trap 'cleanup' EXIT
trap 'log "已中断，临时目录已清理"; exit 130' INT TERM

# ---------- 口令打码 ----------
MASK_SED="$TMPD/mask.sed"
cat > "$MASK_SED" <<'EOF'
s/((pass(word|wd)?|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credentials?)["']?[[:space:]]*[=:][[:space:]]*)("[^"]*"|'[^']*'|[^[:space:],;&]+)/\1******/Ig
s/(--(password|passwd|pass|pwd)[[:space:]]+)('[^']*'|"[^"]*"|[^[:space:]-][^[:space:]]*)/\1******/Ig
/mysql|mariadb|xtrabackup|innobackupex/I s/([[:space:]]-p)('[^']*'|"[^"]*"|[^[:space:]]+)/\1******/g
s/(sshpass[[:space:]]+-p[[:space:]]*)('[^']*'|"[^"]*"|[^[:space:]]+)/\1******/g
s#(://[^/:@[:space:]]+:)[^@[:space:]/]+@#\1******@#g
s/(identified[[:space:]]+by[[:space:]]+)('[^']*'|"[^"]*"|[^[:space:];]+)/\1******/Ig
s/^([[:space:]]*(requirepass|masterauth|masteruser)[[:space:]]+).*/\1******/I
EOF
mask() { sed -r -f "$MASK_SED"; }

# ---------- 执行助手 ----------
IONICE_OK=0
have ionice && ionice -c2 -n7 true 2>/dev/null && IONICE_OK=1
# 低优先级 + 超时：du / find 一律走这里
low() {
    local pre=()
    have timeout && pre+=(timeout "$STEP_TIMEOUT")
    have nice && pre+=(nice -n 19)
    [ "$IONICE_OK" = 1 ] && pre+=(ionice -c2 -n7)
    "${pre[@]}" "$@"
}
# 短命令：限时
t() {
    local s=$1; shift
    if have timeout; then timeout "$s" "$@"; else "$@"; fi
}
# cap 文件 命令...：记录命令行与输出，经打码后追加
cap() {
    local f="$W/$1"; shift
    { printf '$ %s\n' "$*"; t 120 "$@"; echo; } 2>>"$ERR" | mask >> "$f"
}
# sec 文件 标题：多段文件里的段落分隔
sec() { printf '### %s\n' "$2" >> "$W/$1"; }
# catf 文件 路径...：把配置文件原样（打码后）收进来
catf() {
    local f=$1 p; shift
    for p in "$@"; do
        [ -f "$p" ] && [ -r "$p" ] || continue
        printf '### file %s\n' "$p" >> "$W/$f"
        mask < "$p" >> "$W/$f" 2>>"$ERR"
        echo >> "$W/$f"
    done
}
# sz 键 路径 [count]：目录占用（KB）与可选文件数，写进 sizes.tsv
sz() {
    local key=$1 p=$2 kb cnt=-
    [ -e "$p" ] || return 0
    kb=$(low du -sxk -- "$p" 2>>"$ERR" | awk 'END{print $1}')
    if [ "${3:-}" = count ]; then
        cnt=$(low find "$p" -xdev -type f 2>>"$ERR" | wc -l | tr -d ' ')
    fi
    printf '%s\t%s\t%s\t%s\n' "$key" "${kb:-0}" "$cnt" "$p" >> "$SIZES"
}
FILEFMT='%s\t%k\t%T@\t%u\t%p\n'
step_start() { STEP_T0=$(date +%s); log "$1"; }
step_end()   { echo "step_${1}_sec=$(( $(date +%s) - STEP_T0 ))" >> "$W/00_meta.txt"; }
is_mounted() { awk -v m="$1" '{gsub(/\\040/," ",$2)} $2==m{f=1} END{exit !f}' /proc/self/mounts; }

[ "$(id -u)" = 0 ] || log "提示：非 root 运行，很多目录和进程读不到，结果会不全"

# ---------- 00 基本信息 ----------
os=""
[ -r /etc/os-release ] && os=$(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-$NAME $VERSION_ID}")
[ -n "$os" ] || os=$(head -n1 /etc/redhat-release 2>/dev/null)
{
    echo "tool=diskcheck"
    echo "version=$VERSION"
    echo "collected_at=$(date +%s)"
    echo "collected_at_text=$(date '+%F %T %z')"
    echo "hostname=$(hostname 2>/dev/null)"
    echo "ip=$IP"
    echo "os=$os"
    echo "kernel=$(uname -r)"
    echo "arch=$(uname -m)"
    echo "uid=$(id -u)"
    echo "uptime=$(uptime 2>/dev/null | sed 's/^ *//')"
    echo "du_depth=$DEPTH"
    echo "min_file_mb=$MINMB"
    echo "step_timeout=$STEP_TIMEOUT"
    echo "ionice=$IONICE_OK"
} > "$W/00_meta.txt"

# ---------- 10 文件系统与块设备 ----------
step_start "文件系统、块设备、LVM、swap"
cap 10_df.txt   df -hPT
cap 10_df_k.txt df -kPT
cap 10_df_i.txt df -iPT
if have lsblk; then
    cap 11_lsblk.txt lsblk -o NAME,KNAME,TYPE,SIZE,FSTYPE,MOUNTPOINT,RO \
        || cap 11_lsblk.txt lsblk
fi
cat /proc/self/mounts > "$W/12_mounts.txt" 2>>"$ERR"
have findmnt && cap 12_findmnt.txt findmnt -rn -o TARGET,SOURCE,FSTYPE,OPTIONS
catf 13_fstab.txt /etc/fstab
have systemctl && cap 13_fstab.txt systemctl list-units --type=mount --type=automount --all --no-pager --no-legend
if have pvs; then
    cap 14_lvm.txt pvs
    cap 14_lvm.txt vgs
    cap 14_lvm.txt lvs -a -o +devices
fi
cap 15_swap.txt cat /proc/swaps
have free && cap 15_swap.txt free -m
step_end fs

# fstab 里定义、但当前没挂上的挂载点；/mnt、/media 下没挂载的目录
step_start "检查未挂载的挂载点目录"
if [ -r /etc/fstab ]; then
    awk '!/^[[:space:]]*#/ && NF>=3 && $2 ~ /^\// && $3 != "swap" {print $2}' /etc/fstab |
    while IFS= read -r mp; do
        mp=${mp//\\040/ }
        [ "$mp" = / ] && continue
        if is_mounted "$mp"; then
            printf 'fstab_mounted\t-\t-\t%s\n' "$mp" >> "$SIZES"
        elif [ -d "$mp" ]; then
            sz fstab_unmounted "$mp" count
        else
            printf 'fstab_missing_dir\t-\t-\t%s\n' "$mp" >> "$SIZES"
        fi
    done
fi
for d in /mnt/* /media/*; do
    [ -d "$d" ] || continue
    is_mounted "$d" && continue
    grep -qs "[[:space:]]$d[[:space:]]" /etc/fstab && continue
    sz mnt_unmounted "$d" count
done
step_end unmounted

# ---------- 20 目录占用 ----------
step_start "统计 / 目录占用（du -x，深度 $DEPTH，低优先级，可能要几分钟）"
low du -xk --max-depth="$DEPTH" / 2>>"$ERR" |
    awk -F'\t' '{ n = split($2, a, "/"); if ($1 >= 10240 || n <= 3) print }' > "$W/20_du_root.tsv"
rc=${PIPESTATUS[0]}
[ "$rc" = 124 ] && echo "du_root_timeout=1" >> "$W/00_meta.txt"
step_end du_root

# 其他使用率 >= 85% 的本地文件系统，各统计一遍（最多 5 个）
OTHER_MNTS=$(df -kPT 2>/dev/null | awk 'NR>1 && $7 != "/" {
        u = $6; sub(/%/, "", u)
        if (u + 0 >= 85 && $3 >= 1048576 && $2 ~ /^(ext[234]|xfs|btrfs|f2fs|jfs|reiserfs|zfs)$/) { $1=$2=$3=$4=$5=$6=""; sub(/^ +/, ""); print }
    }' | head -n 5)
if [ -n "$OTHER_MNTS" ]; then
    step_start "统计其他高占用文件系统"
    while IFS= read -r m; do
        [ -d "$m" ] || continue
        low du -xk --max-depth=4 "$m" 2>>"$ERR" |
            awk -F'\t' '{ if ($1 >= 10240) print }' >> "$W/22_du_other.tsv"
    done <<< "$OTHER_MNTS"
    step_end du_other
fi

# 根分区 inode 使用率高时，按目录统计文件数
iuse=$(df -iP / 2>/dev/null | awk 'NR==2{u=$5; sub(/%/,"",u); print u+0}')
if [ "${iuse:-0}" -ge 70 ] && du --help 2>/dev/null | grep -q -- '--inodes'; then
    step_start "根分区 inode 使用率 ${iuse}%，按目录统计文件数"
    low du -x --inodes --max-depth=4 / 2>>"$ERR" |
        awk -F'\t' '{ n = split($2, a, "/"); if ($1 >= 10000 || n <= 3) print }' > "$W/21_du_inodes.tsv"
    step_end du_inodes
fi

# ---------- 30 文件清单 ----------
step_start "列出大文件与 core/hprof（find -xdev，阈值 ${MINMB}MB，低优先级）"
find_files() {
    low find "$1" -xdev -type f \( -size +"${MINMB}"M -o -name core -o -name 'core.[0-9]*' \
        -o -name '*.hprof' -o -name 'hs_err_pid*' \) -printf "$FILEFMT" 2>>"$ERR"
}
find_files / > "$W/30_files.tsv"
[ "${PIPESTATUS[0]}" = 124 ] && echo "find_root_timeout=1" >> "$W/00_meta.txt"
if [ -n "$OTHER_MNTS" ]; then
    while IFS= read -r m; do [ -d "$m" ] && find_files "$m" >> "$W/30_files.tsv"; done <<< "$OTHER_MNTS"
fi
n=$(wc -l < "$W/30_files.tsv")
if [ "$n" -gt 300000 ]; then
    sort -t"$(printf '\t')" -k1,1nr "$W/30_files.tsv" | head -n 300000 > "$W/30_files.tmp" &&
        mv "$W/30_files.tmp" "$W/30_files.tsv"
    echo "files_truncated=$n" >> "$W/00_meta.txt"
fi
step_end files

# ---------- 25 最大目录下钻 ----------
# 不写死任何厂商目录：取 / 下第 2~3 层最大的目录（排除系统目录），逐个按扩展名、文件名模式、文件年龄汇总。
# 第 2 层目录里若有一个子目录占了它 70% 以上，就改钻那个子目录。
step_start "下钻最大的几个目录"
NOW=$(date +%s)
awk -F'\t' '{
        n = split($2, a, "/"); d = n - 1
        if (d < 2 || d > 3 || $1 < 262144) next
        if ($2 ~ /^\/(usr|boot|lib|lib64|bin|sbin|etc|proc|sys|dev|run)(\/|$)/) next
        if ($2 ~ /^\/var\/lib\/(rpm|dpkg|yum|dnf|apt)(\/|$)/) next
        size[$2] = $1; dep[$2] = d
        if (d == 3) {
            par = $2; sub(/\/[^\/]*$/, "", par)
            if ($1 > best[par]) { best[par] = $1; bestc[par] = $2 }
        }
    }
    END {
        for (p in size) {
            q = p
            if (dep[p] == 2 && best[p] >= 0.7 * size[p]) q = bestc[p]
            print size[q] "\t" q
        }
    }' "$W/20_du_root.tsv" | sort -t"$(printf '\t')" -k1,1nr |
    awk -F'\t' '{
        p = $2; skip = 0
        for (i = 1; i <= k; i++) {
            if (index(p "/", sel[i] "/") == 1 || index(sel[i] "/", p "/") == 1) { skip = 1; break }
        }
        if (!skip) { sel[++k] = p; print p }
        if (k >= 6) exit
    }' > "$TMPD/drill.lst"
while IFS= read -r d; do
    [ -d "$d" ] || continue
    low find "$d" -xdev -type f -printf '%s\t%T@\t%f\n' 2>>"$ERR" |
    awk -F'\t' -v now="$NOW" -v dir="$d" '
        {
            n++; s += $1; name = $3
            ext = "(无扩展名)"
            i = length(name)
            while (i > 1 && substr(name, i, 1) != ".") i--
            if (i > 1 && length(name) - i <= 8) ext = tolower(substr(name, i))
            gsub(/[0-9]+/, "#", ext)
            es[ext] += $1; ec[ext]++
            pat = name; gsub(/[0-9]+/, "#", pat)
            ps[pat] += $1; pc[pat]++
            age = (now - $2) / 86400
            b = (age < 1) ? "1天内" : (age < 7) ? "1-7天" : (age < 30) ? "7-30天" : (age < 180) ? "30-180天" : "180天以上"
            as[b] += $1; ac[b]++
        }
        END {
            printf "### DIR\t%s\t%d\t%.0f\n", dir, n, s
            for (b in as) printf "A\t%s\t%d\t%.0f\n", b, ac[b], as[b]
            for (e in es) printf "E\t%s\t%d\t%.0f\n", e, ec[e], es[e]
            for (p in ps) printf "P\t%s\t%d\t%.0f\n", p, pc[p], ps[p]
        }' > "$TMPD/drill.one"
    { grep '^###' "$TMPD/drill.one"
      grep '^A' "$TMPD/drill.one"
      grep '^E' "$TMPD/drill.one" | sort -t"$(printf '\t')" -k4,4nr | head -n 15
      grep '^P' "$TMPD/drill.one" | sort -t"$(printf '\t')" -k4,4nr | head -n 20
    } >> "$W/25_drill.tsv"
done < "$TMPD/drill.lst"
step_end drill

# ---------- 31 已删除但仍被占用的文件 ----------
step_start "扫描已删除但仍被进程占用的文件"
find /proc -mindepth 3 -maxdepth 3 -path '/proc/[0-9]*/fd/*' -type l -lname '*(deleted)' 2>/dev/null |
while IFS= read -r fdp; do
    pid=${fdp#/proc/}; pid=${pid%%/*}; fd=${fdp##*/}
    tgt=$(readlink "$fdp" 2>/dev/null) || continue
    case $tgt in /memfd:*|/dev/*|anon_inode:*|socket:*|pipe:*) continue ;; esac
    st=$(stat -L -c '%s %d:%i' "$fdp" 2>/dev/null) || continue
    comm=$(cat "/proc/$pid/comm" 2>/dev/null)
    printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$pid" "$comm" "$fd" "${st%% *}" "${st#* }" "${tgt% (deleted)}"
done > "$W/31_deleted.tsv"
if have lsof; then
    { printf '$ lsof -nP -b -w +L1\n'; t 180 lsof -nP -b -w +L1; } > "$W/32_lsof_L1.txt" 2>>"$ERR"
fi
step_end deleted

# ---------- 40 日志、缓存、内核 ----------
step_start "journald、包缓存、/var/log、logrotate、内核"
have journalctl && cap 40_journal.txt journalctl --disk-usage
sec 40_journal.txt "journald.conf（非注释行）"
for f in /etc/systemd/journald.conf /etc/systemd/journald.conf.d/*.conf; do
    [ -r "$f" ] && grep -Hv '^[[:space:]]*\(#\|;\|$\)' "$f" >> "$W/40_journal.txt" 2>/dev/null
done
sz journal /var/log/journal
sz journal_run /run/log/journal

sz cache_yum /var/cache/yum
sz cache_dnf /var/cache/dnf
sz cache_apt /var/cache/apt
sz cache_packagekit /var/cache/PackageKit
sz cache_snapd /var/lib/snapd/cache
sec 41_pkg.txt "keepcache"
grep -Hs 'keepcache' /etc/yum.conf /etc/dnf/dnf.conf >> "$W/41_pkg.txt"

low find /var/log -xdev -type f -printf "$FILEFMT" > "$W/42_varlog.tsv" 2>>"$ERR"
catf 43_logrotate.txt /etc/logrotate.conf /etc/logrotate.d/*
for f in /var/lib/logrotate/logrotate.status /var/lib/logrotate/status /var/lib/logrotate.status; do
    [ -r "$f" ] && { printf '### status %s\n' "$f"; cat "$f"; } >> "$W/43_logrotate.txt" 2>>"$ERR"
done
if have systemctl; then
    sec 43_logrotate.txt "logrotate 执行状态"
    { systemctl is-active logrotate.timer; systemctl is-failed logrotate.service; } >> "$W/43_logrotate.txt" 2>&1
fi
[ -e /etc/cron.daily/logrotate ] && echo "cron.daily/logrotate=present" >> "$W/43_logrotate.txt"

{
    echo "running=$(uname -r)"
    have rpm && rpm -q kernel kernel-core kernel-uek 2>/dev/null | grep -v 'not installed' | sed 's/^/rpm=/'
    have dpkg && dpkg -l 'linux-image-*' 2>/dev/null | awk '/^ii/{print "deb=" $2 " " $3}'
} > "$W/46_kernels.txt"
cap 46_kernels.txt ls -la /boot
sz boot /boot
sz lib_modules /lib/modules
step_end logs

# ---------- 44 cron 与 45 邮件 ----------
step_start "cron、MAILTO、邮件积压"
catf 44_cron.txt /etc/crontab /etc/anacrontab /etc/cron.d/* /var/spool/cron/* /var/spool/cron/crontabs/*
for d in /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly; do
    [ -d "$d" ] && cap 44_cron.txt ls -la "$d"
done

{
    if have systemctl; then
        echo "postfix_active=$(systemctl is-active postfix 2>/dev/null)"
        echo "sendmail_active=$(systemctl is-active sendmail 2>/dev/null)"
        echo "exim_active=$(systemctl is-active exim4 2>/dev/null)"
    fi
    if have pgrep; then
        echo "postfix_master_pid=$(pgrep -x master | head -n1)"
    fi
} > "$W/45_mail.txt"
if have postqueue && pgrep -x master >/dev/null 2>&1; then
    { printf '$ postqueue -p | tail -n 1\n'; t 30 postqueue -p 2>/dev/null | tail -n 1; } >> "$W/45_mail.txt"
fi
for q in maildrop incoming active deferred hold corrupt; do
    sz "postfix_$q" "/var/spool/postfix/$q" count
done
sz clientmqueue /var/spool/clientmqueue count
sz mqueue /var/spool/mqueue count
sz exim_input /var/spool/exim/input count
sz exim4_input /var/spool/exim4/input count
for d in /var/spool/mail /var/mail; do
    [ -d "$d" ] && [ ! -L "$d" ] && find "$d" -maxdepth 1 -type f -printf "$FILEFMT" >> "$W/45_mailbox.tsv" 2>>"$ERR"
done
step_end mail

# ---------- 50 进程 ----------
ps -eo pid,ppid,user,etime,rss,args --sort=-rss 2>>"$ERR" | mask > "$W/50_ps.txt" ||
    ps -ef 2>>"$ERR" | mask > "$W/50_ps.txt"

# ---------- 60 数据库与容器（检测到才采） ----------
pids_of() { pgrep -x "$1" 2>/dev/null; }
proc_args() { tr '\0' ' ' < "/proc/$1/cmdline" 2>/dev/null; }
arg_val() { # arg_val pid --datadir
    tr '\0' '\n' < "/proc/$1/cmdline" 2>/dev/null | awk -v k="$2" '
        index($0, k "=") == 1 { print substr($0, length(k) + 2); exit }
        nxt { print; exit }
        $0 == k { nxt = 1 }'
}
cfg_val() { # cfg_val key 文件...（取最后一次出现的值）
    local key=$1; shift
    awk -v k="$key" '
        { line = $0; sub(/[#;].*/, "", line); gsub(/^[ \t]+|[ \t]+$/, "", line) }
        line ~ "^" k "[ \t]*=" || line ~ "^" k "[ \t]+" {
            v = line; sub("^" k "[ \t]*=?[ \t]*", "", v); gsub(/^["'"'"']|["'"'"']$/, "", v); val = v
        }
        END { if (val != "") print val }' "$@" 2>/dev/null
}

# MySQL / MariaDB
MY_PIDS=$( { pids_of mysqld; pids_of mariadbd; } | sort -u)
if [ -n "$MY_PIDS" ] || [ -f /etc/my.cnf ] || [ -d /etc/mysql ] || [ -d /var/lib/mysql ]; then
    step_start "MySQL/MariaDB：只读配置文件与数据目录大小，不连库"
    F=60_db_mysql.txt
    sec $F "process"
    for p in $MY_PIDS; do
        echo "pid=$p exe=$(readlink /proc/$p/exe 2>/dev/null) args=$(proc_args "$p")" | mask >> "$W/$F"
        exe=$(readlink "/proc/$p/exe" 2>/dev/null)
        [ -x "$exe" ] && { printf 'version='; t 20 "$exe" --version 2>/dev/null | head -n1; } >> "$W/$F"
    done
    CNFS=""
    for p in $MY_PIDS; do
        df_=$(arg_val "$p" --defaults-file); [ -n "$df_" ] && CNFS="$CNFS $df_"
        de_=$(arg_val "$p" --defaults-extra-file); [ -n "$de_" ] && CNFS="$CNFS $de_"
    done
    # shellcheck disable=SC2086
    catf $F /etc/my.cnf /etc/my.cnf.d/*.cnf /etc/mysql/my.cnf /etc/mysql/*.cnf \
        /etc/mysql/conf.d/*.cnf /etc/mysql/mysql.conf.d/*.cnf /etc/mysql/mariadb.conf.d/*.cnf $CNFS
    ALLCNF=$(ls /etc/my.cnf /etc/my.cnf.d/*.cnf /etc/mysql/my.cnf /etc/mysql/*.cnf /etc/mysql/conf.d/*.cnf \
        /etc/mysql/mysql.conf.d/*.cnf /etc/mysql/mariadb.conf.d/*.cnf $CNFS 2>/dev/null)
    DDS=""
    for p in $MY_PIDS; do d=$(arg_val "$p" --datadir); [ -n "$d" ] && DDS="$DDS
$d"; done
    # shellcheck disable=SC2086
    [ -n "$ALLCNF" ] && d=$(cfg_val datadir $ALLCNF) && [ -n "$d" ] && DDS="$DDS
$d"
    [ -d /var/lib/mysql ] && DDS="$DDS
/var/lib/mysql"
    # shellcheck disable=SC2086
    LB=$( [ -n "$ALLCNF" ] && { cfg_val log-bin $ALLCNF; cfg_val log_bin $ALLCNF; } | tail -n1)
    echo "$DDS" | awk 'NF && !seen[$0]++' | while IFS= read -r dd; do
        [ -d "$dd" ] || continue
        sz mysql_datadir "$dd"
        find "$dd" -maxdepth 1 -type f -printf "$FILEFMT" >> "$W/60_mysql_files.tsv" 2>>"$ERR"
        for sub in "$dd"/*/; do
            [ -d "$sub" ] && sz mysql_db "${sub%/}"
        done
    done
    case $LB in
        /*) bd=$(dirname "$LB")
            [ -d "$bd" ] && find "$bd" -maxdepth 1 -type f -printf "$FILEFMT" >> "$W/60_mysql_files.tsv" 2>>"$ERR"
            sz mysql_binlog_dir "$bd" ;;
    esac
    step_end mysql
fi

# PostgreSQL（含 openGauss/Kingbase 同构目录，进程名不同另行识别）
PG_PIDS=$( { pids_of postgres; pids_of postmaster; } | sort -u)
PG_DIRS=""
for p in $PG_PIDS; do
    pp=$(awk '/^PPid:/{print $2}' "/proc/$p/status" 2>/dev/null)
    # 只取主进程（父进程不是 postgres 的那个），其工作目录就是数据目录
    pc=$(cat "/proc/$pp/comm" 2>/dev/null)
    case $pc in postgres|postmaster) continue ;; esac
    d=$(arg_val "$p" -D); [ -n "$d" ] || d=$(readlink "/proc/$p/cwd" 2>/dev/null)
    PG_DIRS="$PG_DIRS
$d"
done
for d in /var/lib/pgsql/*/data /var/lib/pgsql/data /var/lib/postgresql/*/main; do
    [ -f "$d/PG_VERSION" ] && PG_DIRS="$PG_DIRS
$d"
done
if [ -n "$(echo "$PG_DIRS" | tr -d '[:space:]')" ]; then
    step_start "PostgreSQL：只读配置与数据目录大小，不连库"
    F=61_db_pgsql.txt
    sec $F "process"
    for p in $PG_PIDS; do echo "pid=$p args=$(proc_args "$p")" | mask >> "$W/$F"; done
    echo "$PG_DIRS" | awk 'NF && !seen[$0]++' | while IFS= read -r dd; do
        [ -d "$dd" ] || continue
        printf '### datadir %s\n' "$dd" >> "$W/$F"
        [ -r "$dd/PG_VERSION" ] && echo "PG_VERSION=$(cat "$dd/PG_VERSION")" >> "$W/$F"
        for c in "$dd/postgresql.conf" "$dd/postgresql.auto.conf" /etc/postgresql/*/main/postgresql.conf; do
            [ -r "$c" ] || continue
            printf '### conf %s\n' "$c" >> "$W/$F"
            grep -v '^[[:space:]]*\(#\|$\)' "$c" | mask >> "$W/$F"
        done
        sz pg_data "$dd"
        sz pg_base "$dd/base"
        for w in pg_wal pg_xlog; do [ -d "$dd/$w" ] && sz pg_wal "$dd/$w" count; done
        for l in log pg_log; do [ -d "$dd/$l" ] && sz pg_log "$dd/$l" count; done
        for w in pg_wal pg_xlog; do
            [ -d "$dd/$w/archive_status" ] &&
                echo "archive_ready=$(find "$dd/$w/archive_status" -name '*.ready' 2>/dev/null | wc -l)" >> "$W/$F"
        done
        [ -d "$dd/pg_replslot" ] && echo "replslots=$(ls "$dd/pg_replslot" 2>/dev/null | tr '\n' ' ')" >> "$W/$F"
    done
    step_end pgsql
fi

# 其他数据库：MongoDB / Redis / Oracle / 达梦 / 人大金仓 / Elasticsearch
F=62_db_other.txt
other_db() { # other_db 名称 key pid...
    local name=$1 key=$2 p cwd; shift 2
    for p in "$@"; do
        cwd=$(readlink "/proc/$p/cwd" 2>/dev/null)
        printf '### %s pid=%s cwd=%s\n' "$name" "$p" "$cwd" >> "$W/$F"
        echo "args=$(proc_args "$p")" | mask >> "$W/$F"
    done
}
P=$(pids_of mongod)
if [ -n "$P" ] || [ -f /etc/mongod.conf ]; then
    # shellcheck disable=SC2086
    other_db MongoDB mongo $P
    catf $F /etc/mongod.conf
    d=$(awk '/dbPath:/{print $2; exit}' /etc/mongod.conf 2>/dev/null)
    [ -n "$d" ] && sz mongo_data "$d"
    sz mongo_data /var/lib/mongo
    sz mongo_data /var/lib/mongodb
fi
P=$(pids_of redis-server)
if [ -n "$P" ] || ls /etc/redis*.conf /etc/redis/*.conf >/dev/null 2>&1; then
    # shellcheck disable=SC2086
    other_db Redis redis $P
    catf $F /etc/redis.conf /etc/redis/*.conf
    for p in $P; do
        cwd=$(readlink "/proc/$p/cwd" 2>/dev/null)
        [ -d "$cwd" ] && find "$cwd" -maxdepth 1 -type f \( -name '*.rdb' -o -name '*.aof' -o -name 'temp-*' \) -printf "$FILEFMT" >> "$W/62_redis_files.tsv" 2>/dev/null
    done
    sz redis_data /var/lib/redis
fi
P=$(pgrep -f '^ora_pmon_' 2>/dev/null)
if [ -n "$P" ]; then
    # shellcheck disable=SC2086
    other_db Oracle oracle $P
    for p in $P; do
        tr '\0' '\n' < "/proc/$p/environ" 2>/dev/null | grep -E '^(ORACLE_BASE|ORACLE_HOME|ORACLE_SID)=' >> "$W/$F"
        ob=$(tr '\0' '\n' < "/proc/$p/environ" 2>/dev/null | sed -n 's/^ORACLE_BASE=//p')
        [ -d "$ob/diag" ] && sz oracle_diag "$ob/diag" count
        for a in "$ob"/admin/*/adump; do [ -d "$a" ] && sz oracle_adump "$a" count; done
        [ -d "$ob/oradata" ] && sz oracle_data "$ob/oradata"
    done
fi
P=$(pids_of dmserver)
if [ -n "$P" ]; then
    # shellcheck disable=SC2086
    other_db 达梦DM dm $P
    for p in $P; do
        ini=$(tr '\0' '\n' < "/proc/$p/cmdline" 2>/dev/null | sed -n 's/^\(path=\)\{0,1\}\(\/.*dm\.ini\)$/\2/p' | head -n1)
        [ -r "$ini" ] || continue
        echo "dm_ini=$ini" >> "$W/$F"
        grep -E '^[[:space:]]*(SYSTEM_PATH|CTL_PATH|ARCH_INI|BAK_PATH|INSTANCE_NAME)[[:space:]]*=' "$ini" >> "$W/$F" 2>/dev/null
        dd=$(dirname "$ini"); sz dm_data "$dd"
        [ -r "$dd/dmarch.ini" ] && { echo "### dmarch.ini"; grep -v '^[[:space:]]*#' "$dd/dmarch.ini"; } >> "$W/$F"
        ad=$(awk -F= '/^[[:space:]]*ARCH_DEST[[:space:]]*=/{gsub(/[ \t]/,"",$2); print $2; exit}' "$dd/dmarch.ini" 2>/dev/null)
        case $ad in /*) [ -d "$ad" ] && sz dm_arch "$ad" count ;; esac
    done
fi
P=$( { pids_of kingbase; pids_of gaussdb; } | sort -u)
if [ -n "$P" ]; then
    # shellcheck disable=SC2086
    other_db Kingbase/openGauss kingbase $P
    for p in $P; do
        d=$(arg_val "$p" -D); [ -n "$d" ] || d=$(readlink "/proc/$p/cwd" 2>/dev/null)
        [ -d "$d" ] && sz kingbase_data "$d"
        for w in "$d/sys_wal" "$d/pg_wal" "$d/pg_xlog"; do [ -d "$w" ] && sz kingbase_wal "$w" count; done
    done
fi
P=$(pgrep -f 'org.elasticsearch.bootstrap' 2>/dev/null)
if [ -n "$P" ]; then
    # shellcheck disable=SC2086
    other_db Elasticsearch es $P
    for c in /etc/elasticsearch/elasticsearch.yml; do catf $F "$c"; done
    d=$(awk '/^[[:space:]]*path\.data:/{print $2; exit}' /etc/elasticsearch/elasticsearch.yml 2>/dev/null)
    [ -n "$d" ] && sz es_data "$d"
    sz es_data /var/lib/elasticsearch
fi

# Docker / containerd
if [ -d /var/lib/docker ] || have docker; then
    step_start "Docker：目录大小与 docker system df（只读）"
    F=63_docker.txt
    if have docker; then
        cap $F docker system df
        { printf '$ docker info (root dir / logging driver)\n'; t 30 docker info --format '{{.DockerRootDir}} {{.LoggingDriver}}'; } >> "$W/$F" 2>>"$ERR"
    fi
    catf $F /etc/docker/daemon.json
    sz docker /var/lib/docker
    sz docker_overlay2 /var/lib/docker/overlay2
    sz docker_containers /var/lib/docker/containers
    sz docker_volumes /var/lib/docker/volumes
    sz docker_buildkit /var/lib/docker/buildkit
    [ -d /var/lib/docker/containers ] &&
        find /var/lib/docker/containers -maxdepth 2 -name '*-json.log' -printf "$FILEFMT" >> "$W/63_docker_logs.tsv" 2>>"$ERR"
    step_end docker
fi
sz containerd /var/lib/containerd

# 常见位置
sz tmp /tmp
sz var_tmp /var/tmp
sz coredump /var/lib/systemd/coredump count
sz crash /var/crash count
sz abrt /var/spool/abrt count
sz abrt /var/tmp/abrt count
sz sa /var/log/sa

# ---------- 90 终端摘要 ----------
{
    echo "== 根分区 =="
    df -hPT / | tail -n 1
    df -iP / | awk 'NR==2{print "inode 使用率 " $5}'
    echo
    echo "== / 下各一级目录（KB） =="
    awk -F'\t' '{ n = split($2, a, "/"); if (n == 2 && $2 != "/") print }' "$W/20_du_root.tsv" | sort -t"$(printf '\t')" -k1,1nr | head -n 12
    echo
    echo "== 第 2~4 层 >= 1GB 的目录（KB） =="
    awk -F'\t' '{ n = split($2, a, "/"); if (n >= 3 && n <= 5 && $1 >= 1048576) print }' "$W/20_du_root.tsv" | sort -t"$(printf '\t')" -k1,1nr | head -n 15
    echo
    echo "== 已删除仍被占用 =="
    awk -F'\t' '!seen[$5]++ { s += $4; n++ } END { printf "%d 个文件，共 %.1f MB\n", n, s / 1048576 }' "$W/31_deleted.tsv"
    echo
    echo "== 最大的 10 个文件 =="
    sort -t"$(printf '\t')" -k1,1nr "$W/30_files.tsv" | head -n 10 | awk -F'\t' '{ printf "%10.1f MB  %s\n", $1 / 1048576, $5 }'
    echo
    echo "== 文件名模式（数字换 #，>=1MB 的文件） =="
    awk -F'\t' '{ p = $5; d = p; sub(/\/[^\/]*$/, "", d); b = substr(p, length(d) + 2); gsub(/[0-9]+/, "#", b)
        k = d "/" b; s[k] += $1; c[k]++ }
        END { for (k in s) if (c[k] >= 3) printf "%.0f\t%d\t%s\n", s[k], c[k], k }' "$W/30_files.tsv" |
        sort -t"$(printf '\t')" -k1,1nr | head -n 10 | awk -F'\t' '{ printf "%10.1f MB  %5d 个  %s\n", $1 / 1048576, $2, $3 }'
} > "$W/90_summary.txt" 2>>"$ERR"

echo "finished_at=$(date +%s)" >> "$W/00_meta.txt"

# ---------- 打包 ----------
OUT="$OUTDIR/$BASE.tar.gz"
if tar -C "$TMPD" -czf "$OUT" "$BASE" 2>>"$ERR"; then
    cat "$W/90_summary.txt"
    echo
    log "完成：$OUT（$(du -h "$OUT" | awk '{print $1}')）"
    log "拷回本机后用 https://xvuln.com/diskcheck/ 打开分析（浏览器本地解析，不上传）"
else
    log "打包失败，见 $ERR"
    exit 1
fi
