Files
.dotfile/script/ssh-harden.sh
T
2026-07-25 21:21:09 +08:00

313 lines
10 KiB
Bash
Executable File
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.
#!/bin/bash
# 验证已有公钥后关闭 SSH 密码和 root 登录,并改用 55522 端口
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/common/help.sh"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/common/os-arch.sh"
set -euo pipefail
usage() {
cat <<'EOF'
用法: sudo ssh-harden [--user <用户名>] [--skip-key-check] [--yes]
默认在确认目标用户已有 SSH 公钥后,写入 /etc/ssh/sshd_config.d/99-dotfile-key-only.conf:
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PermitRootLogin no
Port 55522
选项:
-u, --user <用户名> 要确认公钥的用户;默认使用调用 sudo 前的用户
-k, --skip-key-check 跳过本地 authorized_keys 与权限检查
-y, --yes 跳过交互确认
-h, --help 显示此帮助
示例:
sudo ssh-harden
sudo ssh-harden --user deploy
sudo ssh-harden -k
重要说明:
- 仅支持使用 systemd 管理 SSH 服务的 Linux 服务器。
- 脚本不会创建 SSH 密钥;目标用户的 ~/.ssh/authorized_keys 中必须已有有效公钥。
- 使用 -k/--skip-key-check 时,必须已有其他已验证的登录方式(例如 Tailscale SSH
或云控制台);该选项仅跳过本地 authorized_keys 检查。
- 写入前会校验 sshd 配置,且重载后会再次核对生效设置;失败会恢复原文件。
- 若 UFW 已启用,脚本会自动放行 TCP 55522;云安全组和其他防火墙仍需自行放行。
- 请保持当前 SSH 会话不要退出,另开一个终端验证密钥登录成功后再关闭。
EOF
}
TARGET_USER="${SUDO_USER:-}"
ASSUME_YES=0
SSH_PORT=55522
UFW_RULE_ADDED=0
SKIP_KEY_CHECK=0
while [[ $# -gt 0 ]]; do
case "$1" in
-h|--help)
dotfile_show_help
;;
-u|--user)
[[ $# -ge 2 ]] || { echo "错误: $1 需要用户名" >&2; exit 1; }
TARGET_USER="$2"
shift 2
;;
-y|--yes)
ASSUME_YES=1
shift
;;
-k|--skip-key-check)
SKIP_KEY_CHECK=1
shift
;;
*)
echo "错误: 不支持参数: $1" >&2
usage >&2
exit 1
;;
esac
done
die() {
echo "错误: $*" >&2
exit 1
}
find_sshd() {
local candidate
if command -v sshd >/dev/null 2>&1; then
command -v sshd
return
fi
for candidate in /usr/sbin/sshd /usr/local/sbin/sshd; do
[[ -x "$candidate" ]] && { printf '%s\n' "$candidate"; return; }
done
}
user_home() {
local user="$1" home
if command -v getent >/dev/null 2>&1; then
home=$(getent passwd "$user" | awk -F: 'NR == 1 {print $6}')
fi
printf '%s' "${home:-}"
}
setting_value() {
local setting="$1"
printf '%s\n' "$EFFECTIVE_CONFIG" | awk -v key="$setting" '$1 == key {print $2; exit}'
}
check_ssh_path_permissions() {
local path="$1" owner mode
owner=$(stat -c '%U' "$path")
mode=$(stat -c '%a' "$path")
[[ "$owner" == "$TARGET_USER" || "$owner" == "root" ]] || die "$path 的属主为 $owner,应为 $TARGET_USER 或 root"
(( (8#$mode & 022) == 0 )) || die "$path 对组或其他用户可写(权限: $mode),sshd 可能会忽略其中的公钥"
}
config_is_hardened() {
local ports
EFFECTIVE_CONFIG=$("$SSHD_BIN" -T -f "$SSHD_CONFIG" 2>/dev/null || true)
[[ "$(setting_value pubkeyauthentication)" == "yes" ]] || return 1
[[ "$(setting_value passwordauthentication)" == "no" ]] || return 1
[[ "$(setting_value kbdinteractiveauthentication)" == "no" ]] || return 1
[[ "$(setting_value permitrootlogin)" == "no" ]] || return 1
ports=$(printf '%s\n' "$EFFECTIVE_CONFIG" | awk '$1 == "port" {print $2}')
[[ "$ports" == "$SSH_PORT" ]] || return 1
}
port_is_in_use() {
if command -v ss >/dev/null 2>&1; then
ss -H -ltn "sport = :$SSH_PORT" 2>/dev/null | grep -q .
return
fi
if command -v netstat >/dev/null 2>&1; then
netstat -ltn 2>/dev/null | grep -Eq "[.:]${SSH_PORT}[[:space:]]"
return
fi
return 1
}
sshd_already_uses_port() {
local current_config
current_config=$("$SSHD_BIN" -T -f "$SSHD_CONFIG" 2>/dev/null || true)
printf '%s\n' "$current_config" | awk -v port="$SSH_PORT" '$1 == "port" && $2 == port {found = 1} END {exit !found}'
}
ensure_ufw_port() {
local status
command -v ufw >/dev/null 2>&1 || return
status=$(ufw status 2>&1)
if ! printf '%s\n' "$status" | grep -qi '^Status: active'; then
echo "UFW 未启用,跳过防火墙规则修改。"
return
fi
if printf '%s\n' "$status" | grep -Eq "^[[:space:]]*${SSH_PORT}(/tcp)?[[:space:]]"; then
echo "UFW 已有 TCP $SSH_PORT 规则,跳过。"
return
fi
if ! ufw allow "$SSH_PORT/tcp"; then
echo "无法通过 UFW 放行 TCP $SSH_PORT。" >&2
return 1
fi
UFW_RULE_ADDED=1
echo "已通过 UFW 放行 TCP $SSH_PORT。"
}
rollback_ufw_port() {
if [[ $UFW_RULE_ADDED -eq 1 ]]; then
if ufw --force delete allow "$SSH_PORT/tcp" >/dev/null 2>&1; then
echo "已删除本次新增的 UFW TCP $SSH_PORT 规则。" >&2
else
echo "警告: 未能自动删除本次新增的 UFW TCP $SSH_PORT 规则,请手动检查。" >&2
fi
fi
}
reload_sshd() {
local unit
for unit in ssh sshd; do
if systemctl reload "$unit" >/dev/null 2>&1; then
return 0
fi
done
return 1
}
if [[ $EUID -ne 0 ]]; then
die "请使用 sudo 运行,例如: sudo $0${TARGET_USER:+ --user "$TARGET_USER"}"
fi
[[ "$(get_os)" == "linux" ]] || die "当前仅支持 systemd 管理 SSH 服务的 Linux 服务器"
command -v systemctl >/dev/null 2>&1 || die "未找到 systemctl,无法安全重载 SSH 服务"
if [[ $SKIP_KEY_CHECK -eq 0 ]]; then
[[ -n "$TARGET_USER" ]] || die "无法识别目标用户;请指定 --user <用户名>"
id "$TARGET_USER" >/dev/null 2>&1 || die "用户不存在: $TARGET_USER"
TARGET_HOME=$(user_home "$TARGET_USER")
[[ -n "$TARGET_HOME" && -d "$TARGET_HOME" ]] || die "无法确定用户主目录: $TARGET_USER"
AUTHORIZED_KEYS="$TARGET_HOME/.ssh/authorized_keys"
[[ -r "$AUTHORIZED_KEYS" ]] || die "未找到可读取的公钥文件: $AUTHORIZED_KEYS"
[[ -d "$TARGET_HOME/.ssh" ]] || die "未找到 SSH 配置目录: $TARGET_HOME/.ssh"
check_ssh_path_permissions "$TARGET_HOME"
check_ssh_path_permissions "$TARGET_HOME/.ssh"
check_ssh_path_permissions "$AUTHORIZED_KEYS"
if ! grep -Eq '^[[:space:]]*(ssh-(rsa|ed25519|dss)|ecdsa-sha2-nistp(256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com|[[:alnum:]_-]+-cert-v01@openssh\.com)[[:space:]]+' "$AUTHORIZED_KEYS"; then
die "$AUTHORIZED_KEYS 中未发现有效的 SSH 公钥,已取消以避免锁定访问"
fi
fi
SSHD_BIN=$(find_sshd)
[[ -n "$SSHD_BIN" ]] || die "未找到 sshd"
SSHD_CONFIG=/etc/ssh/sshd_config
[[ -r "$SSHD_CONFIG" ]] || die "无法读取 $SSHD_CONFIG"
if ! grep -Eq '^[[:space:]]*Include[[:space:]].*sshd_config\.d' "$SSHD_CONFIG"; then
die "$SSHD_CONFIG 未包含 sshd_config.d;为避免覆盖现有规则,未作修改"
fi
DROP_IN_DIR=/etc/ssh/sshd_config.d
DROP_IN="$DROP_IN_DIR/99-dotfile-key-only.conf"
mkdir -p "$DROP_IN_DIR"
if ! sshd_already_uses_port && port_is_in_use; then
die "TCP $SSH_PORT 已被其他服务监听;请选择空闲端口后再修改脚本"
fi
echo "将为 SSH 启用公钥认证,关闭密码、交互式认证和 root 登录,并改用端口 $SSH_PORT。"
if [[ $SKIP_KEY_CHECK -eq 1 ]]; then
echo "已选择跳过本地 authorized_keys 检查。"
echo "请确认仍可通过其他已验证方式登录。"
else
echo "已确认 $TARGET_USER 的公钥文件: $AUTHORIZED_KEYS"
fi
echo "配置文件: $DROP_IN"
echo "若 UFW 已启用,脚本会自动放行 TCP $SSH_PORT;请确认云安全组已放行。"
if [[ $ASSUME_YES -ne 1 ]]; then
if [[ $SKIP_KEY_CHECK -eq 1 ]]; then
read -r -p "确认仍有其他登录方式且云安全组已放行 TCP $SSH_PORT?[y/N] " answer
else
read -r -p "确认云安全组已放行 TCP $SSH_PORT 并继续?[y/N] " answer
fi
[[ "$answer" =~ ^[Yy]$ ]] || { echo "已取消,未修改配置。"; exit 0; }
fi
STAMP=$(date +%Y%m%d%H%M%S)
BACKUP=""
if [[ -f "$DROP_IN" ]]; then
BACKUP="${DROP_IN}.bak.${STAMP}"
cp -p "$DROP_IN" "$BACKUP"
fi
TMP_FILE=$(mktemp "${DROP_IN}.tmp.XXXXXX")
VALIDATION_ERROR=$(mktemp)
trap 'rm -f "$TMP_FILE" "$VALIDATION_ERROR"' EXIT
cat >"$TMP_FILE" <<'EOF'
# Managed by ~/.dotfile/script/ssh-harden.sh
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
PermitRootLogin no
Port 55522
EOF
install -m 0600 -o root -g root "$TMP_FILE" "$DROP_IN"
restore_drop_in() {
if [[ -n "$BACKUP" ]]; then
cp -p "$BACKUP" "$DROP_IN"
else
rm -f "$DROP_IN"
fi
}
if ! ensure_ufw_port; then
restore_drop_in
die "UFW 规则添加失败,已恢复原 SSH 配置"
fi
if ! "$SSHD_BIN" -t -f "$SSHD_CONFIG" 2>"$VALIDATION_ERROR"; then
restore_drop_in
rollback_ufw_port
echo "sshd 配置校验输出:" >&2
cat "$VALIDATION_ERROR" >&2
die "新配置未通过校验,已恢复原文件"
fi
if ! config_is_hardened; then
restore_drop_in
rollback_ufw_port
die "新配置未成为 sshd 的生效设置(可能被更早的配置覆盖),已恢复原文件"
fi
if ! reload_sshd; then
restore_drop_in
rollback_ufw_port
die "无法重载 ssh/sshd 服务,已恢复原文件;请检查 systemctl 状态"
fi
if ! config_is_hardened; then
restore_drop_in
reload_sshd || true
rollback_ufw_port
die "重载后设置未生效,已恢复原文件并尝试重载旧配置"
fi
echo "已启用仅公钥 SSH 登录,已禁止 root 登录,SSH 端口已改为 $SSH_PORT。"
[[ -n "$BACKUP" ]] && echo "原配置已备份到: $BACKUP"
echo "请保持当前会话,另开终端确认可通过密钥连接到端口 $SSH_PORT 后再退出。"