feat: add ssh-key-only

This commit is contained in:
2026-07-25 20:58:38 +08:00
parent dc58f0c253
commit db8a531b92
3 changed files with 848 additions and 0 deletions
+323
View File
@@ -0,0 +1,323 @@
#!/bin/bash
# 交互式设置网卡静态 IPv4 地址或切回 DHCP
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# shellcheck disable=SC1091
source "$SCRIPT_DIR/common/help.sh"
usage() {
cat <<'EOF'
用法: ip-switch
交互选择网络服务/网卡,并设置临时静态 IPv4 地址或切换回 DHCP。
支持:
macOS 使用 networksetup 管理网络服务
Linux 优先使用 NetworkManager (nmcli);否则使用 ip + DHCP 客户端
选项:
-h, --help 显示此帮助
说明:
修改网络配置通常需要 sudo。Linux 下通过 nmcli 或 ip 做出的修改仅作用于
当前连接,不会写入连接配置文件;重新连接后会恢复原配置。
ip 命令本身不能获取 DHCP 租约;切回 DHCP 时需要 dhclient、udhcpc 或 dhcpcd。
设置静态地址时,子网掩码默认 255.255.255.0,默认网关按 IP 推导为 x.x.x.1。
远程 SSH 连接中运行时,修改当前网卡可能会导致连接中断。
EOF
}
dotfile_help_requested "${1:-}" && dotfile_show_help
set -euo pipefail
OS="$(uname -s)"
die() {
echo "错误: $*" >&2
exit 1
}
is_ipv4() {
local ip="$1" octet
[[ "$ip" =~ ^[0-9]{1,3}(\.[0-9]{1,3}){3}$ ]] || return 1
IFS='.' read -r -a octets <<<"$ip"
for octet in "${octets[@]}"; do
((10#$octet <= 255)) || return 1
done
}
netmask_to_prefix() {
case "$1" in
255.255.255.255) echo 32 ;;
255.255.255.254) echo 31 ;;
255.255.255.252) echo 30 ;;
255.255.255.248) echo 29 ;;
255.255.255.240) echo 28 ;;
255.255.255.224) echo 27 ;;
255.255.255.192) echo 26 ;;
255.255.255.128) echo 25 ;;
255.255.255.0) echo 24 ;;
255.255.254.0) echo 23 ;;
255.255.252.0) echo 22 ;;
255.255.248.0) echo 21 ;;
255.255.240.0) echo 20 ;;
255.255.224.0) echo 19 ;;
255.255.192.0) echo 18 ;;
255.255.128.0) echo 17 ;;
255.255.0.0) echo 16 ;;
255.254.0.0) echo 15 ;;
255.252.0.0) echo 14 ;;
255.248.0.0) echo 13 ;;
255.240.0.0) echo 12 ;;
255.224.0.0) echo 11 ;;
255.192.0.0) echo 10 ;;
255.128.0.0) echo 9 ;;
255.0.0.0) echo 8 ;;
254.0.0.0) echo 7 ;;
252.0.0.0) echo 6 ;;
248.0.0.0) echo 5 ;;
240.0.0.0) echo 4 ;;
224.0.0.0) echo 3 ;;
192.0.0.0) echo 2 ;;
128.0.0.0) echo 1 ;;
0.0.0.0) echo 0 ;;
*) return 1 ;;
esac
}
confirm() {
local answer
read -r -p "确认应用此网络配置?[y/N] " answer
[[ "$answer" =~ ^[Yy]$ ]]
}
select_item() {
local prompt="$1" item index=1 choice
shift
local -a items=("$@")
echo
for item in "${items[@]}"; do
printf ' %d) %s\n' "$index" "$item"
((index += 1))
done
echo " 0) 取消"
while true; do
read -r -p "$prompt" choice
[[ "$choice" == "0" || "$choice" == "q" || "$choice" == "Q" ]] && return 1
if [[ "$choice" =~ ^[0-9]+$ ]] && ((choice >= 1 && choice <= ${#items[@]})); then
SELECTED_INDEX=$((choice - 1))
return 0
fi
echo "请输入列表中的编号。"
done
}
prompt_static_config() {
local default_gateway
while true; do
read -r -p "IPv4 地址: " STATIC_IP
is_ipv4 "$STATIC_IP" && break
echo "请输入有效的 IPv4 地址。"
done
while true; do
read -r -p "子网掩码 [255.255.255.0]: " STATIC_MASK
STATIC_MASK="${STATIC_MASK:-255.255.255.0}"
if STATIC_PREFIX=$(netmask_to_prefix "$STATIC_MASK"); then
break
fi
echo "请输入连续的 IPv4 子网掩码,例如 255.255.255.0。"
done
default_gateway="${STATIC_IP%.*}.1"
while true; do
read -r -p "默认网关 [$default_gateway]: " STATIC_GATEWAY
STATIC_GATEWAY="${STATIC_GATEWAY:-$default_gateway}"
is_ipv4 "$STATIC_GATEWAY" && break
echo "请输入有效的 IPv4 默认网关。"
done
}
run_macos() {
local service device current action
local -a services=() devices=() labels=()
command -v networksetup >/dev/null || die "找不到 networksetup"
while IFS=$'\t' read -r service device; do
[ -n "$service" ] || continue
services+=("$service")
devices+=("$device")
labels+=("$service ($device)")
done < <(networksetup -listnetworkserviceorder | awk '
/^\([0-9]+\) / { service = $0; sub(/^\([0-9]+\) \*?/, "", service); next }
/Hardware Port:.*Device:/ {
device = $0; sub(/^.*Device: /, "", device); sub(/\).*/, "", device)
if (service != "") print service "\t" device
}
')
((${#services[@]} > 0)) || die "未发现可配置的网络服务"
select_item "选择网络服务: " "${labels[@]}" || exit 0
service="${services[$SELECTED_INDEX]}"
device="${devices[$SELECTED_INDEX]}"
current="$(networksetup -getinfo "$service" 2>&1)"
echo
echo "当前配置 ($service / $device):"
echo "$current"
select_item "选择操作: " "设置静态 IPv4" "切换为 DHCP" || exit 0
action="$SELECTED_INDEX"
if ((action == 0)); then
prompt_static_config
echo
printf '将 %s (%s) 设为静态地址: %s / %s,网关: %s\n' \
"$service" "$device" "$STATIC_IP" "$STATIC_MASK" "$STATIC_GATEWAY"
confirm || { echo "已取消。"; exit 0; }
sudo networksetup -setmanual "$service" "$STATIC_IP" "$STATIC_MASK" "$STATIC_GATEWAY"
else
echo
echo "将 $service ($device) 切换为 DHCP。"
confirm || { echo "已取消。"; exit 0; }
sudo networksetup -setdhcp "$service"
fi
echo
echo "已应用。当前配置:"
networksetup -getinfo "$service"
}
run_linux() {
local device type state action
local -a devices=() labels=()
while IFS=: read -r device type state; do
[ -n "$device" ] || continue
[ "$type" = "loopback" ] && continue
devices+=("$device")
labels+=("$device ($type, $state)")
done < <(nmcli -t -f DEVICE,TYPE,STATE device status)
((${#devices[@]} > 0)) || die "未发现可配置的网络接口"
select_item "选择网络接口: " "${labels[@]}" || exit 0
device="${devices[$SELECTED_INDEX]}"
echo
echo "当前配置 ($device):"
nmcli device show "$device" | awk '/GENERAL.CONNECTION|IP4\.ADDRESS|IP4\.GATEWAY|IP4\.DNS/ { print }'
select_item "选择操作: " "设置静态 IPv4" "切换为 DHCP" || exit 0
action="$SELECTED_INDEX"
if ((action == 0)); then
prompt_static_config
echo
printf '将 %s 设为静态地址: %s/%s,网关: %s\n' \
"$device" "$STATIC_IP" "$STATIC_PREFIX" "$STATIC_GATEWAY"
confirm || { echo "已取消。"; exit 0; }
sudo nmcli device modify "$device" ipv4.method manual \
ipv4.addresses "$STATIC_IP/$STATIC_PREFIX" ipv4.gateway "$STATIC_GATEWAY"
else
echo
echo "将 $device 切换为 DHCP。"
confirm || { echo "已取消。"; exit 0; }
sudo nmcli device modify "$device" ipv4.method auto ipv4.addresses "" ipv4.gateway ""
fi
echo
echo "已应用。当前配置:"
nmcli device show "$device" | awk '/GENERAL.CONNECTION|IP4\.ADDRESS|IP4\.GATEWAY|IP4\.DNS/ { print }'
}
linux_dhcp_client() {
if command -v dhclient >/dev/null; then
echo dhclient
elif command -v udhcpc >/dev/null; then
echo udhcpc
elif command -v dhcpcd >/dev/null; then
echo dhcpcd
fi
}
run_linux_ip() {
local device action dhcp_client
local -a devices=() labels=()
command -v ip >/dev/null || die "找不到 ip 命令"
while IFS= read -r device; do
[ -n "$device" ] || continue
devices+=("$device")
labels+=("$device ($(ip -br link show dev "$device" | awk '{ print $2 }'))")
done < <(ip -o link show | awk -F': ' '$2 != "lo" { sub(/@.*/, "", $2); print $2 }')
((${#devices[@]} > 0)) || die "未发现可配置的网络接口"
select_item "选择网络接口: " "${labels[@]}" || exit 0
device="${devices[$SELECTED_INDEX]}"
echo
echo "当前配置 ($device):"
ip -br -4 addr show dev "$device"
ip route show default dev "$device" || true
select_item "选择操作: " "设置静态 IPv4" "切换为 DHCP" || exit 0
action="$SELECTED_INDEX"
if ((action == 0)); then
prompt_static_config
echo
printf '将 %s 设为静态地址: %s/%s,网关: %s\n' \
"$device" "$STATIC_IP" "$STATIC_PREFIX" "$STATIC_GATEWAY"
confirm || { echo "已取消。"; exit 0; }
sudo ip link set dev "$device" up
sudo ip -4 addr flush dev "$device"
sudo ip -4 addr add "$STATIC_IP/$STATIC_PREFIX" dev "$device"
sudo ip route replace default via "$STATIC_GATEWAY" dev "$device"
else
dhcp_client="$(linux_dhcp_client)"
[ -n "$dhcp_client" ] || die "切回 DHCP 需要 dhclient、udhcpc 或 dhcpcd;请安装与现有网络管理方式兼容的 DHCP 客户端"
echo
echo "将 $device 切换为 DHCP(使用 $dhcp_client)。"
confirm || { echo "已取消。"; exit 0; }
sudo ip link set dev "$device" up
case "$dhcp_client" in
dhclient)
sudo dhclient -4 -r "$device" 2>/dev/null || true
sudo ip -4 addr flush dev "$device"
sudo ip route flush default dev "$device" 2>/dev/null || true
sudo dhclient -4 -v "$device"
;;
udhcpc)
sudo ip -4 addr flush dev "$device"
sudo ip route flush default dev "$device" 2>/dev/null || true
sudo udhcpc -i "$device" -q -n
;;
dhcpcd)
sudo ip -4 addr flush dev "$device"
sudo ip route flush default dev "$device" 2>/dev/null || true
sudo dhcpcd -4 "$device"
;;
esac
fi
echo
echo "已应用。当前配置:"
ip -br -4 addr show dev "$device"
ip route show default dev "$device" || true
}
case "$OS" in
Darwin) run_macos ;;
Linux)
if command -v nmcli >/dev/null && systemctl is-active --quiet NetworkManager 2>/dev/null; then
run_linux
else
run_linux_ip
fi
;;
*) die "不支持的操作系统: $OS" ;;
esac
+309
View File
@@ -0,0 +1,309 @@
#!/bin/bash
# 快速检查服务器 SSH、端口、防火墙、特权账号与近期登录风险
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"
usage() {
cat <<'EOF'
用法: server-security-check
快速、只读地检查以下服务器安全项:
- SSH 是否允许 root 或密码登录
- 当前监听端口,以及常见明文服务端口
- 本机防火墙是否启用
- UID 0 异常账号和 sudoers 中的免密 sudo 规则
- 最近 24 小时 SSH 失败登录摘要和最近成功登录来源
选项:
-h, --help 显示此帮助
说明:
不扫描网络、不查询漏洞库、不修改配置,通常可在数秒内完成。
建议使用 sudo 运行,以读取 sudoers 和系统认证日志:
sudo server-security-check
EOF
}
dotfile_help_requested "${1:-}" && dotfile_show_help
if [[ $# -ne 0 ]]; then
echo "错误: 不支持参数: $*" >&2
usage >&2
exit 1
fi
PASS_COUNT=0
WARN_COUNT=0
HIGH_COUNT=0
INFO_COUNT=0
pass() {
PASS_COUNT=$((PASS_COUNT + 1))
printf '[通过] %s\n' "$*"
}
warn() {
WARN_COUNT=$((WARN_COUNT + 1))
printf '[警告] %s\n' "$*"
}
high() {
HIGH_COUNT=$((HIGH_COUNT + 1))
printf '[高风险] %s\n' "$*"
}
info() {
INFO_COUNT=$((INFO_COUNT + 1))
printf '[信息] %s\n' "$*"
}
section() {
printf '\n== %s ==\n' "$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
if [[ -x "$candidate" ]]; then
printf '%s\n' "$candidate"
return
fi
done
}
check_sshd_setting() {
local setting="$1" value
value=$(printf '%s\n' "$SSHD_EFFECTIVE_CONFIG" | awk -v key="$setting" '$1 == key { print $2; exit }')
printf '%s' "$value"
}
check_ssh() {
local sshd_bin root_login password_login
section "SSH 登录"
sshd_bin=$(find_sshd)
if [[ -z "$sshd_bin" ]]; then
info "未安装 sshd,跳过 SSH 配置检查"
return
fi
SSHD_EFFECTIVE_CONFIG=$("$sshd_bin" -T 2>/dev/null)
if [[ -z "$SSHD_EFFECTIVE_CONFIG" ]]; then
warn "无法读取 sshd 生效配置;请使用 sudo 重试或检查 sshd 配置"
return
fi
root_login=$(check_sshd_setting permitrootlogin)
password_login=$(check_sshd_setting passwordauthentication)
case "$root_login" in
no|forced-commands-only)
pass "SSH 已禁止 root 交互式登录(PermitRootLogin: $root_login)"
;;
prohibit-password)
pass "SSH root 登录仅允许密钥认证(PermitRootLogin: prohibit-password)"
;;
yes)
high "SSH 允许 root 直接登录;建议设为 PermitRootLogin no"
;;
*)
warn "无法确定 PermitRootLogin 的安全状态(当前: ${root_login:-未设置})"
;;
esac
case "$password_login" in
no)
pass "SSH 已禁用密码登录"
;;
yes)
warn "SSH 允许密码登录;公网服务器建议改用密钥认证"
;;
*)
warn "无法确定 PasswordAuthentication 的安全状态(当前: ${password_login:-未设置})"
;;
esac
}
check_listening_ports() {
local listeners risky
section "监听端口"
if [[ "$OS_NAME" == "linux" ]] && command -v ss >/dev/null 2>&1; then
listeners=$(ss -H -lntu 2>/dev/null)
elif [[ "$OS_NAME" == "darwin" ]] && command -v lsof >/dev/null 2>&1; then
listeners=$(lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | sed 1d)
elif command -v netstat >/dev/null 2>&1; then
listeners=$(netstat -lnt 2>/dev/null || netstat -an 2>/dev/null)
else
warn "找不到 ss、lsof 或 netstat,无法列出监听端口"
return
fi
if [[ -z "$listeners" ]]; then
pass "未发现 TCP/UDP 监听端口"
return
fi
printf '%s\n' "$listeners" | sed -n '1,30p'
local listener_count
listener_count=$(printf '%s\n' "$listeners" | wc -l | tr -d ' ')
if ((listener_count > 30)); then
info "仅显示前 30 个监听项,共 ${listener_count} 项"
fi
risky=$(printf '%s\n' "$listeners" | grep -E '(:21|:23|:69|:111|:512|:513|:514)([[:space:]]|$)' || true)
if [[ -n "$risky" ]]; then
warn "发现 FTP/Telnet/rsh 等常见高风险或明文服务端口:$(printf '%s' "$risky" | tr '\n' ';')"
else
pass "未发现常见明文服务端口(21、23、69、111、512-514)"
fi
}
check_firewall() {
local ufw_output zones
section "防火墙"
case "$OS_NAME" in
linux)
if command -v ufw >/dev/null 2>&1; then
ufw_output=$(ufw status verbose 2>&1)
if printf '%s\n' "$ufw_output" | grep -qi '^Status: active'; then
if printf '%s\n' "$ufw_output" | grep -qi 'Default: deny (incoming)'; then
pass "UFW 已启用,默认拒绝入站连接"
else
warn "UFW 已启用,但未确认默认拒绝入站连接"
fi
else
warn "UFW 未启用;若未使用云安全组或其他防火墙,请启用入站访问控制"
fi
return
fi
if command -v firewall-cmd >/dev/null 2>&1 && firewall-cmd --state 2>/dev/null | grep -qx running; then
zones=$(firewall-cmd --get-active-zones 2>/dev/null | tr '\n' ';')
pass "firewalld 正在运行(活动区域: ${zones:-未知})"
info "请确认活动区域只放行实际需要的服务"
return
fi
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet nftables 2>/dev/null; then
pass "nftables 服务正在运行"
info "请确认 nftables 的入站默认策略为 drop 或 reject"
return
fi
warn "未检测到正在运行的 UFW、firewalld 或 nftables;若依赖云安全组,请确认其已限制入站端口"
;;
darwin)
if [[ -x /usr/libexec/ApplicationFirewall/socketfilterfw ]]; then
if /usr/libexec/ApplicationFirewall/socketfilterfw --getglobalstate 2>/dev/null | grep -qi enabled; then
pass "macOS 应用防火墙已启用"
else
warn "macOS 应用防火墙未启用"
fi
else
warn "无法读取 macOS 防火墙状态"
fi
;;
*)
warn "不支持的系统,无法检测防火墙"
;;
esac
}
check_privileged_accounts() {
local uid_zero sudo_rules
section "高权限账号"
uid_zero=$(awk -F: '$3 == 0 && $1 != "root" {print $1}' /etc/passwd 2>/dev/null)
if [[ -n "$uid_zero" ]]; then
high "发现非 root 的 UID 0 账号:$(printf '%s' "$uid_zero" | tr '\n' ' ')"
else
pass "未发现非 root 的 UID 0 账号"
fi
if [[ $EUID -ne 0 ]]; then
info "未以 root 运行,跳过完整 sudoers 免密规则检查;可用 sudo 重跑"
return
fi
sudo_rules=$(grep -REh '^[[:space:]]*[^#].*NOPASSWD' /etc/sudoers /etc/sudoers.d 2>/dev/null || true)
if [[ -n "$sudo_rules" ]]; then
warn "发现免密 sudo 规则:$(printf '%s' "$sudo_rules" | tr '\n' ';')"
else
pass "未发现 sudoers 中的 NOPASSWD 免密 sudo 规则"
fi
}
check_recent_logins() {
local auth_log log_text failed_count successful
section "近期 SSH 登录"
auth_log=""
for candidate in /var/log/auth.log /var/log/secure; do
if [[ -r "$candidate" ]]; then
auth_log="$candidate"
break
fi
done
if [[ -n "$auth_log" ]]; then
log_text=$(tail -n 5000 "$auth_log" 2>/dev/null)
elif command -v journalctl >/dev/null 2>&1; then
log_text=$(journalctl --no-pager --since '24 hours ago' -u ssh -u sshd 2>/dev/null)
else
log_text=""
fi
if [[ -n "$log_text" ]]; then
failed_count=$(printf '%s\n' "$log_text" | grep -Eic 'Failed password|Invalid user|authentication failure|Failed publickey' || true)
if ((failed_count == 0)); then
pass "最近 24 小时未发现 SSH 失败登录记录"
elif ((failed_count < 20)); then
warn "最近 24 小时发现 ${failed_count} 条 SSH 失败登录记录"
else
high "最近 24 小时发现 ${failed_count} 条 SSH 失败登录记录,可能存在暴力破解"
fi
else
info "无法读取最近 24 小时 SSH 日志;可用 sudo 重跑,或确认日志服务正常"
fi
if command -v last >/dev/null 2>&1; then
successful=$(last -i -n 5 2>/dev/null | sed -n '/^wtmp begins/q;p' | sed '/^$/d')
if [[ -n "$successful" ]]; then
printf '最近成功登录(最多 5 条):\n%s\n' "$successful"
else
info "未读取到成功登录记录"
fi
fi
}
OS_NAME=$(get_os)
echo "服务器快速安全检查(系统: ${OS_NAME}/$(get_arch))"
echo "只读检查;建议使用 sudo 获取完整结果。"
check_ssh
check_listening_ports
check_firewall
check_privileged_accounts
check_recent_logins
printf '\n== 汇总 ==\n'
printf '通过: %d 警告: %d 高风险: %d 信息: %d\n' "$PASS_COUNT" "$WARN_COUNT" "$HIGH_COUNT" "$INFO_COUNT"
if ((HIGH_COUNT > 0)); then
echo "请优先处理“高风险”项。"
elif ((WARN_COUNT > 0)); then
echo "建议逐项确认“警告”项是否符合服务器的实际部署方式。"
else
echo "未发现需要立即处理的项。"
fi
+216
View File
@@ -0,0 +1,216 @@
#!/bin/bash
# 验证已有公钥后关闭 SSH 密码登录,仅保留公钥认证
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-key-only [--user <用户名>] [--yes]
在确认目标用户已有 SSH 公钥后,写入 /etc/ssh/sshd_config.d/99-dotfile-key-only.conf:
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
选项:
-u, --user <用户名> 要确认公钥的用户;默认使用调用 sudo 前的用户
-y, --yes 跳过交互确认
-h, --help 显示此帮助
示例:
sudo ssh-key-only
sudo ssh-key-only --user deploy
重要说明:
- 仅支持使用 systemd 管理 SSH 服务的 Linux 服务器。
- 脚本不会创建 SSH 密钥;目标用户的 ~/.ssh/authorized_keys 中必须已有有效公钥。
- 写入前会校验 sshd 配置,且重载后会再次核对生效设置;失败会恢复原文件。
- 请保持当前 SSH 会话不要退出,另开一个终端验证密钥登录成功后再关闭。
EOF
}
TARGET_USER="${SUDO_USER:-}"
ASSUME_YES=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
;;
*)
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_key_only() {
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
}
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 服务"
[[ -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
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"
echo "将为 SSH 启用公钥认证,并关闭密码与交互式认证。"
echo "已确认 $TARGET_USER 的公钥文件: $AUTHORIZED_KEYS"
echo "配置文件: $DROP_IN"
if [[ $ASSUME_YES -ne 1 ]]; then
read -r -p "确认继续?[y/N] " answer
[[ "$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-key-only.sh
PubkeyAuthentication yes
PasswordAuthentication no
KbdInteractiveAuthentication no
ChallengeResponseAuthentication no
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 ! "$SSHD_BIN" -t -f "$SSHD_CONFIG" 2>"$VALIDATION_ERROR"; then
restore_drop_in
echo "sshd 配置校验输出:" >&2
cat "$VALIDATION_ERROR" >&2
die "新配置未通过校验,已恢复原文件"
fi
if ! config_is_key_only; then
restore_drop_in
die "新配置未成为 sshd 的生效设置(可能被更早的配置覆盖),已恢复原文件"
fi
if ! reload_sshd; then
restore_drop_in
die "无法重载 ssh/sshd 服务,已恢复原文件;请检查 systemctl 状态"
fi
if ! config_is_key_only; then
restore_drop_in
reload_sshd || true
die "重载后设置未生效,已恢复原文件并尝试重载旧配置"
fi
echo "已启用仅公钥 SSH 登录。"
[[ -n "$BACKUP" ]] && echo "原配置已备份到: $BACKUP"
echo "请保持当前会话,另开终端确认可通过密钥登录后再退出。"