aistack 用户、SSH 端口与 LVM 磁盘清理脚本
用途与适用范围
本脚本面向使用 systemd、OpenSSH 和 LVM2 的 Linux 主机,目标发行版包括 Ubuntu、CentOS、Rocky Linux、openEuler 和麒麟。实际行为以主机的服务配置和磁盘布局为准;目前只做了静态检查,尚未在这些发行版上逐一执行验证。
- 不带参数:只检查并展示计划,不修改系统。
-
--apply:创建aistack、配置免密 sudo、把 SSH 服务端口设为 2222,并在人工确认后清理一块 LVM 磁盘。 - 只接受唯一一块符合条件的候选磁盘;不会让脚本在
/dev/sdb与/dev/vdb等设备名之间猜测。
注意: LVM 删除和
wipefs -af会破坏存储配置,无法靠脚本自动撤销。操作前先备份数据,并通过控制台或仍可用的 SSH 会话操作。
使用方式
将本文末尾代码保存为 aistack-setup.sh,或者使用同目录下的脚本文件。需要 root 权限;预览也需要读取完整 LVM 信息。
sudo bash aistack-setup.sh
sudo bash aistack-setup.sh --apply
第二条命令到达清盘步骤时会要求输入完整磁盘路径,例如 /dev/sdb。输入不一致就停止清盘。不要用其他参数代替 --apply。
执行前确认 2222/tcp 已在云安全组及网络访问控制中放通。若本机正在使用 UFW、firewalld、强制模式 SELinux 或 SSH socket activation,执行模式会停止;需先按该系统的管理方式处理 2222 端口。脚本不会自动修改这些设置。
执行流程与副作用
| 步骤 | 脚本行为 | 副作用或停止条件 |
|---|---|---|
| 1. 环境检查 | 检查 root 身份、命令和 SSH 服务 | 缺少依赖或服务时停止 |
| 2. 磁盘识别 | 从 PV 找到物理磁盘,检查挂载、非 LVM 分区和跨盘 VG | 无法唯一选出候选盘时停止;没有 PV 时跳过清盘 |
| 3. 创建用户 | 创建 aistack,写入 /etc/sudoers.d/90-aistack 并校验 |
该用户获得无需输入密码的完整 sudo 权限 |
| 4. 修改 SSH | 备份并修改 /etc/ssh/sshd_config,校验后重启 ssh 或 sshd
|
新连接应使用 2222 端口;配置或重启失败时尝试恢复配置 |
| 5. 清理磁盘 | 输入磁盘路径确认,再次识别;依次删除 LV、VG、PV,最后执行 wipefs -af
|
LVM 数据和设备签名被删除;失败时不会自动回滚已删除部分 |
执行顺序很重要: 用户和 SSH 修改发生在磁盘确认之前。因此,即使输入磁盘路径不匹配,前两项修改也可能已经完成。重新运行前先查看现状。
“免密”在此指 sudo 无需密码。脚本没有设置 aistack 的登录密码,也没有配置 SSH 公钥;若需要该用户通过 SSH 登录,应另外安装其公钥。
wipefs 清除文件系统、RAID 或分区表等签名,不会逐字节覆写磁盘数据;不能将它当作安全擦除。对带分区表的整盘执行 wipefs -af 时,主要清除整盘可见签名;脚本先对识别出的 PV 执行 pvremove。
停止条件与维护说明
- 候选磁盘有挂载点或交换空间、包含非 LVM 分区、VG 横跨多块磁盘、候选盘不止一块,或 PV 所在物理盘无法识别时,停止清盘。
-
--apply时发现 SSH socket activation、运行中的 UFW/firewalld 或强制模式 SELinux,会在创建用户之前停止。 - 代码按磁盘识别、环境检查、用户创建、SSH 修改、磁盘清理拆成函数;每个函数只处理一类工作。SSH 配置备份文件为
/etc/ssh/sshd_config.aistack-backup,重复运行会覆盖这份备份。 - 脚本适合简单、单磁盘的 LVM 布局。若需要在多盘 VG、复杂设备映射或自动化批量主机上执行,应先明确目标盘和回滚策略,再考虑用 Python 实现结构化盘符解析与可审计的执行计划。
脚本代码
#!/usr/bin/env bash
# Preview by default. Run with --apply to make changes.
set -euo pipefail
mode=${1:-}
[[ $# -le 1 && ( -z $mode || $mode == --apply ) ]] || {
echo '用法: bash aistack-setup.sh [--apply]' >&2
exit 2
}
die() { echo "错误: $*" >&2; exit 1; }
command_exists() { command -v "$1" >/dev/null 2>&1; }
# Return the physical disk containing a PV. Reject unusual layouts.
disk_for_pv() {
local pv=$1 name type disk=''
while read -r name type; do
if [[ $type == disk ]]; then
[[ -z $disk ]] || return 1
disk=$name
fi
done < <(lsblk -spno NAME,TYPE "$pv")
[[ -n $disk ]] || return 1
printf '%s\n' "$disk"
}
# Select exactly one disk whose PVs and VGs are wholly on that disk.
select_lvm_disk() {
local pv vg disk other_pv other_vg other_disk mountpoint disk_pv
local -a candidates=() pvs_on_disk=() vgs_on_disk=()
local -A seen_disks=()
local -a all_pvs=() all_vgs=()
while IFS='|' read -r pv vg; do
pv=${pv//[[:space:]]/}
vg=${vg//[[:space:]]/}
[[ -n $pv ]] || continue
all_pvs+=("$pv")
all_vgs+=("$vg")
done < <(pvs --noheadings --separator '|' -o pv_name,vg_name)
((${#all_pvs[@]})) || { echo 'LVM: 未发现 PV;跳过磁盘清理。'; return 0; }
for pv in "${all_pvs[@]}"; do
disk=$(disk_for_pv "$pv") || die "无法识别 PV $pv 所在的物理磁盘"
[[ -n ${seen_disks[$disk]:-} ]] && continue
seen_disks[$disk]=1
pvs_on_disk=()
vgs_on_disk=()
local safe=1 reason=''
# Any mounted filesystem or swap on this disk makes it ineligible.
while IFS= read -r mountpoint; do
if [[ -n $mountpoint ]]; then safe=0; reason='有挂载点或交换空间'; fi
done < <(lsblk -nrpo MOUNTPOINTS "$disk")
for ((i=0; i<${#all_pvs[@]}; i++)); do
other_pv=${all_pvs[i]}
other_vg=${all_vgs[i]}
other_disk=$(disk_for_pv "$other_pv") || die "无法识别 PV $other_pv 所在的物理磁盘"
if [[ $other_disk == "$disk" ]]; then
pvs_on_disk+=("$other_pv")
[[ -z $other_vg ]] || vgs_on_disk+=("$other_vg")
fi
done
# Do not erase a disk that also contains non-LVM partitions.
while read -r other_pv other_vg; do
if [[ $other_vg == part ]]; then
local found=0
for disk_pv in "${pvs_on_disk[@]}"; do
[[ $disk_pv == "$other_pv" ]] && found=1
done
if (( ! found )); then safe=0; reason='包含非 LVM 分区'; fi
fi
done < <(lsblk -nrpo NAME,TYPE "$disk")
for vg in "${vgs_on_disk[@]}"; do
for ((i=0; i<${#all_pvs[@]}; i++)); do
if [[ ${all_vgs[i]} == "$vg" ]]; then
other_disk=$(disk_for_pv "${all_pvs[i]}") || die '无法检查跨盘 VG'
if [[ $other_disk != "$disk" ]]; then safe=0; reason='VG 跨磁盘'; fi
fi
done
done
if ((safe)); then
echo "候选磁盘: $disk;PV: ${pvs_on_disk[*]};VG: ${vgs_on_disk[*]:-无}"
candidates+=("$disk")
else
echo "跳过磁盘: $disk ($reason)"
fi
done
((${#candidates[@]} == 1)) || die "需要恰好一块安全候选磁盘,当前 ${#candidates[@]} 块;不会清理磁盘"
selected_disk=${candidates[0]}
selected_pvs=()
selected_vgs=()
local -A seen_vgs=()
for ((i=0; i<${#all_pvs[@]}; i++)); do
other_disk=$(disk_for_pv "${all_pvs[i]}") || die '无法复核 PV'
if [[ $other_disk == "$selected_disk" ]]; then
selected_pvs+=("${all_pvs[i]}")
if [[ -n ${all_vgs[i]} && -z ${seen_vgs[${all_vgs[i]}]:-} ]]; then
selected_vgs+=("${all_vgs[i]}")
seen_vgs[${all_vgs[i]}]=1
fi
fi
done
}
check_environment() {
[[ $EUID -eq 0 ]] || die '请使用 root 运行;预览也需要读取完整 LVM 信息'
local cmd
for cmd in getent useradd systemctl sshd visudo lsblk pvs vgs lvs lvremove vgremove pvremove wipefs findmnt; do
command_exists "$cmd" || die "缺少命令: $cmd"
done
[[ -f /etc/ssh/sshd_config ]] || die '未找到 /etc/ssh/sshd_config'
# Socket activation can keep listening on 22 regardless of sshd_config.
if [[ $mode == --apply ]] && { systemctl is-active --quiet ssh.socket || systemctl is-active --quiet sshd.socket; }; then
die '检测到 SSH socket activation;需先针对该系统配置 socket 的监听端口'
fi
if systemctl list-unit-files sshd.service --no-legend 2>/dev/null | grep -q '^sshd.service'; then
ssh_service=sshd
elif systemctl list-unit-files ssh.service --no-legend 2>/dev/null | grep -q '^ssh.service'; then
ssh_service=ssh
else
die '找不到 ssh/sshd systemd 服务'
fi
if [[ $mode == --apply ]] && command_exists getenforce && [[ $(getenforce) == Enforcing ]]; then
die 'SELinux 为 Enforcing;需先允许 SSH 使用 2222/tcp'
fi
if [[ $mode == --apply ]] && command_exists firewall-cmd && firewall-cmd --state >/dev/null 2>&1; then
die 'firewalld 正在运行;需先放通 2222/tcp'
fi
if [[ $mode == --apply ]] && command_exists ufw && ufw status | grep -q '^Status: active'; then
die 'UFW 正在运行;需先放通 2222/tcp'
fi
}
create_user() {
if getent passwd aistack >/dev/null; then
echo '用户 aistack 已存在'
else
useradd -m -s /bin/bash aistack
fi
printf 'aistack ALL=(ALL) NOPASSWD: ALL\n' > /etc/sudoers.d/90-aistack
chmod 0440 /etc/sudoers.d/90-aistack
visudo -cf /etc/sudoers.d/90-aistack >/dev/null
}
change_ssh_port() {
local config=/etc/ssh/sshd_config backup=/etc/ssh/sshd_config.aistack-backup
local tmp
tmp=$(mktemp)
cp -p "$config" "$backup"
# Comment every active Port in the main file, then set one explicit port.
awk 'BEGIN { print "Port 2222" }
/^[[:space:]]*Port[[:space:]]+/ { print "# aistack disabled: " $0; next }
{ print }' "$config" > "$tmp"
cat "$tmp" > "$config"
rm -f "$tmp"
if ! sshd -t || [[ $(sshd -T | awk '$1 == "port" { print $2 }' | sort -u) != 2222 ]]; then
cp -p "$backup" "$config"
die 'SSH 配置检查失败,已恢复原文件'
fi
if ! systemctl restart "$ssh_service"; then
cp -p "$backup" "$config"
systemctl restart "$ssh_service" || true
die 'SSH 服务重启失败,已恢复配置文件'
fi
}
clear_lvm_disk() {
local vg pv answer
[[ -n ${selected_disk:-} ]] || return 0
echo "即将永久删除 $selected_disk 上的所有 LVM 数据和磁盘签名。"
read -r -p "输入磁盘路径 $selected_disk 以确认: " answer
[[ $answer == "$selected_disk" ]] || die '磁盘确认不匹配,已停止清理'
# Recheck immediately before destructive commands.
select_lvm_disk
[[ $answer == "$selected_disk" ]] || die '候选磁盘已变化,已停止清理'
for vg in "${selected_vgs[@]}"; do
if lvs --noheadings -o lv_name "$vg" | grep -q '[^[:space:]]'; then
lvremove -fy "$vg"
fi
vgremove -fy "$vg"
done
for pv in "${selected_pvs[@]}"; do pvremove -ffy "$pv"; done
wipefs -af "$selected_disk"
}
check_environment
selected_disk=''
selected_pvs=()
selected_vgs=()
select_lvm_disk
echo '计划: 创建 aistack 并赋予免密 sudo;SSH 端口改为 2222。'
echo "计划: 重启 $ssh_service;清理磁盘 ${selected_disk:-无} 的 LV/VG/PV,再运行 wipefs -af。"
if [[ $mode != --apply ]]; then
echo '当前仅预览。执行请使用: bash aistack-setup.sh --apply'
exit 0
fi
create_user
change_ssh_port
clear_lvm_disk
echo '完成。请从另一终端验证 SSH 2222 端口可登录。'
已完成的验证
已通过 bash -n 语法检查和 shellcheck 静态检查;未在目标 Linux 主机上执行 --apply。