#!/usr/bin/env bash # 受控自动修复:默认只生成计划,不修改远端。 # 只使用现有 SSH key;不接受密码参数,不关闭 host key 校验。 set -Eeuo pipefail usage() { cat <<'EOF' 用法: remediate.sh user@host --plan [--fix ssh-perms] [--fix fail2ban] remediate.sh user@host --apply --fix ssh-perms --confirm "APPLY user@host" remediate.sh user@host --apply --fix fail2ban --ignore-ip CIDR --confirm "APPLY user@host" remediate.sh user@host --rollback BACKUP_ID --confirm "ROLLBACK BACKUP_ID" 默认是 --plan,只读检查。 可用修复项: ssh-perms 修正当前 SSH 用户 ~/.ssh 和 authorized_keys 权限 fail2ban 写入独立 sshd jail,要求提供至少一个 --ignore-ip 防火墙、SSH 登录策略、容器绑定、恶意文件和密钥/token 不由本脚本自动修改。 EOF } die() { echo "错误: $*" >&2; exit 2; } [[ $# -ge 1 ]] || { usage; exit 2; } case "$1" in -h|--help) usage; exit 0 ;; esac HOST=$1 shift [[ "$HOST" != -* && "$HOST" != *[[:space:]]* ]] || die "HOST 必须是 user@host 或 SSH config 别名" MODE=plan ROLLBACK_ID= CONFIRM= SSH_PORT= FIXES=() IGNORE_IPS=() while [[ $# -gt 0 ]]; do case "$1" in --plan) [[ "$MODE" == plan ]] || die "--plan 不能和 --apply/--rollback 同时使用" MODE=plan; shift ;; --apply) [[ "$MODE" == plan ]] || die "--apply 不能和 --rollback 同时使用" MODE=apply; shift ;; --rollback) [[ $# -ge 2 ]] || die "--rollback 缺少 BACKUP_ID" [[ "$MODE" == plan ]] || die "--rollback 不能和 --apply 同时使用" MODE=rollback; ROLLBACK_ID=$2; shift 2 ;; --fix) [[ $# -ge 2 ]] || die "--fix 缺少修复项" case "$2" in ssh-perms|fail2ban) FIXES+=("$2") ;; *) die "不支持的修复项: $2" ;; esac shift 2 ;; --ignore-ip) [[ $# -ge 2 ]] || die "--ignore-ip 缺少 CIDR/IP" [[ "$2" =~ ^[0-9A-Fa-f:.]+(/[0-9]{1,3})?$ ]] || die "非法 IP/CIDR: $2" IGNORE_IPS+=("$2"); shift 2 ;; --ssh-port) [[ $# -ge 2 ]] || die "--ssh-port 缺少端口" [[ "$2" =~ ^[0-9]+$ && "$2" -ge 1 && "$2" -le 65535 ]] || die "非法 SSH 端口: $2" SSH_PORT=$2; shift 2 ;; --confirm) [[ $# -ge 2 ]] || die "--confirm 缺少确认串" CONFIRM=$2; shift 2 ;; -h|--help) usage; exit 0 ;; *) die "未知参数: $1" ;; esac done if [[ "$MODE" == apply ]]; then ((${#FIXES[@]} > 0)) || die "--apply 必须至少指定一个 --fix" [[ "$CONFIRM" == "APPLY $HOST" ]] || die "需要确认串: APPLY $HOST" if printf '%s\n' "${FIXES[@]}" | grep -qx fail2ban && ((${#IGNORE_IPS[@]} == 0)); then die "应用 fail2ban 前必须至少提供一个 --ignore-ip,避免误封管理入口" fi elif [[ "$MODE" == rollback ]]; then [[ "$ROLLBACK_ID" =~ ^[0-9]{8}T[0-9]{6}Z$ ]] || die "非法 BACKUP_ID" [[ "$CONFIRM" == "ROLLBACK $ROLLBACK_ID" ]] || die "需要确认串: ROLLBACK $ROLLBACK_ID" fi FIX_CSV=$(IFS=,; echo "${FIXES[*]-}") IGNORE_CSV=$(IFS=,; echo "${IGNORE_IPS[*]-}") SSH=(ssh -o BatchMode=yes -o ConnectTimeout=15 "$HOST") "${SSH[@]}" bash -s -- "$MODE" "$FIX_CSV" "$IGNORE_CSV" "$SSH_PORT" "$ROLLBACK_ID" <<'REMOTE' set -Eeuo pipefail MODE=${1:?} FIX_CSV=${2-} IGNORE_CSV=${3-} REQUESTED_PORT=${4-} ROLLBACK_ID=${5-} has_fix() { case ",$FIX_CSV," in *",$1,"*) return 0 ;; *) return 1 ;; esac } as_root() { if [[ "$(id -u)" == 0 ]]; then "$@" else sudo -n "$@" fi } need_root() { as_root true } show_state() { echo "目标: $(id -un)@$(hostname -f 2>/dev/null || hostname)" echo "系统: $(. /etc/os-release 2>/dev/null; echo "${PRETTY_NAME:-unknown}")" echo "SSH 生效配置:" if command -v sshd >/dev/null 2>&1; then sshd -T 2>/dev/null | grep -iE '^(port|permitrootlogin|passwordauthentication|kbdinteractiveauthentication|pubkeyauthentication|maxauthtries) ' || true else echo " sshd: unavailable" fi echo "防火墙状态(只读):" ufw status verbose 2>/dev/null || true firewall-cmd --state 2>/dev/null || true nft list ruleset 2>/dev/null | head -80 || true echo "Fail2ban:" if command -v fail2ban-client >/dev/null 2>&1; then fail2ban-client status 2>/dev/null || true else echo " fail2ban-client: unavailable" fi echo "容器暴露(只读):" docker ps -a --format '{{.Names}} ports={{.Ports}}' 2>/dev/null || true } find_ssh_port() { if [[ -n "$REQUESTED_PORT" ]]; then printf '%s\n' "$REQUESTED_PORT" else sshd -T 2>/dev/null | awk '$1 == "port" { print $2; exit }' || true fi } print_plan() { show_state echo echo "修复计划(默认不执行):" if has_fix ssh-perms; then current_user=$(id -un) home=$(getent passwd "$current_user" | awk -F: '{print $6}') echo "- ssh-perms: 检查并修正 $home/.ssh=0700、$home/.ssh/authorized_keys=0600" fi if has_fix fail2ban; then port=$(find_ssh_port) echo "- fail2ban: 写入 /etc/fail2ban/jail.d/host-sec-check.local,SSH 端口=${port:-未识别}" if [[ -n "$IGNORE_CSV" ]]; then echo " ignoreip: $IGNORE_CSV" else echo " 阻断: --apply 前必须提供 --ignore-ip" fi fi echo "- firewall/SSH 策略/Docker 绑定/恶意文件: 只报告,不由脚本自动修改" } backup_root= manifest= modes= record_manifest() { local state=$1 path=$2 as_root sh -c 'printf "%s %s\\n" "$1" "$2" >> "$3"' _ "$state" "$path" "$manifest" } record_mode() { local mode path=$1 mode=$(stat -c '%a' "$path") as_root sh -c 'printf "%s %s\\n" "$1" "$2" >> "$3"' _ "$mode" "$path" "$modes" } backup_file() { local path=$1 dest dest="$backup_root$path" if [[ -e "$path" || -L "$path" ]]; then as_root install -d -m 0700 "$(dirname "$dest")" as_root cp -a -- "$path" "$dest" record_manifest present "$path" else record_manifest absent "$path" fi } start_backup() { local id id=$(date -u +%Y%m%dT%H%M%SZ) backup_root="/var/backups/host-sec-check/$id" manifest="$backup_root/MANIFEST" modes="$backup_root/MODES" as_root install -d -m 0700 /var/backups/host-sec-check as_root mkdir -m 0700 "$backup_root" as_root install -m 0600 /dev/null "$manifest" as_root install -m 0600 /dev/null "$modes" echo "备份: $backup_root" } apply_ssh_perms() { local user home ssh_dir auth user=$(id -un) home=$(getent passwd "$user" | awk -F: '{print $6}') [[ -n "$home" && "$home" != / ]] || { echo "[跳过] 无法识别当前用户 home"; return 1; } ssh_dir="$home/.ssh" auth="$ssh_dir/authorized_keys" if [[ -d "$ssh_dir" ]]; then record_mode "$ssh_dir" as_root chmod 700 "$ssh_dir" echo "[已修复] $ssh_dir mode=0700" else echo "[未修改] $ssh_dir 不存在" fi if [[ -f "$auth" ]]; then record_mode "$auth" as_root chmod 600 "$auth" echo "[已修复] $auth mode=0600" else echo "[未修改] $auth 不存在" fi } apply_fail2ban() { local port ignore_line path=/etc/fail2ban/jail.d/host-sec-check.local command -v fail2ban-client >/dev/null 2>&1 || { echo "[阻断] 未安装 fail2ban-client;不自动安装系统包" >&2; return 1; } port=$(find_ssh_port) [[ "$port" =~ ^[0-9]+$ ]] || { echo "[阻断] 无法识别 SSH 端口" >&2; return 1; } as_root systemctl is-active --quiet fail2ban || { echo "[阻断] fail2ban 未运行;不自动启动服务" >&2; return 1; } ignore_line="127.0.0.1/8 ::1" IFS=, read -r -a extra_ips <<< "$IGNORE_CSV" for ip in "${extra_ips[@]}"; do [[ -n "$ip" ]] && ignore_line+=" $ip" done backup_file "$path" as_root install -d -m 0755 /etc/fail2ban/jail.d as_root tee "$path" >/dev/null <&2; return 1; } while read -r state path; do [[ -n "${path:-}" ]] || continue if [[ ! "$path" =~ ^/root/\.ssh(/authorized_keys)?$ && ! "$path" =~ ^/home/[A-Za-z0-9._-]+/\.ssh(/authorized_keys)?$ && "$path" != /etc/fail2ban/jail.d/host-sec-check.local ]]; then echo "[阻断] 备份路径不在允许回滚范围: $path" >&2 return 1 fi case "$state" in present) source="$backup_root$path" [[ -e "$source" || -L "$source" ]] || { echo "[阻断] 备份文件缺失: $source" >&2; return 1; } as_root install -d -m 0755 "$(dirname "$path")" as_root cp -a -- "$source" "$path" echo "[已回滚] $path" ;; absent) as_root rm -f -- "$path" echo "[已回滚] 删除本次新建文件 $path" ;; *) echo "[阻断] MANIFEST 状态无效: $state" >&2; return 1 ;; esac done < <(as_root cat "$manifest") if as_root test -f "$modes"; then while read -r mode path; do [[ -n "${path:-}" && -e "$path" ]] || continue if [[ ! "$path" =~ ^/root/\.ssh(/authorized_keys)?$ && ! "$path" =~ ^/home/[A-Za-z0-9._-]+/\.ssh(/authorized_keys)?$ ]]; then echo "[阻断] 权限备份路径不在允许回滚范围: $path" >&2 return 1 fi as_root chmod "$mode" "$path" echo "[已回滚] $path mode=$mode" done < <(as_root cat "$modes") fi } case "$MODE" in plan) print_plan ;; apply) need_root start_backup trap 'rc=$?; echo "[部分失败] 已执行的变更可能不完整: $backup_root" >&2; echo "回滚命令: remediate.sh HOST --rollback $(basename "$backup_root") --confirm \"ROLLBACK $(basename "$backup_root")\"" >&2; exit "$rc"' ERR if has_fix ssh-perms; then apply_ssh_perms; fi if has_fix fail2ban; then apply_fail2ban; fi trap - ERR echo "应用完成。备份目录: $backup_root" ;; rollback) need_root rollback ;; *) echo "[阻断] 未知模式: $MODE" >&2 exit 2 ;; esac REMOTE