diff --git a/script/ip-switch.sh b/script/ip-switch.sh new file mode 100755 index 0000000..d999ae2 --- /dev/null +++ b/script/ip-switch.sh @@ -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 diff --git a/script/server-security-check.sh b/script/server-security-check.sh new file mode 100755 index 0000000..5e68202 --- /dev/null +++ b/script/server-security-check.sh @@ -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 diff --git a/script/ssh-key-only.sh b/script/ssh-key-only.sh new file mode 100755 index 0000000..46f9496 --- /dev/null +++ b/script/ssh-key-only.sh @@ -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 "请保持当前会话,另开终端确认可通过密钥登录后再退出。"