Files
key.sh/key.sh
T
2026-09-12 02:03:06 +08:00

1370 lines
59 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
#=============================================================
# SSH Security Installer (key.sh)
# Multi-distro: Debian/Ubuntu, RHEL/CentOS/Fedora/Rocky/Alma,
# Alpine, Arch/Manjaro, openSUSE
#=============================================================
RED="\033[31m"; GREEN="\033[32m"; YELLOW="\033[33m"
BLUE="\033[34m"; PURPLE="\033[35m"; CYAN="\033[36m"
GRAY="\033[90m"; BOLD="\033[1m"; RESET="\033[0m"
INFO="${GREEN}[INFO]${RESET}"; WARN="${YELLOW}[WARN]${RESET}"; ERROR="${RED}[ERROR]${RESET}"
JAIL_CONF="/etc/fail2ban/jail.local"
LOG_FILE="/var/log/fail2ban.log"
TARGET_JAIL="sshd"
SSHD_MAIN="/etc/ssh/sshd_config"
SSHD_CONF_DIR="/etc/ssh/sshd_config.d"
SSHD_CUSTOM="${SSHD_CONF_DIR}/99-key.sh.conf"
[ "$EUID" -ne 0 ] && SUDO="sudo" || SUDO=""
PKG_MGR=""; INIT_SYS=""; SSH_LOG=""; OS_NAME=""; OS_ID=""; OS_VER=""; OS_SHORT=""
# ============ 命令行帮助 ============
case "${1:-}" in
-h|--help)
cat <<HELP
SSH Security Installer
用法:
$0 [选项]
交互模式:
直接运行 $0 进入交互式菜单
命令行模式:
-g <用户名> 从 GitHub 拉取公钥
-u <URL> 从自定义 URL 拉取公钥
-f <文件> 从本地文件导入公钥
-p <端口> 修改 SSH 端口
-d 禁用密码登录
-o 覆盖模式(清空已存公钥)
其他:
-h, --help 显示帮助
HELP
exit 0 ;;
esac
# ============ 能力检测 ============
detect_os() {
if [ -f /etc/os-release ]; then
. /etc/os-release
OS_NAME="${NAME:-Unknown}"; OS_ID="${ID:-unknown}"; OS_VER="${VERSION_ID:-}"
else
OS_NAME=$(uname -s); OS_VER=$(uname -r); OS_ID="unknown"
fi
case "$OS_ID" in
debian) OS_SHORT="Debian" ;;
ubuntu) OS_SHORT="Ubuntu" ;;
linuxmint) OS_SHORT="Linux Mint" ;;
kali) OS_SHORT="Kali" ;;
rhel) OS_SHORT="RHEL" ;;
centos) OS_SHORT="CentOS" ;;
fedora) OS_SHORT="Fedora" ;;
rocky) OS_SHORT="Rocky" ;;
almalinux) OS_SHORT="AlmaLinux" ;;
ol) OS_SHORT="Oracle Linux" ;;
amzn) OS_SHORT="Amazon Linux" ;;
alpine) OS_SHORT="Alpine" ;;
arch) OS_SHORT="Arch" ;;
manjaro) OS_SHORT="Manjaro" ;;
endeavouros) OS_SHORT="EndeavourOS" ;;
opensuse*) OS_SHORT="openSUSE" ;;
sles) OS_SHORT="SLES" ;;
*)
local first="${OS_ID:0:1}"
OS_SHORT="$(echo "$first" | tr '[:lower:]' '[:upper:]')${OS_ID:1}"
;;
esac
}
detect_pkg_mgr() {
for pm in apt dnf yum zypper pacman apk; do
command -v "$pm" &>/dev/null && { PKG_MGR="$pm"; return; }
done
PKG_MGR="unknown"
}
detect_init() {
if [ -d /run/systemd/system ]; then INIT_SYS="systemd"
elif [ -d /run/openrc ]; then INIT_SYS="openrc"
elif [ -x /sbin/init ]; then INIT_SYS="sysvinit"
else INIT_SYS="unknown"; fi
}
detect_ssh_log() {
case "$OS_ID" in
debian|ubuntu|linuxmint|kali) SSH_LOG="/var/log/auth.log" ;;
rhel|centos|fedora|rocky|almalinux|ol|amzn) SSH_LOG="/var/log/secure" ;;
alpine) SSH_LOG="/var/log/messages" ;;
arch|manjaro|endeavouros) SSH_LOG="/var/log/auth.log" ;;
opensuse*|sles) SSH_LOG="/var/log/messages" ;;
*) SSH_LOG="/var/log/auth.log" ;;
esac
}
# ============ 服务管理抽象 ============
svc_start() { case "$INIT_SYS" in systemd) $SUDO systemctl start "$1" 2>/dev/null;; openrc) $SUDO rc-service "$1" start 2>/dev/null;; sysvinit) $SUDO service "$1" start 2>/dev/null;; esac; }
svc_stop() { case "$INIT_SYS" in systemd) $SUDO systemctl stop "$1" 2>/dev/null;; openrc) $SUDO rc-service "$1" stop 2>/dev/null;; sysvinit) $SUDO service "$1" stop 2>/dev/null;; esac; }
svc_restart(){ case "$INIT_SYS" in systemd) $SUDO systemctl restart "$1" 2>/dev/null;; openrc) $SUDO rc-service "$1" restart 2>/dev/null;; sysvinit) $SUDO service "$1" restart 2>/dev/null;; esac; }
svc_enable() { case "$INIT_SYS" in systemd) $SUDO systemctl enable "$1" 2>/dev/null;; openrc) $SUDO rc-update add "$1" default 2>/dev/null;; sysvinit) $SUDO update-rc.d "$1" defaults 2>/dev/null || chkconfig "$1" on 2>/dev/null;; esac; }
svc_disable(){ case "$INIT_SYS" in systemd) $SUDO systemctl disable "$1" 2>/dev/null;; openrc) $SUDO rc-update del "$1" default 2>/dev/null;; sysvinit) $SUDO update-rc.d -f "$1" remove 2>/dev/null || chkconfig "$1" off 2>/dev/null;; esac; }
# ============ 包管理抽象 ============
pkg_install() {
case "$PKG_MGR" in
apt) $SUDO apt-get update -qq && $SUDO apt-get install -y "$@" ;;
dnf) $SUDO dnf install -y "$@" ;;
yum) $SUDO yum install -y "$@" ;;
zypper) $SUDO zypper --non-interactive install "$@" ;;
pacman) $SUDO pacman -Sy --noconfirm "$@" ;;
apk) $SUDO apk add --no-cache "$@" ;;
*) echo -e "${ERROR} 未知包管理器"; return 1 ;;
esac
}
pkg_remove() {
case "$PKG_MGR" in
apt) $SUDO apt-get remove --purge -y "$@"; $SUDO apt-get autoremove -y ;;
dnf) $SUDO dnf remove -y "$@" ;;
yum) $SUDO yum remove -y "$@" ;;
zypper) $SUDO zypper --non-interactive remove "$@" ;;
pacman) $SUDO pacman -R --noconfirm "$@" ;;
apk) $SUDO apk del "$@" ;;
*) return 1 ;;
esac
}
# 检测 EOL 系统(仅用于提示,不自动修改配置)
check_eol_system() {
case "$OS_ID" in
debian)
case "$OS_VER" in
8|9|10|11)
echo -e "\n${YELLOW}${BOLD}[提示] 检测到 Debian ${OS_VER},该系统已停止官方支持。${RESET}"
echo -e "${YELLOW}apt 源可能已失效,导致无法正常安装软件。${RESET}"
echo -e "${YELLOW}如果安装失败,可将 /etc/apt/sources.list 改为 archive 源:${RESET}\n"
local codename=""
case "$OS_VER" in
8) codename="jessie" ;;
9) codename="stretch" ;;
10) codename="buster" ;;
11) codename="bullseye" ;;
esac
if [ -n "$codename" ]; then
echo -e "${CYAN}示例:${RESET}"
echo -e " deb http://archive.debian.org/debian ${codename} main contrib non-free"
echo -e " deb http://archive.debian.org/debian ${codename}-updates main contrib non-free"
echo -e "\n${CYAN}修改后执行:sudo apt update${RESET}"
fi
echo ""
;;
esac
;;
ubuntu)
case "$OS_VER" in
14.04|16.04|18.04|20.04|21.04|21.10|22.10)
echo -e "\n${YELLOW}${BOLD}[提示] 检测到 Ubuntu ${OS_VER},该系统已停止官方支持。${RESET}"
echo -e "${YELLOW}apt 源可能已失效,建议升级到 LTS 版本。${RESET}\n"
;;
esac
;;
esac
}
# ============ SSH 配置管理 ============
# 读取顺序:优先读 99-key.sh.conf(我们写入的地方)→ 主文件 → 其他 .d 文件
# 与 OpenSSH "First Match Wins" 生效顺序一致
get_sshd_config_val() {
local key="$1" default_val="$2"
local val=""
# 1. 优先读取我们写入的 99-key.sh.conf
if [ -f "$SSHD_CUSTOM" ]; then
val=$(grep -iE "^[[:space:]]*${key}[[:space:]=]+" "$SSHD_CUSTOM" | tail -n 1 | \
sed -E 's/^[[:space:]]*[^[:space:]=]+[[:space:]=]+//' | awk '{print $1}')
fi
# 2. 主文件
if [ -z "$val" ] && [ -f "$SSHD_MAIN" ]; then
val=$(grep -iE "^[[:space:]]*${key}[[:space:]=]+" "$SSHD_MAIN" | tail -n 1 | \
sed -E 's/^[[:space:]]*[^[:space:]=]+[[:space:]=]+//' | awk '{print $1}')
fi
# 3. 其他 .d 文件
if [ -z "$val" ] && [ -d "$SSHD_CONF_DIR" ]; then
for conf in "${SSHD_CONF_DIR}"/*.conf; do
[ -f "$conf" ] || continue
[ "$conf" = "$SSHD_CUSTOM" ] && continue
val=$(grep -iE "^[[:space:]]*${key}[[:space:]=]+" "$conf" | tail -n 1 | \
sed -E 's/^[[:space:]]*[^[:space:]=]+[[:space:]=]+//' | awk '{print $1}')
[ -n "$val" ] && break
done
fi
[ -n "$val" ] && { echo "$val"; return; }
echo "$default_val"
}
# 写入 SSH 配置
# 优先写入 99-key.sh.conf(避开 Match 块陷阱 + 最高优先级)
# 无 sshd_config.d 目录时才写主文件
set_sshd_config() {
local param="$1" value="$2"
if [ -d "$SSHD_CONF_DIR" ]; then
# 1. 注释其他 .conf 中的同参数项,避免 First Match Wins 冲突
for conf in "${SSHD_CONF_DIR}"/*.conf; do
[ -f "$conf" ] || continue
[ "$conf" = "$SSHD_CUSTOM" ] && continue
$SUDO sed -i -E "s/^[[:space:]]*#?[[:space:]]*(${param})[[:space:]=].*/# \1 (disabled by key.sh)/" "$conf"
done
# 2. 确保自定义文件存在
[ ! -f "$SSHD_CUSTOM" ] && echo "${param} ${value}" | $SUDO tee "$SSHD_CUSTOM" > /dev/null
# 3. 删除自定义文件中的旧值,写入新值
$SUDO sed -i -E "/^[[:space:]]*#?[[:space:]]*(${param})[[:space:]=]/d" "$SSHD_CUSTOM"
echo "${param} ${value}" | $SUDO tee -a "$SSHD_CUSTOM" > /dev/null
else
# 传统主文件模式(无 sshd_config.d 目录的老系统)
$SUDO sed -i -E "/^[[:space:]]*#?[[:space:]]*(${param})[[:space:]=]/d" "$SSHD_MAIN"
if grep -q -iE "^[[:space:]]*Match[[:space:]]+" "$SSHD_MAIN"; then
# 存在 Match 块,插入到第一个 Match 之前(大小写不敏感)
local tmpf; tmpf=$(mktemp)
awk -v p="${param}" -v v="${value}" '
BEGIN { inserted=0 }
tolower($0) ~ /^[[:space:]]*match[[:space:]]/ && !inserted {
print p " " v
inserted=1
}
{ print }
' "$SSHD_MAIN" > "$tmpf"
$SUDO cp "$tmpf" "$SSHD_MAIN"
rm -f "$tmpf"
else
echo "${param} ${value}" | $SUDO tee -a "$SSHD_MAIN" > /dev/null
fi
fi
}
restart_sshd() {
echo -e "${INFO} 正在检测 SSH 配置文件语法..."
if command -v sshd &>/dev/null; then
if ! $SUDO sshd -t; then
echo -e "${ERROR} SSH 配置文件测试失败!检测到语法错误,已取消重启以防止断连锁死!"
return 1
fi
fi
echo -e "${INFO} 正在重启 SSH 服务..."
local restart_ok=1
if [ "$INIT_SYS" = "systemd" ]; then
if systemctl list-unit-files 2>/dev/null | grep -q "ssh.socket"; then
echo -e "${INFO} 检测到 systemd ssh.socket,正在强制关闭并屏蔽 (mask)..."
$SUDO systemctl stop ssh.socket &>/dev/null
$SUDO systemctl disable ssh.socket &>/dev/null
$SUDO systemctl mask ssh.socket &>/dev/null
fi
$SUDO systemctl daemon-reload &>/dev/null
local ssh_unit=""
if systemctl list-unit-files 2>/dev/null | grep -q "^ssh.service"; then
ssh_unit="ssh"
elif systemctl list-unit-files 2>/dev/null | grep -q "^sshd.service"; then
ssh_unit="sshd"
fi
if [ -n "$ssh_unit" ]; then
# 防御性 unmask:避免上层曾 mask 过 ssh.service 导致 enable 静默失败
$SUDO systemctl unmask "${ssh_unit}.service" &>/dev/null || true
svc_enable "$ssh_unit"
svc_restart "$ssh_unit" && restart_ok=0
else
echo -e "${ERROR} 未找到 ssh.service 或 sshd.service!"
fi
elif [ "$INIT_SYS" = "openrc" ]; then
svc_restart sshd && restart_ok=0
else
svc_restart sshd 2>/dev/null && restart_ok=0
if [ "$restart_ok" -ne 0 ]; then
svc_restart ssh 2>/dev/null && restart_ok=0
fi
fi
if [ "$restart_ok" -eq 0 ]; then
echo -e "${INFO} ${GREEN}SSH 服务重启成功!${RESET}"
return 0
else
echo -e "${ERROR} SSH 服务重启失败,请检查配置文件!"
return 1
fi
}
# ============ 依赖安装 ============
check_dependencies() {
local pkgs=()
for dep in curl ssh-keygen awk; do
command -v "$dep" &>/dev/null && continue
case "$dep" in
curl) pkgs+=("curl") ;;
ssh-keygen) case "$PKG_MGR" in apt) pkgs+=("openssh-client");; dnf|yum) pkgs+=("openssh-clients");; apk) pkgs+=("openssh-client");; pacman) pkgs+=("openssh");; zypper) pkgs+=("openssh");; esac ;;
awk) command -v gawk &>/dev/null && continue; pkgs+=("gawk") ;;
esac
done
[ ${#pkgs[@]} -gt 0 ] && { echo -e "${WARN} 正在安装依赖: ${pkgs[*]}"; pkg_install "${pkgs[@]}"; }
}
init_ssh_dir() {
mkdir -p "${HOME}/.ssh"; chmod 700 "${HOME}/.ssh"
touch "${HOME}/.ssh/authorized_keys"; chmod 600 "${HOME}/.ssh/authorized_keys"
}
# 追加公钥
# - 保留选项前缀(如 from="1.2.3.4",command="...")
# - 丢弃尾部注释(避免污染元数据标签)
# - 用 base64 主体做去重
append_key_with_meta() {
local pub_content="$1" source_tag="$2" auth_file="${HOME}/.ssh/authorized_keys"
local ts; ts=$(date "+%Y-%m-%d %H:%M:%S")
init_ssh_dir
while IFS= read -r line; do
[ -z "$line" ] && continue
line=$(echo "$line" | sed -e 's/[[:space:]]*$//')
# 从行首保留到 base64 主体结束(含选项前缀和密钥类型),丢弃之后的内容
local core_key
core_key=$(echo "$line" | awk '{
for(i=1;i<=NF;i++){
if($i ~ /^AAAA/){
for(j=1;j<=i;j++) printf "%s%s", $j, (j<i?" ":"")
print ""
exit
}
}
}')
[ -z "$core_key" ] && continue
# 用 base64 主体做去重
local key_base64
key_base64=$(echo "$core_key" | awk '{print $NF}')
if grep -qF "$key_base64" "$auth_file" 2>/dev/null; then
echo -e "${WARN} 该公钥已经存在于 authorized_keys 中,已跳过重复追加。"
continue
fi
echo "${core_key} [${ts}|${source_tag}]" >> "$auth_file"
echo -e "${INFO} ${GREEN}已成功追加公钥 (${source_tag})${RESET}"
done <<< "$pub_content"
chmod 600 "$auth_file"
}
# 统计 authorized_keys 中有效公钥数量
count_authorized_keys() {
local auth_file="${HOME}/.ssh/authorized_keys"
if [ -f "$auth_file" ]; then
awk '!/^[[:space:]]*(#|$)/{c++} END{print c+0}' "$auth_file"
else
echo "0"
fi
}
# ============ Fail2Ban ============
# 用 awk 精确解析 INI 块,避免 sed 范围瞬间闭合的 bug
get_f2b_conf() {
local key=$1
[ -f "$JAIL_CONF" ] || return
awk -v t="$TARGET_JAIL" -v k="$key" '
BEGIN { in_block=0; result="" }
$0 ~ "^\\[" t "\\][[:space:]]*$" { in_block=1; next }
/^[[:space:]]*\[/ { in_block=0 }
in_block && $0 ~ "^[[:space:]]*" k "[[:space:]]*=" {
value=$0
sub(/^[[:space:]]*[^=]+=[[:space:]]*/, "", value)
sub(/[[:space:]]+$/, "", value)
result = value
}
END { if (result != "") print result }
' "$JAIL_CONF"
}
set_f2b_conf() {
local key=$1 val=$2
if [ ! -f "$JAIL_CONF" ]; then
echo "[$TARGET_JAIL]" | $SUDO tee "$JAIL_CONF" > /dev/null
fi
# 判断该 key 是否已存在
if [ -n "$(get_f2b_conf "$key")" ]; then
# 用 awk 重写整个文件,精确替换块内该 key 的值
local tmpf; tmpf=$(mktemp)
awk -v t="$TARGET_JAIL" -v k="$key" -v v="$val" '
BEGIN { in_block=0; done=0 }
$0 ~ "^\\[" t "\\][[:space:]]*$" { in_block=1; print; next }
/^[[:space:]]*\[/ { in_block=0 }
in_block && !done && $0 ~ "^[[:space:]]*" k "[[:space:]]*=" {
print k " = " v
done=1
next
}
{ print }
' "$JAIL_CONF" > "$tmpf"
$SUDO cp "$tmpf" "$JAIL_CONF"
rm -f "$tmpf"
else
# 不存在则追加到 [sshd] 下方
$SUDO sed -i "/^\[${TARGET_JAIL}\]/a ${key} = ${val}" "$JAIL_CONF"
fi
}
restart_f2b() {
echo -e "${INFO} 正在重载 Fail2Ban 配置..."
svc_restart fail2ban
for i in {1..5}; do
if fail2ban-client ping >/dev/null 2>&1; then
echo -e "${INFO} ${GREEN}成功!配置已生效。${RESET}"; return 0
fi; sleep 1
done
echo -e "${ERROR} Fail2Ban 重启超时或失败。"
echo -e "${YELLOW}请手动运行 'journalctl -u fail2ban -n 50' 排查错误。${RESET}"
}
get_fail2ban_status() {
if command -v fail2ban-client >/dev/null 2>&1 && fail2ban-client ping >/dev/null 2>&1; then
local count
count=$(fail2ban-client status "$TARGET_JAIL" 2>/dev/null | grep -i "Currently banned" | awk '{print $NF}')
echo -e "${GREEN}防护中 (已封禁${count:-0} IP)${RESET}"
elif command -v fail2ban-client >/dev/null 2>&1; then
echo -e "${YELLOW}已安装 / 已停止${RESET}"
else
echo -e "${YELLOW}未安装${RESET}"
fi
}
fmt_f2b_unit() {
local val=$1 type=$2
if [[ "$val" =~ ^[0-9]+$ ]]; then
[ "$type" == "time" ] && echo "${val}秒" || { [ "$type" == "factor" ] && echo "${val}倍" || echo "$val"; }
else echo "$val"; fi
}
validate_time() { [[ "$1" =~ ^[0-9]+[smhdw]?$ ]]; }
validate_int() { [[ "$1" =~ ^[0-9]+(\.[0-9]+)?$ ]]; }
# systemd 环境默认走 journal,不写死 logpath(避免 WARN)
generate_default_jail_conf() {
local backend="auto"
local logpath_line="logpath = ${SSH_LOG}"
if [ "$INIT_SYS" = "systemd" ]; then
backend="systemd"
logpath_line=""
fi
local ssh_filter="sshd"
[ "$OS_ID" = "alpine" ] && ssh_filter="alpine-sshd"
local banaction="iptables-multiport"
if ! command -v iptables &>/dev/null && command -v nft &>/dev/null; then
banaction="nftables-multiport"
fi
cat <<EOF2
[DEFAULT]
backend = ${backend}
[${TARGET_JAIL}]
enabled = true
port = ssh
filter = ${ssh_filter}
${logpath_line}
maxretry = 5
bantime = 600
findtime = 3600
bantime.increment = true
bantime.factor = 2
bantime.maxtime = 7d
banaction = ${banaction}
ignoreip = 127.0.0.1/8
EOF2
}
check_f2b_install() {
hash -r 2>/dev/null
if ! command -v fail2ban-client >/dev/null 2>&1; then
echo -e "${WARN} 未检测到 Fail2Ban 服务。"
read -rp "是否立即安装 Fail2Ban?(y/N): " install_confirm
[[ ! "$install_confirm" =~ ^[Yy]$ ]] && { echo -e "${WARN} 已取消安装。"; return 1; }
echo -e "${INFO} 正在安装 Fail2Ban 及相关依赖..."
local f2b_pkgs=()
case "$PKG_MGR" in
apt) f2b_pkgs=("fail2ban" "python3-systemd" "rsyslog");;
dnf|yum) f2b_pkgs=("fail2ban" "rsyslog");;
zypper) f2b_pkgs=("fail2ban" "rsyslog");;
pacman) f2b_pkgs=("fail2ban");;
apk) f2b_pkgs=("fail2ban");;
esac
pkg_install "${f2b_pkgs[@]}"
hash -r 2>/dev/null
if ! command -v fail2ban-client >/dev/null 2>&1; then
echo -e "\n${ERROR} Fail2Ban 安装失败!${RESET}"
check_eol_system
echo -e "${YELLOW}请先解决软件源问题,然后重新运行本脚本。${RESET}"
read -rp "按回车键返回..."
return 1
fi
if [ ! -d "/etc/fail2ban" ]; then
echo -e "\n${ERROR} /etc/fail2ban 目录不存在,安装可能不完整。${RESET}"
echo -e "${YELLOW}请检查 Fail2Ban 是否安装成功:dpkg -l | grep fail2ban${RESET}"
read -rp "按回车键返回..."
return 1
fi
[ ! -f "$SSH_LOG" ] && { $SUDO touch "$SSH_LOG"; svc_enable rsyslog 2>/dev/null; svc_start rsyslog 2>/dev/null; }
[ ! -f "$JAIL_CONF" ] && generate_default_jail_conf | $SUDO tee "$JAIL_CONF" > /dev/null
[ "$INIT_SYS" = "systemd" ] && $SUDO systemctl unmask fail2ban &>/dev/null || true
svc_enable fail2ban; svc_start fail2ban
echo -e "${INFO} ${GREEN}Fail2Ban 安装并启动完成!${RESET}"; sleep 1; return 0
fi
if [ ! -d "/etc/fail2ban" ]; then
echo -e "${ERROR} /etc/fail2ban 目录不存在,请重新安装 Fail2Ban。${RESET}"
read -rp "按回车键返回..."
return 1
fi
if [ ! -f "$JAIL_CONF" ]; then
generate_default_jail_conf | $SUDO tee "$JAIL_CONF" > /dev/null
else
if ! grep -q "^\[DEFAULT\]" "$JAIL_CONF"; then
local be="auto"; [ "$INIT_SYS" = "systemd" ] && be="systemd"
$SUDO sed -i "1i [DEFAULT]\nbackend = ${be}" "$JAIL_CONF"
fi
if ! grep -q "^\[${TARGET_JAIL}\]" "$JAIL_CONF"; then
generate_default_jail_conf | grep -A99 "^\[${TARGET_JAIL}\]" | $SUDO tee -a "$JAIL_CONF" > /dev/null
else
local defaults=(
"enabled=true" "port=ssh" "filter=sshd" "maxretry=5"
"bantime=600" "findtime=3600"
"bantime.increment=true" "bantime.factor=2" "bantime.maxtime=7d"
)
for item in "${defaults[@]}"; do
local k="${item%%=*}" v="${item#*=}"
[ -n "$(get_f2b_conf "$k")" ] || set_f2b_conf "$k" "$v"
done
fi
fi
return 0
}
uninstall_f2b() {
echo -e "\n${RED}${BOLD}警告:即将卸载 Fail2Ban 及其配置!${RESET}"
read -rp "确认卸载吗?(y/N): " confirm
[[ ! "$confirm" =~ ^[Yy]$ ]] && { echo -e "${INFO} 已取消卸载。"; read -rp "按回车键继续..."; return; }
svc_stop fail2ban; svc_disable fail2ban
pkg_remove fail2ban
read -rp "是否同时删除配置目录 /etc/fail2ban ?(y/N): " del_conf
[[ "$del_conf" =~ ^[Yy]$ ]] && { $SUDO rm -rf /etc/fail2ban; echo -e "${INFO} 已删除 /etc/fail2ban"; }
echo -e "${INFO} ${GREEN}Fail2Ban 卸载完成。${RESET}"; read -rp "按回车键继续..."
}
change_f2b_param() {
local name=$1 key=$2 type=$3
local current; current=$(get_f2b_conf "$key")
echo -e "\n${INFO} 正在修改: ${CYAN}${name}${RESET}"
echo -e "当前值: ${GREEN}$(fmt_f2b_unit "$current" "$type")${RESET}"
[ "$type" == "time" ] && echo -e "${GRAY}(支持后缀: s=秒, m=分, h=小时, d=天)${RESET}"
while true; do
read -rp "请输入新值 (留空取消): " new_val
[ -z "$new_val" ] && return
if [ "$type" == "time" ] && validate_time "$new_val"; then break; fi
if [ "$type" == "int" ] && validate_int "$new_val"; then break; fi
if [ "$type" == "factor" ] && validate_int "$new_val"; then break; fi
echo -e "${ERROR} 格式错误,请重试。"
done
set_f2b_conf "$key" "$new_val"; restart_f2b
}
toggle_f2b_service() {
echo -e "\n${CYAN}------------------- 服务开关 -------------------${RESET}"
if fail2ban-client ping >/dev/null 2>&1; then
read -rp "是否停止并禁用 Fail2Ban? (y/N): " confirm
[[ "$confirm" =~ ^[Yy]$ ]] && { svc_stop fail2ban; svc_disable fail2ban; echo -e "${WARN} 服务已停止。${RESET}"; }
else
read -rp "是否启用并启动 Fail2Ban? (y/N): " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
svc_enable fail2ban; svc_start fail2ban
for i in {1..5}; do
if fail2ban-client ping >/dev/null 2>&1; then echo -e "${INFO} ${GREEN}服务已成功启动。${RESET}"; read -rp "按回车键继续..."; return; fi; sleep 1
done
echo -e "${ERROR} 启动失败或超时。"
fi
fi
read -rp "按回车键继续..."
}
unban_f2b_ip() {
echo -e "\n${CYAN}------------------ 手动解封 IP ------------------${RESET}"
local banned_list
banned_list=$(fail2ban-client status "$TARGET_JAIL" 2>/dev/null | grep "Banned IP list" | awk -F':' '{print $2}' | sed 's/^[ \t]*//')
[ -z "$banned_list" ] && banned_list="无"
echo -e "当前被封禁列表: ${YELLOW}${banned_list}${RESET}"
read -rp "输入要解封的 IP (留空取消): " target_ip; [ -z "$target_ip" ] && return
$SUDO fail2ban-client set "$TARGET_JAIL" unbanip "$target_ip"
[ $? -eq 0 ] && echo -e "${INFO} ${GREEN}解封成功: $target_ip${RESET}" || echo -e "${ERROR} 操作失败。"
read -rp "按回车键继续..."
}
add_f2b_whitelist() {
echo -e "\n${CYAN}------------------ 白名单管理 ------------------${RESET}"
local current_list; current_list=$(get_f2b_conf "ignoreip")
echo -e "当前白名单: ${YELLOW}${current_list:-继承全局或无}${RESET}"
local current_ip; current_ip=$(echo "$SSH_CLIENT" | awk '{print $1}')
read -rp "输入要放行的 IP (回车默认当前连接 IP: ${current_ip:-无}): " input_ip
[ -z "$input_ip" ] && input_ip="$current_ip"
[ -z "$input_ip" ] && echo -e "${ERROR} 无法获取 IP。" && return
if echo "$current_list" | grep -Fq "$input_ip"; then
echo -e "${WARN} 该 IP 已在白名单中。"
else
if [ -z "$current_list" ]; then set_f2b_conf "ignoreip" "$input_ip"
else set_f2b_conf "ignoreip" "$current_list $input_ip"; fi
restart_f2b
fi
read -rp "按回车键继续..."
}
view_f2b_logs() {
clear
echo -e "${CYAN}============================================================${RESET}"
echo -e "${BOLD}${PURPLE} Fail2Ban 审计日志 (最近 20 条)${RESET}"
echo -e "${CYAN}============================================================${RESET}"
if [ ! -f "$LOG_FILE" ]; then
echo -e "${WARN} 日志文件不存在: $LOG_FILE"
else
local out
out=$(grep -E "(Ban|Unban)" "$LOG_FILE" 2>/dev/null | tail -n 20)
if [ -z "$out" ]; then
echo -e "${WARN} 暂无封禁/解封记录${RESET}"
else
echo "$out" | awk '{
gsub(/Unban/, "\033[32m&\033[0m");
gsub(/Ban/, "\033[31m&\033[0m");
print
}'
fi
fi
echo -e "${CYAN}============================================================${RESET}"
read -rp "按回车键返回..."
}
menu_f2b_exponential() {
while true; do
clear
local inc fac max
inc=$(get_f2b_conf "bantime.increment")
fac=$(get_f2b_conf "bantime.factor")
max=$(get_f2b_conf "bantime.maxtime")
local S_INC; [ "$inc" == "true" ] && S_INC="${GREEN}启用${RESET}" || S_INC="${YELLOW}禁用${RESET}"
echo -e "${CYAN}============================================================${RESET}"
echo -e "${BOLD}${PURPLE} 高级: 指数封禁设置 (针对 sshd)${RESET}"
echo -e "${CYAN}============================================================${RESET}"
echo -e " 说明: 对重复犯错的恶意 IP,封禁时间按设定系数成倍递增"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
echo -e " ${GREEN}1.${RESET} 递增模式开关 [${S_INC}]"
echo -e " ${GREEN}2.${RESET} 增长系数 [${YELLOW}${fac:-未设置}${RESET}]$(fmt_f2b_unit "$fac" "factor")"
echo -e " ${GREEN}3.${RESET} 封禁上限 [${YELLOW}${max:-未设置}${RESET}]$(fmt_f2b_unit "$max" "time")"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
echo -e " ${GREEN}0.${RESET} 返回上级"
echo -e "${CYAN}============================================================${RESET}"
read -rp "请选择 [0-3]: " sc
case "$sc" in
1) [ "$inc" == "true" ] && ns="false" || ns="true"; set_f2b_conf "bantime.increment" "$ns"; restart_f2b ;;
2) change_f2b_param "增长系数 (倍数)" "bantime.factor" "factor" ;;
3) change_f2b_param "封禁上限 (时间)" "bantime.maxtime" "time" ;;
0) return ;;
*) echo -e "${ERROR} 无效选项!"; sleep 1 ;;
esac
done
}
manage_fail2ban_menu() {
if ! check_f2b_install; then read -rp "按回车键返回主菜单..."; return; fi
while true; do
clear
VAL_MAX=$(get_f2b_conf "maxretry"); VAL_BAN=$(get_f2b_conf "bantime"); VAL_FIND=$(get_f2b_conf "findtime")
echo -e "${CYAN}============================================================${RESET}"
echo -e "${BOLD}${PURPLE} Fail2Ban 防护管理${RESET}"
echo -e "${CYAN}============================================================${RESET}"
echo -e " 服务状态: $(get_fail2ban_status)"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
echo -e " ${GREEN}1.${RESET} 最大重试次数 [${YELLOW}${VAL_MAX:-默认}${RESET}]"
echo -e " ${GREEN}2.${RESET} 初始封禁时长 [${YELLOW}${VAL_BAN:-默认}${RESET}]$(fmt_f2b_unit "$VAL_BAN" "time")"
echo -e " ${GREEN}3.${RESET} 监测时间窗口 [${YELLOW}${VAL_FIND:-默认}${RESET}]$(fmt_f2b_unit "$VAL_FIND" "time")"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
echo -e " ${GREEN}4.${RESET} 手动解封 IP"
echo -e " ${GREEN}5.${RESET} 添加 IP 白名单"
echo -e " ${GREEN}6.${RESET} 查看封禁日志 (最近20条)"
echo -e " ${GREEN}7.${RESET} 指数递增封禁设置 ->"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
echo -e " ${GREEN}8.${RESET} 启用 / 停止 服务"
echo -e " ${GREEN}9.${RESET} 卸载 Fail2Ban"
echo -e " ${GREEN}0.${RESET} 返回主菜单"
echo -e "${CYAN}============================================================${RESET}"
read -rp "请选择 [0-9]: " choice
case "$choice" in
1) change_f2b_param "最大重试次数" "maxretry" "int" ;;
2) change_f2b_param "初始封禁时长" "bantime" "time" ;;
3) change_f2b_param "监测时间窗口" "findtime" "time" ;;
4) unban_f2b_ip ;;
5) add_f2b_whitelist ;;
6) view_f2b_logs ;;
7) menu_f2b_exponential ;;
8) toggle_f2b_service ;;
9) uninstall_f2b ;;
0) return ;;
*) echo -e "${ERROR} 无效选项!"; sleep 1 ;;
esac
done
}
# ============ 状态面板 ============
show_status() {
local port; port=$(get_sshd_config_val "Port" "22")
local pwd_auth; pwd_auth=$(get_sshd_config_val "PasswordAuthentication" "yes")
local pubkey_auth; pubkey_auth=$(get_sshd_config_val "PubkeyAuthentication" "yes")
local f2b_stat; f2b_stat=$(get_fail2ban_status)
local key_count; key_count=$(count_authorized_keys)
echo -e "${CYAN}============================================================${RESET}"
echo -e "${BOLD}${PURPLE} SSH 安全配置工具${RESET}"
echo -e "${CYAN}============================================================${RESET}"
echo -e " 系统架构 : ${GREEN}${OS_SHORT} ${OS_VER}${RESET}"
if [[ "${pubkey_auth,,}" == "yes" ]]; then
if [ "$key_count" -gt 0 ]; then
echo -e " 密钥登录 : ${GREEN}已启用 (${key_count} 把公钥)${RESET}"
else
echo -e " 密钥登录 : ${YELLOW}已启用 (但无公钥,无法密钥登录)${RESET}"
fi
else
echo -e " 密钥登录 : ${YELLOW}已禁用${RESET}"
fi
if [[ "${pwd_auth,,}" == "no" ]]; then
echo -e " 密码登录 : ${GREEN}已禁用 (PasswordAuthentication no)${RESET}"
else
echo -e " 密码登录 : ${YELLOW}已启用 (推荐配置密钥后禁用)${RESET}"
fi
echo -e " Fail2Ban : ${f2b_stat}"
echo -e " SSH 端口 : ${CYAN}${port}${RESET}"
echo -e "${CYAN}============================================================${RESET}"
}
# ============ 密钥生成 ============
generate_vps_keypair() {
echo -e "\n${INFO} 准备在 VPS 上生成新的 ED25519 密钥..."
local key_file="${HOME}/.ssh/PrivateKey.pem"
local pub_file="${HOME}/.ssh/PublicKey.pub"
if [ -f "$key_file" ] || [ -f "$pub_file" ] || [ -f "${key_file}.pub" ]; then
echo -e "${WARN} 检测到已有旧同名密钥,即将覆盖清理..."
rm -f "$key_file" "$pub_file" "${key_file}.pub"
fi
# -C "" 保证生成的密钥无后缀标识信息
ssh-keygen -t ed25519 -C "" -f "$key_file" -N "" -q
if [ ! -f "${key_file}.pub" ]; then
echo -e "${ERROR} 密钥生成失败!请检查 $HOME/.ssh 是否可写或磁盘空间是否充足。"
read -rp "按回车键返回..."
return 1
fi
mv "${key_file}.pub" "$pub_file"
local pub_content
pub_content=$(cat "$pub_file")
append_key_with_meta "$pub_content" "VPS本地生成"
echo -e "\n${GREEN}====================== 密钥生成成功 ======================${RESET}"
echo -e " VPS 上的私钥路径 : ${CYAN}${key_file}${RESET}"
echo -e " VPS 上的公钥路径 : ${CYAN}${pub_file}${RESET}"
echo -e " 授权目标文件 : 已将公钥写入 ${CYAN}${HOME}/.ssh/authorized_keys${RESET}"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
echo -e "${YELLOW}${BOLD}[私钥文本 (PrivateKey.pem)] - 用于登录此 VPS:${RESET}"
echo -e "${YELLOW}"
cat "$key_file"
echo -e "${RESET}"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
echo -e "${GREEN}${BOLD}[公钥文本 (PublicKey.pub)] - 用于上传至 GitHub:${RESET}"
echo -e "${GREEN}${pub_content}${RESET}"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
echo -e "${BOLD}${PURPLE}[💡 新手一劳永逸指南]${RESET}"
echo -e " ${BOLD}一、保存密钥到本地电脑 (二选一):${RESET}"
echo -e " ${GREEN}• 方式 A (推荐·直接下载):${RESET}打开 SSH 客户端自带的 ${CYAN}SFTP / 文件传输${RESET} 功能 (如 FinalShell、Xshell、WinSCP、Termius 等),"
echo -e " 定位到服务器目录:${CYAN}${HOME}/.ssh/${RESET}"
echo -e " 将 ${CYAN}PrivateKey.pem${RESET} (私钥) 和 ${CYAN}PublicKey.pub${RESET} (公钥) 直接下载保存到本地电脑。"
echo -e " ${GREEN}• 方式 B (手动复制):${RESET}"
echo -e " 1. 复制上面的【私钥文本】,在本地新建纯文本文件保存为:${CYAN}PrivateKey.pem${RESET}"
echo -e " 2. 复制上面的【公钥文本】,在本地新建纯文本文件保存为:${CYAN}PublicKey.pub${RESET}"
echo -e "\n ${BOLD}二、绑定 GitHub 打通长期复用工作流(可选):${RESET}"
echo -e " 1. 打开 ${CYAN}https://github.com/settings/keys${RESET} ,点击 \"New SSH key\";"
echo -e " 2. 将 ${CYAN}PublicKey.pub${RESET} 里的【公钥文本】粘贴进去并保存;"
echo -e " 3. ${GREEN}以后在其他 VPS 上运行本脚本,只需选择【选项 1】输入 GitHub 用户名即可快速拉取!${RESET}\n"
read -rp "确认已保存/下载密钥,是否立即删除 VPS 上的暂存密钥文件?(Y/n): " rm_confirm
if [[ -z "$rm_confirm" || "$rm_confirm" =~ ^[Yy]$ ]]; then
rm -f "$key_file" "$pub_file"
echo -e "${INFO} ${GREEN}已成功删除 VPS 上的暂存密钥文件。${RESET}"
else
echo -e "${WARN} 密钥文件仍保留在: ${key_file} 和 ${pub_file} (请务必防范私钥泄露)"
fi
}
# ============ 密钥登录开关 ============
toggle_pubkey_login() {
local current
current=$(get_sshd_config_val "PubkeyAuthentication" "yes")
if [[ "${current,,}" == "no" ]]; then
echo -e "\n当前密钥登录已${GREEN}禁用${RESET}。"
local key_count; key_count=$(count_authorized_keys)
if [ "$key_count" -eq 0 ]; then
echo -e "${YELLOW}[提示] 当前 authorized_keys 中还没有公钥,启用后仍需先添加公钥才能通过密钥登录。${RESET}"
fi
read -rp "是否要启用密钥登录?(y/N): " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
set_sshd_config "PubkeyAuthentication" "yes"
restart_sshd
echo -e "${INFO} ${GREEN}密钥登录已成功启用。${RESET}"
fi
else
echo -e "\n${YELLOW}${BOLD}[警告] 禁用密钥登录后,如果密码登录也已禁用,你将无法登录 VPS!${RESET}"
if [ -n "$SSH_CLIENT" ] || [ -n "$SSH_TTY" ]; then
echo -e "${CYAN}${BOLD}[提示] 检测到您正在使用 SSH 远程会话,修改后切勿关闭当前窗口!${RESET}"
fi
local pwd_auth
pwd_auth=$(get_sshd_config_val "PasswordAuthentication" "yes")
if [[ "${pwd_auth,,}" == "no" ]]; then
echo -e "${RED} 检测到密码登录已禁用,禁用密钥登录后你将无法登录此 VPS!${RESET}"
read -rp "确认仍然要禁用密钥登录吗?(y/N): " confirm_risky
[[ ! "$confirm_risky" =~ ^[Yy]$ ]] && { echo -e "${INFO} 已取消操作。"; read -rp "按回车键继续..."; return; }
else
read -rp "确认禁用密钥登录吗?(y/N): " confirm
[[ ! "$confirm" =~ ^[Yy]$ ]] && { echo -e "${INFO} 已取消操作。"; read -rp "按回车键继续..."; return; }
fi
set_sshd_config "PubkeyAuthentication" "no"
restart_sshd
echo -e "${INFO} ${GREEN}密钥登录已禁用,现在只能通过密码登录。${RESET}"
fi
read -rp "按回车键继续..."
}
# ============ 密钥配置菜单 ============
install_key_menu() {
while true; do
clear
init_ssh_dir
# 动态统计当前公钥数量
local key_count
key_count=$(count_authorized_keys)
local key_count_label
if [ "$key_count" -gt 0 ]; then
key_count_label=" (${GREEN}${key_count} 把公钥${RESET})"
else
key_count_label=" (${YELLOW}无公钥${RESET})"
fi
# 动态显示当前密钥登录开关状态,避免新人困惑
local pubkey_status
pubkey_status=$(get_sshd_config_val "PubkeyAuthentication" "yes")
local pubkey_label
if [[ "${pubkey_status,,}" == "yes" ]]; then
pubkey_label="[${GREEN}已启用${RESET}]"
else
pubkey_label="[${YELLOW}已禁用${RESET}]"
fi
echo -e "${CYAN}============================================================${RESET}"
echo -e "${BOLD}${PURPLE} SSH 密钥登录管理${RESET}"
echo -e "${CYAN}============================================================${RESET}"
echo -e "${BOLD}请选择 SSH 密钥配置方式:${RESET}"
echo -e " ${GREEN}1.${RESET} 从 GitHub 获取公钥 (${CYAN}适合:已将公钥上传至 GitHub 的用户${RESET})"
echo -e " ${GREEN}2.${RESET} 在 VPS 上全新生成密钥 (${CYAN}适合:本地没有密钥的新手,生成后可传 GitHub${RESET})"
echo -e " ${GREEN}3.${RESET} 从自定义 URL 获取公钥 (${CYAN}适合:有公钥直链的用户${RESET})"
echo -e " ${GREEN}4.${RESET} 管理已存公钥${key_count_label}"
echo -e " ${GREEN}5.${RESET} 密钥登录开关 ${pubkey_label}"
echo -e " ${GREEN}0.${RESET} 返回主菜单"
echo -e "${CYAN}============================================================${RESET}"
read -rp "请输入选项 [0-5]: " key_opt
local test_hint=""
local do_restart=0
case "$key_opt" in
1)
echo -e "\n${YELLOW}${BOLD}[使用前提]${RESET}"
echo -e "需先将本地公钥上传至 GitHub: ${CYAN}https://github.com/settings/keys${RESET}\n"
read -rp "请输入您的 GitHub 用户名: " gh_user
if [ -z "$gh_user" ]; then
echo -e "${ERROR} 输入不能为空!"
read -rp "按回车键继续..."
continue
fi
echo -e "${INFO} 正在从 GitHub 拉取公钥..."
local pub_key
pub_key=$(curl -fsSL "https://github.com/${gh_user}.keys")
if [ -z "$pub_key" ] || [[ "$pub_key" == "Not Found" ]]; then
echo -e "\n${ERROR} 获取公钥失败!可能是用户名不正确,或该 GitHub 账号未配置公钥。"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
read -rp "是否要在 VPS 上全新生成密钥 (选项 2)?(y/N): " switch_opt2
if [[ "$switch_opt2" =~ ^[Yy]$ ]]; then
generate_vps_keypair
test_hint="请将已保存的私钥导入本地 SSH 客户端,新建终端测试连接。"
do_restart=1
else
read -rp "按回车键继续..."
continue
fi
else
append_key_with_meta "$pub_key" "GitHub: ${gh_user}"
test_hint="请使用该 GitHub 公钥对应的本地私钥,新建终端测试连接。"
do_restart=1
fi
;;
2)
generate_vps_keypair
test_hint="请将已保存的私钥导入本地 SSH 客户端,新建终端测试连接。"
do_restart=1
;;
3)
read -rp "请输入公钥 URL: " key_url
if [ -z "$key_url" ]; then
echo -e "${ERROR} URL 不能为空!"
read -rp "按回车键继续..."
continue
fi
local pub_key
pub_key=$(curl -fsSL "$key_url")
if [ -z "$pub_key" ]; then
echo -e "${ERROR} 从 URL 获取公钥失败!"
read -rp "按回车键继续..."
continue
fi
append_key_with_meta "$pub_key" "自定义URL"
test_hint="请使用该公钥对应的本地私钥,新建终端测试连接。"
do_restart=1
;;
4)
manage_keys_menu
continue
;;
5)
toggle_pubkey_login
continue
;;
0) return ;;
*)
echo -e "${ERROR} 无效选项!"
sleep 1
continue
;;
esac
if [ "$do_restart" == "1" ]; then
set_sshd_config "PubkeyAuthentication" "yes"
restart_sshd
echo -e "\n${CYAN}------------------------------------------------------------${RESET}"
echo -e "${YELLOW}${BOLD}[重点测试]${RESET} ${test_hint}"
echo -e "测试成功后,再返回主菜单【禁用密码登录】!"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
read -rp "按回车键返回密钥管理子菜单..."
fi
done
}
# ============ 已存公钥管理 ============
manage_keys_menu() {
init_ssh_dir
local auth_file="${HOME}/.ssh/authorized_keys"
while true; do
clear
echo -e "${CYAN}============================================================${RESET}"
echo -e "${BOLD}${PURPLE} SSH 已存公钥管理${RESET}"
echo -e "${CYAN}============================================================${RESET}"
local key_lines=()
local key_contents=()
local line_num=0
if [ -f "$auth_file" ]; then
while IFS= read -r line || [ -n "$line" ]; do
((line_num++))
if [[ -z "$line" || "$line" =~ ^[[:space:]]*# ]]; then
continue
fi
key_lines+=("$line_num")
key_contents+=("$line")
done < "$auth_file"
fi
if [ ${#key_contents[@]} -eq 0 ]; then
echo -e "\n${WARN} 当前 ${auth_file} 中没有找到任何有效公钥!"
echo -e "${CYAN}============================================================${RESET}"
read -rp "按回车键返回..."
return
fi
printf " %-4s | %-19s | %-8s | %-16s\n" "序号" " 添加时间" "公钥类型" " 备注来源"
echo -e "${CYAN}------------------------------------------------------------${RESET}"
local idx=1
for key in "${key_contents[@]}"; do
local add_time="历史存量/未标记"
local key_tag="未知/手动导入"
if [[ "$key" =~ \[([0-9]{4}-[0-9]{2}-[0-9]{2}\ [0-9]{2}:[0-9]{2}:[0-9]{2})\|([^\]]+)\] ]]; then
add_time="${BASH_REMATCH[1]}"
key_tag="${BASH_REMATCH[2]}"
fi
# 修正:带选项前缀的公钥也能正确显示密钥类型
local key_type
key_type=$(echo "$key" | awk '{
for(i=1;i<=NF;i++){
if($i ~ /^(ssh-|ecdsa-|sk-)/){ print $i; exit }
}
print "未知"
}')
printf " ${GREEN}[%2d]${RESET} | ${YELLOW}%19s${RESET} | ${CYAN}%-8s${RESET} | ${PURPLE}%-16s${RESET}\n" "$idx" "$add_time" "$key_type" "$key_tag"
((idx++))
done
echo -e "${CYAN}============================================================${RESET}"
echo -e " 输入 ${RED}[序号]${RESET} : 删除指定公钥"
echo -e " 输入 ${RED}[all]${RESET} : 清空全部公钥"
echo -e " 输入 ${GREEN}[0]${RESET} : 返回上级菜单"
echo -e "${CYAN}============================================================${RESET}"
read -rp "请输入操作指令: " key_action
if [ "$key_action" == "0" ]; then
return
elif [ "$key_action" == "all" ]; then
# 危险检查:密码登录已禁用时清空全部公钥会锁死
if [[ "$(get_sshd_config_val "PasswordAuthentication" "yes")" == "no" ]]; then
echo -e "${RED}${BOLD}[危险] 密码登录已禁用,清空全部公钥后你将无法登录此 VPS!${RESET}"
read -rp "确认仍然要清空吗?(y/N): " risky_all
[[ ! "$risky_all" =~ ^[Yy]$ ]] && continue
fi
read -rp "确认要清空所有公钥吗?(y/N): " confirm_all
if [[ "$confirm_all" =~ ^[Yy]$ ]]; then
> "$auth_file"
echo -e "${INFO} 已清空所有公钥!"
sleep 1
continue
fi
elif [[ "$key_action" =~ ^[0-9]+$ ]] && [ "$key_action" -ge 1 ] && [ "$key_action" -le "${#key_contents[@]}" ]; then
local target_idx=$((key_action - 1))
local target_line_num="${key_lines[$target_idx]}"
# 危险检查:密码登录已禁用 + 只剩最后一个公钥时删除会锁死
if [[ "$(get_sshd_config_val "PasswordAuthentication" "yes")" == "no" ]] && [ "${#key_contents[@]}" -eq 1 ]; then
echo -e "${RED}${BOLD}[危险] 密码登录已禁用,删除最后一个公钥后你将无法登录此 VPS!${RESET}"
read -rp "确认仍然要删除吗?(y/N): " risky_del
[[ ! "$risky_del" =~ ^[Yy]$ ]] && continue
fi
read -rp "确认删除序号 [${key_action}] 的公钥吗?(y/N): " confirm_del
if [[ "$confirm_del" =~ ^[Yy]$ ]]; then
sed -i "${target_line_num}d" "$auth_file"
chmod 600 "$auth_file"
echo -e "${INFO} ${GREEN}序号 [${key_action}] 的公钥已成功删除!${RESET}"
sleep 1
continue
fi
else
echo -e "${ERROR} 输入无效,请重新输入!"
sleep 1
continue
fi
done
}
# ============ 密码登录开关 ============
toggle_password_login() {
local current
current=$(get_sshd_config_val "PasswordAuthentication" "yes")
if [[ "${current,,}" == "no" ]]; then
echo -e "\n当前密码登录已${GREEN}禁用${RESET}。"
read -rp "是否要启用密码登录?(y/N): " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
set_sshd_config "PasswordAuthentication" "yes"
restart_sshd
echo -e "${INFO} 已成功启用密码登录。"
read -rp "是否需要为当前用户 ($(whoami)) 设置新密码?(y/N): " pwd_confirm
if [[ "$pwd_confirm" =~ ^[Yy]$ ]]; then
passwd "$(whoami)"
fi
fi
else
echo -e "\n${YELLOW}${BOLD}[警告] 禁用密码登录前,请务必确认密钥登录能够成功!${RESET}"
# 危险检查:密钥登录不可用时禁用密码登录会锁死
local pubkey_auth; pubkey_auth=$(get_sshd_config_val "PubkeyAuthentication" "yes")
local pubkey_ok=0
if [[ "${pubkey_auth,,}" == "yes" ]] && grep -qE '^(ssh-|ecdsa-)' "${HOME}/.ssh/authorized_keys" 2>/dev/null; then
pubkey_ok=1
fi
if [ "$pubkey_ok" -eq 0 ]; then
if [[ "${pubkey_auth,,}" != "yes" ]]; then
echo -e "${RED}${BOLD}[危险] 密钥登录已禁用,禁用密码登录后你将无法登录此 VPS!${RESET}"
else
echo -e "${RED}${BOLD}[危险] authorized_keys 中没有任何公钥,禁用密码登录后你将无法登录此 VPS!${RESET}"
fi
read -rp "确认仍然要禁用吗?(y/N): " risky_confirm
[[ ! "$risky_confirm" =~ ^[Yy]$ ]] && { echo -e "${INFO} 已取消操作。"; read -rp "按回车键继续..."; return; }
fi
if [ -n "$SSH_CLIENT" ] || [ -n "$SSH_TTY" ]; then
echo -e "${CYAN}${BOLD}[提示] 检测到您正在使用 SSH 远程会话,修改后切勿关闭当前窗口,请先新建终端测试连接!${RESET}"
fi
read -rp "确认彻底禁用密码登录吗?(y/N): " confirm
if [[ "$confirm" =~ ^[Yy]$ ]]; then
set_sshd_config "PasswordAuthentication" "no"
set_sshd_config "ChallengeResponseAuthentication" "no"
set_sshd_config "KbdInteractiveAuthentication" "no"
restart_sshd
echo -e "${INFO} ${GREEN}密码登录已成功禁用!现在只能通过密钥登录。${RESET}"
fi
fi
read -rp "按回车键返回主菜单..."
}
# ============ 修改 SSH 端口 ============
change_ssh_port() {
local current_port
current_port=$(get_sshd_config_val "Port" "22")
echo -e "\n当前 SSH 端口为: ${CYAN}${current_port}${RESET}"
if [ -n "$SSH_CLIENT" ] || [ -n "$SSH_TTY" ]; then
echo -e "${YELLOW}${BOLD}[提示] 检测到您正在使用 SSH 远程会话,修改端口后请勿关闭当前窗口,请先新建终端验证!${RESET}"
fi
read -rp "请输入新的 SSH 端口 (1024-65535): " new_port
if [[ ! "$new_port" =~ ^[0-9]+$ ]] || [ "$new_port" -lt 1 ] || [ "$new_port" -gt 65535 ]; then
echo -e "${ERROR} 端口格式不正确!"
read -rp "按回车键返回主菜单..."
return
fi
if command -v getenforce &>/dev/null && [ "$(getenforce)" != "Disabled" ]; then
echo -e "${INFO} 检测到 SELinux 启用,正在申请放行端口 ${new_port}..."
if command -v semanage &>/dev/null; then
$SUDO semanage port -a -t ssh_port_t -p tcp "$new_port" 2>/dev/null || \
$SUDO semanage port -m -t ssh_port_t -p tcp "$new_port" 2>/dev/null
else
echo -e "${WARN} 未安装 semanage,如使用 RHEL/CentOS 系系统,可能需要手动配置 SELinux。"
fi
fi
if command -v ufw &>/dev/null && ufw status | grep -q "active"; then
echo -e "${INFO} 正在向 ufw 防火墙放行端口 ${new_port}/tcp..."
$SUDO ufw allow "$new_port"/tcp >/dev/null
elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld; then
echo -e "${INFO} 正在向 firewalld 防火墙放行端口 ${new_port}/tcp..."
$SUDO firewall-cmd --permanent --add-port="${new_port}/tcp" >/dev/null
$SUDO firewall-cmd --reload >/dev/null
fi
set_sshd_config "Port" "$new_port"
if restart_sshd; then
echo -e "${INFO} ${GREEN}SSH 端口已顺利修改为 ${new_port}${RESET}"
# 同步更新 Fail2Ban 端口
if [ -f "$JAIL_CONF" ] && grep -q "^\[${TARGET_JAIL}\]" "$JAIL_CONF"; then
set_f2b_conf "port" "$new_port"
if command -v fail2ban-client &>/dev/null && fail2ban-client ping >/dev/null 2>&1; then
echo -e "${INFO} 正在同步更新 Fail2Ban 防护端口..."
restart_f2b
fi
fi
if command -v ss &>/dev/null; then
echo -e "${INFO} 系统实际监听服务端口状态:"
ss -tulpn | grep ssh || true
fi
echo -e "${WARN} 注意:如使用的是阿里云/腾讯云/AWS等,请务必在【云服务器安全组】中开放 TCP ${new_port} 端口!"
fi
read -rp "按回车键返回主菜单..."
}
# ============ 主逻辑 ============
detect_os; detect_pkg_mgr; detect_init; detect_ssh_log
check_dependencies
# ============ 命令行参数解析 ============
# 先收集所有参数,循环结束后按依赖顺序统一执行
OVERWRITE=0
CLI_GH_USER=""
CLI_KEY_URL=""
CLI_KEY_FILE=""
CLI_PORT=""
CLI_DISABLE_PWD=0
while getopts "og:u:f:p:d" OPT; do
case $OPT in
o) OVERWRITE=1 ;;
g) CLI_GH_USER="$OPTARG" ;;
u) CLI_KEY_URL="$OPTARG" ;;
f) CLI_KEY_FILE="$OPTARG" ;;
p) CLI_PORT="$OPTARG" ;;
d) CLI_DISABLE_PWD=1 ;;
*) exit 1 ;;
esac
done
# -o 必须配合公钥来源使用,否则会误清空 authorized_keys
if [ "$OVERWRITE" -eq 1 ] && [ -z "$CLI_GH_USER" ] && [ -z "$CLI_KEY_URL" ] && [ -z "$CLI_KEY_FILE" ]; then
echo -e "${ERROR} -o(覆盖模式)必须与 -g、-u 或 -f 一起使用!"
echo -e "${YELLOW}单独执行 -o 会清空 authorized_keys 中所有公钥,已阻止。${RESET}"
exit 1
fi
# 有任意 CLI 参数就进入批量执行模式
if [ -n "$CLI_GH_USER" ] || [ -n "$CLI_KEY_URL" ] || [ -n "$CLI_KEY_FILE" ] || \
[ -n "$CLI_PORT" ] || [ "$CLI_DISABLE_PWD" -eq 1 ]; then
need_restart_sshd=0
need_restart_f2b=0
if [ -n "$CLI_GH_USER" ] || [ -n "$CLI_KEY_URL" ] || [ -n "$CLI_KEY_FILE" ]; then
init_ssh_dir
[ "$OVERWRITE" == "1" ] && > "${HOME}/.ssh/authorized_keys"
fi
# 1. 导入公钥
if [ -n "$CLI_GH_USER" ]; then
PUB_KEY=$(curl -fsSL "https://github.com/${CLI_GH_USER}.keys")
[ -z "$PUB_KEY" ] && echo -e "${ERROR} 获取 GitHub 公钥失败" && exit 1
append_key_with_meta "$PUB_KEY" "GitHub: ${CLI_GH_USER}"
set_sshd_config "PubkeyAuthentication" "yes"
need_restart_sshd=1
fi
if [ -n "$CLI_KEY_URL" ]; then
PUB_KEY=$(curl -fsSL "${CLI_KEY_URL}")
[ -z "$PUB_KEY" ] && echo -e "${ERROR} 从 URL 获取公钥失败" && exit 1
append_key_with_meta "$PUB_KEY" "自定义URL"
set_sshd_config "PubkeyAuthentication" "yes"
need_restart_sshd=1
fi
if [ -n "$CLI_KEY_FILE" ]; then
if [ -f "${CLI_KEY_FILE}" ]; then
PUB_KEY=$(cat "${CLI_KEY_FILE}")
append_key_with_meta "$PUB_KEY" "本地文件导入"
set_sshd_config "PubkeyAuthentication" "yes"
need_restart_sshd=1
else
echo -e "${ERROR} 找不到公钥文件 ${CLI_KEY_FILE}" && exit 1
fi
fi
# 2. 修改端口
if [ -n "$CLI_PORT" ]; then
if [[ ! "$CLI_PORT" =~ ^[0-9]+$ ]] || [ "$CLI_PORT" -lt 1 ] || [ "$CLI_PORT" -gt 65535 ]; then
echo -e "${ERROR} 端口格式不正确!" && exit 1
fi
if command -v getenforce &>/dev/null && [ "$(getenforce)" != "Disabled" ]; then
echo -e "${INFO} 检测到 SELinux 启用,正在申请放行端口 ${CLI_PORT}..."
if command -v semanage &>/dev/null; then
$SUDO semanage port -a -t ssh_port_t -p tcp "$CLI_PORT" 2>/dev/null || \
$SUDO semanage port -m -t ssh_port_t -p tcp "$CLI_PORT" 2>/dev/null
else
echo -e "${WARN} 未安装 semanage,如使用 RHEL/CentOS 系系统,可能需要手动配置 SELinux。"
fi
fi
if command -v ufw &>/dev/null && ufw status | grep -q "active"; then
echo -e "${INFO} 正在向 ufw 防火墙放行端口 ${CLI_PORT}/tcp..."
$SUDO ufw allow "$CLI_PORT"/tcp >/dev/null
elif command -v firewall-cmd &>/dev/null && systemctl is-active --quiet firewalld; then
echo -e "${INFO} 正在向 firewalld 防火墙放行端口 ${CLI_PORT}/tcp..."
$SUDO firewall-cmd --permanent --add-port="${CLI_PORT}/tcp" >/dev/null
$SUDO firewall-cmd --reload >/dev/null
fi
set_sshd_config "Port" "$CLI_PORT"
need_restart_sshd=1
if [ -f "$JAIL_CONF" ] && grep -q "^\[${TARGET_JAIL}\]" "$JAIL_CONF"; then
set_f2b_conf "port" "$CLI_PORT"
need_restart_f2b=1
fi
fi
# 3. 禁用密码登录(危险检查)
if [ "$CLI_DISABLE_PWD" -eq 1 ]; then
if ! grep -qE '^(ssh-|ecdsa-)' "${HOME}/.ssh/authorized_keys" 2>/dev/null || \
[[ "$(get_sshd_config_val "PubkeyAuthentication" "yes")" != "yes" ]]; then
echo -e "${ERROR} 密钥登录不可用(未启用或无公钥),禁用密码登录将导致你无法登录!"
read -rp "确认仍然执行?(y/N): " confirm
[[ ! "$confirm" =~ ^[Yy]$ ]] && exit 1
fi
set_sshd_config "PasswordAuthentication" "no"
set_sshd_config "ChallengeResponseAuthentication" "no"
set_sshd_config "KbdInteractiveAuthentication" "no"
need_restart_sshd=1
fi
# 统一重启 SSH 服务
if [ "$need_restart_sshd" -eq 1 ]; then
if restart_sshd; then
if [ -n "$CLI_PORT" ]; then
if command -v ss &>/dev/null; then
echo -e "${INFO} 系统实际监听服务端口状态:"
ss -tulpn | grep ssh || true
fi
echo -e "${WARN} 注意:如使用的是阿里云/腾讯云/AWS等,请务必在【云服务器安全组】中开放 TCP ${CLI_PORT} 端口!"
fi
fi
fi
# 同步重启 Fail2Ban
if [ "$need_restart_f2b" -eq 1 ]; then
if command -v fail2ban-client &>/dev/null && fail2ban-client ping >/dev/null 2>&1; then
echo -e "${INFO} 正在同步更新 Fail2Ban 防护端口..."
restart_f2b
fi
fi
exit 0
fi
# ============ 交互式主菜单 ============
while true; do
clear
show_status
echo -e " ${GREEN}1.${RESET} 密钥登录管理"
echo -e " ${GREEN}2.${RESET} 密码登录开关"
echo -e " ${GREEN}3.${RESET} Fail2Ban防护"
echo -e " ${GREEN}4.${RESET} SSH 端口修改"
echo -e " ${GREEN}0.${RESET} 退出脚本"
echo -e "${CYAN}============================================================${RESET}"
read -rp "请输入选项 [0-4]: " choice
case "$choice" in
1) install_key_menu ;;
2) toggle_password_login ;;
3) manage_fail2ban_menu ;;
4) change_ssh_port ;;
0) echo -e "\n感谢使用!"; exit 0 ;;
*) echo -e "${ERROR} 无效选项,请重新选择!"; sleep 1 ;;
esac
done