Linux 下让 .ssh/config 同时支持密钥和密码连接 SSH

在日常的服务器管理和开发工作中,我们经常需要通过 SSH 连接到多台远程主机。管理这些连接,特别是当某些主机需要密码登录而另一些使用密钥登录时,可能会变得繁琐。

Google Gemini 帮忙写了一个 nssh 是一个 Bash 脚本工具,旨在通过与 ~/.ssh/config 文件集成,简化这一过程,并提供主机列表选择、自定义密码文件支持等功能。

功能特性

  • ~/.ssh/config 深度集成: 利用您现有的 SSH 配置文件进行主机参数(如 HostName, User, Port, IdentityFile)的解析。
  • 自定义密码文件支持: 通过在 ~/.ssh/config 中为特定主机添加 pwfilepath 指令,nssh 可以从指定的文件中读取密码并使用 sshpass 自动登录。
  • 智能认证回退: 如果主机未配置 pwfilepathnssh 会自动回退到标准的 SSH 行为(通常是密钥认证或提示手动输入密码)。
  • 主机列表与选择: 使用 nssh -lnssh --list 命令,可以列出 ~/.ssh/config 中定义的所有主机,并通过数字选择快速连接。
  • 帮助信息: 通过 nssh -hnssh --help 提供详细的使用说明。
  • 参数透传: 支持将额外的 SSH 选项或远程命令直接传递给底层的 ssh 命令。
  • 兼容性: 旨在避免因自定义指令(如 pwfilepath)导致原生 ssh 命令解析配置文件出错。

依赖与环境

依赖项

  1. Bash: 版本需 >= 4.0(因为脚本中使用了 mapfile 命令)。大多数现代 Linux 发行版都满足此要求。
    • 检查 Bash 版本: bash --version
  2. sshpass: 用于非交互式地向 SSH 提供密码。如果系统中未安装,脚本会提示。
    • 安装 (Ubuntu/Debian): sudo apt update && sudo apt install sshpass
    • 安装 (Fedora/CentOS/RHEL): sudo yum install sshpasssudo dnf install sshpass
    • 安装 (Arch Linux): sudo pacman -S sshpass
  3. OpenSSH 客户端: 即标准的 ssh 命令。
  4. 标准 GNU Coreutils: 脚本使用了 awk, sed, grep, stat, sort 等常见工具,这些在大多数 Linux 发行版中都是标配。

支持环境

  • 主流现代 Linux 发行版:
    • Ubuntu (如 20.04, 22.04, 24.04 LTS)
    • Debian 及其衍生版
    • Fedora, CentOS Stream, RHEL 及其兼容版
    • Arch Linux 及其衍生版
  • 潜在问题:
    • macOS / BSD: stat -c "%a" (GNU stat 语法) 需要替换为对应系统的 stat 语法 (例如 macOS 上是 stat -f "%Lp" )。macOS 默认 Bash 版本可能较低,需要通过 Homebrew 安装新版 Bash。
    • 非常古老的 Linux发行版: Bash 版本可能低于 4.0,导致 mapfile 失败。
    • 极度精简的 Linux 环境: 可能缺少某些依赖工具。

安装

  1. 创建脚本文件:
    将下面的脚本内容保存到一个名为 nssh 的文件中。推荐的存放位置是 ~/bin (如果已将其加入 PATH 环境变量) 或 /usr/local/bin (需要管理员权限)。
    例如,保存到 /usr/local/bin/nssh

  2. 赋予执行权限:

    sudo chmod +x /usr/local/bin/nssh
    

配置

nssh 的核心配置依赖于两个文件:标准的 SSH 客户端配置文件 (~/.ssh/config) 和您为密码登录主机指定的密码文件。

1. SSH 客户端配置文件 (~/.ssh/config)

确保该文件存在且权限为 600

touch ~/.ssh/config
chmod 600 ~/.ssh/config

在该文件中,您可以像往常一样定义主机。对于 nssh,关键是为需要密码登录的主机添加一个自定义的 pwfilepath 指令。
示例 ~/.ssh/config:

# 主机1: 使用密钥登录 (nssh 会正常处理)
Host server-key
    HostName key-server.example.com
    User admin
    Port 22
    IdentityFile ~/.ssh/id_rsa_server_key

# 主机2: 使用密码登录 (nssh 特殊处理)
Host server-pass-prod
    HostName prod.example.com
    User deploy_user
    Port 2222
    # nssh 自定义指令: 指向包含密码的文件
    pwfilepath ~/.ssh/my_server_passwords.txt

# 主机3: 另一个使用密码的主机 (可以指向同一个密码文件)
Host dev-vm
    HostName 192.168.1.100
    User developer
    pwfilepath ~/.ssh/my_server_passwords.txt

2. 密码文件

根据 pwfilepath 指令中设置的路径创建密码文件。
示例密码文件 (例如 ~/.ssh/my_server_passwords.txt):

# 文件格式: Host别名=密码
# Host别名必须与 ~/.ssh/config 中的 'Host' 定义完全一致

server-pass-prod=MySup3rS3cur3P@ssw0rd!
dev-vm=simpleDevPass123

重要: 密码文件包含敏感信息,必须严格限制其权限:

chmod 600 ~/.ssh/my_server_passwords.txt

脚本内容

#!/bin/bash

# --- nssh ---
# Wrapper for ssh to allow password-based authentication via a password file
# if specified in ~/.ssh/config using a custom 'pwfilepath' directive.
# Also provides a host listing and selection feature.

# --- Configuration ---
SSH_CONFIG_FILE="$HOME/.ssh/config"

# --- Helper Functions ---

print_help() {
    echo "nssh - Enhanced SSH connection tool"
    echo
    echo "Usage: nssh [options] <host_alias> [ssh_options_or_remote_command...]"
    echo "       nssh -l | --list"
    echo "       nssh -h | --help"
    echo
    echo "Options:"
    echo "  <host_alias>                      The host alias defined in your ~/.ssh/config to connect to."
    echo "  -l, --list                      List all host aliases from ~/.ssh/config and prompt for selection."
    echo "  -h, --help                      Show this help message and exit."
    echo "  [ssh_options_or_remote_command] Optional arguments to pass directly to the underlying ssh command."
    echo
    echo "Description:"
    echo "  nssh simplifies SSH connections by:"
    echo "  1. Allowing password-based authentication using a 'pwfilepath' directive in ~/.ssh/config."
    echo "     If 'pwfilepath' is set for a host, nssh reads the password from the specified file."
    echo "     The password file should contain entries like: host_alias=password"
    echo "     Ensure the password file has restrictive permissions (e.g., chmod 600)."
    echo "  2. Falling back to standard key-based (or interactive password) SSH if 'pwfilepath' is not set."
    echo "  3. Providing a host selection menu via '-l' or '--list'."
    echo
    echo "Example ~/.ssh/config entry for password-based auth:"
    echo "  Host my_server_pass"
    echo "    HostName server.example.com"
    echo "    User myuser"
    echo "    pwfilepath ~/.ssh/my_passwords.txt"
    echo
    echo "Example ~/.ssh/config entry for key-based auth:"
    echo "  Host my_server_key"
    echo "    HostName key.example.com"
    echo "    User anotheruser"
    echo "    IdentityFile ~/.ssh/id_rsa_server_key"
    echo
    echo "To connect: nssh my_server_pass"
    echo "            nssh my_server_key"
    echo "            nssh -l  (then choose from list)"
}

# Function to parse ~/.ssh/config and extract all Host aliases
parse_ssh_config_for_hosts() {
    local config_file="$SSH_CONFIG_FILE"
    local hosts_array=()

    if [ ! -f "$config_file" ]; then
        echo "Warning: SSH config file '$config_file' not found." >&2
        return 1
    fi

    while IFS= read -r line || [[ -n "$line" ]]; do
        line_clean=$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/#.*//')
        if [[ "$line_clean" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+ ]]; then
            IFS=' ' read -ra patterns <<< "$(echo "$line_clean" | awk '{$1=""; print $0}' | sed 's/^[[:space:]]*//')"
            for pattern in "${patterns[@]}"; do
                if [[ -n "$pattern" && "$pattern" != "*" ]]; then
                    if ! [[ " ${hosts_array[*]} " =~ " ${pattern} " ]]; then
                        hosts_array+=("$pattern")
                    fi
                fi
            done
        fi
    done < "$config_file"

    IFS=$'\n' sorted_hosts_array=($(sort <<<"${hosts_array[*]}"))
    unset IFS

    for host in "${sorted_hosts_array[@]}"; do
        echo "$host"
    done
    return 0
}

# Function to get a specific config value for a host from ~/.ssh/config
get_ssh_config_value() {
    local host_alias="$1"
    local key_to_find="$2"
    local config_file="$SSH_CONFIG_FILE"
    local value=""
    local in_host_block=0 
    local host_match_level=0

    if [ ! -f "$config_file" ]; then
        return
    fi

    while IFS= read -r line || [[ -n "$line" ]]; do
        line_clean=$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/#.*//')
        if [[ -z "$line_clean" ]]; then continue; fi

        if [[ "$line_clean" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+ ]]; then
            current_host_patterns=$(echo "$line_clean" | awk '{for(i=2;i<=NF;i++) printf "%s ", $i}')
            in_host_block=0 
            for pattern in $current_host_patterns; do
                if [[ "$host_alias" == "$pattern" ]]; then 
                    in_host_block=1
                    host_match_level=2 
                    break
                fi
            done
        elif [[ "$in_host_block" -eq 1 && "$host_match_level" -ge 2 ]]; then 
            if echo "$line_clean" | grep -iqE "^${key_to_find}[[:space:]]+"; then
                value=$(echo "$line_clean" | sed -E -e "s/^[[:space:]]*${key_to_find}[[:space:]]+(.*)/\1/" -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
            fi
        fi
    done < "$config_file"

    if [ -z "$value" ]; then 
        in_host_block=0
        local temp_value_wildcard=""
        local temp_value_global=""

        while IFS= read -r line || [[ -n "$line" ]]; do
            line_clean=$(echo "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/#.*//')
            if [[ -z "$line_clean" ]]; then continue; fi

            if [[ "$line_clean" =~ ^[Hh][Oo][Ss][Tt][[:space:]]+ ]]; then
                current_host_patterns=$(echo "$line_clean" | awk '{for(i=2;i<=NF;i++) printf "%s ", $i}')
                in_host_block=0 
                for pattern in $current_host_patterns; do
                    if [[ "$pattern" == "*" ]]; then
                        in_host_block=2 
                        break
                    elif [[ "${pattern: -1}" == "*" && "${host_alias}" == "${pattern:0:-1}"* ]]; then
                        in_host_block=1 
                        break
                    fi
                done
            elif [[ "$in_host_block" -gt 0 ]]; then 
                 if echo "$line_clean" | grep -iqE "^${key_to_find}[[:space:]]+"; then
                    local current_block_value=$(echo "$line_clean" | sed -E -e "s/^[[:space:]]*${key_to_find}[[:space:]]+(.*)/\1/" -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
                    if [[ "$in_host_block" -eq 1 ]]; then 
                        temp_value_wildcard="$current_block_value"
                    elif [[ "$in_host_block" -eq 2 ]]; then 
                        temp_value_global="$current_block_value"
                    fi
                fi
            fi
        done < "$config_file"

        if [ -n "$temp_value_wildcard" ]; then
            value="$temp_value_wildcard"
        elif [ -n "$temp_value_global" ]; then
            value="$temp_value_global"
        fi
    fi
    echo "$value"
}

# --- Main Logic ---

if ! command -v sshpass &> /dev/null && [[ "$1" != "-h" && "$1" != "--help" && "$1" != "-l" && "$1" != "--list" ]]; then
    echo "Error: sshpass command not found. Please install it (e.g., sudo apt install sshpass)."
    exit 1
fi

if [[ "$1" == "-h" || "$1" == "--help" ]]; then
    print_help
    exit 0
fi

if [[ "$1" == "-l" || "$1" == "--list" ]]; then
    mapfile -t hosts < <(parse_ssh_config_for_hosts)
    if [ ${#hosts[@]} -eq 0 ]; then
        echo "No hosts found in $SSH_CONFIG_FILE or the file does not exist."
        exit 1
    fi

    echo "Available hosts:"
    for i in "${!hosts[@]}"; do
        printf "%3d) %s\n" $((i + 1)) "${hosts[$i]}"
    done
    echo

    read -p "Enter number to connect (or q to quit): " choice

    if [[ "$choice" =~ ^[qQ]$ ]]; then
        exit 0
    fi

    if ! [[ "$choice" =~ ^[0-9]+$ ]] || [ "$choice" -lt 1 ] || [ "$choice" -gt ${#hosts[@]} ]; then
        echo "Invalid selection."
        exit 1
    fi

    HOST_ALIAS="${hosts[$((choice - 1))]}"
    echo "Selected host: $HOST_ALIAS"
    set -- # Clear positional parameters for list mode, as no further args are expected
elif [ -z "$1" ]; then 
    print_help
    exit 1
else 
    HOST_ALIAS="$1"
    shift 
fi

PASSWORD_FILE_PATH_CONFIG=$(get_ssh_config_value "$HOST_ALIAS" "pwfilepath")

if [ -n "$PASSWORD_FILE_PATH_CONFIG" ]; then
    PASSWORD_FILE_PATH=$(eval echo "$PASSWORD_FILE_PATH_CONFIG") 

    if [ ! -f "$PASSWORD_FILE_PATH" ]; then
        echo "Error: Password file '$PASSWORD_FILE_PATH' specified for host '$HOST_ALIAS' not found."
        exit 1
    fi
    PERMISSIONS=$(stat -c "%a" "$PASSWORD_FILE_PATH") # GNU stat syntax
    if [ "$PERMISSIONS" != "600" ] && [ "$PERMISSIONS" != "400" ]; then
        echo "Warning: Password file '$PASSWORD_FILE_PATH' has insecure permissions ($PERMISSIONS). Should be 600 or 400."
    fi
    PASSWORD_ENTRY=$(grep -E "^${HOST_ALIAS}=" "$PASSWORD_FILE_PATH")
    if [ -z "$PASSWORD_ENTRY" ]; then
        echo "Error: Password for host '$HOST_ALIAS' not found in '$PASSWORD_FILE_PATH'."
        echo "Expected format: $HOST_ALIAS=yourpassword"
        exit 1
    fi
    PASSWORD=$(echo "$PASSWORD_ENTRY" | cut -d'=' -f2-)
    if [ -z "$PASSWORD" ]; then
        echo "Error: Password for host '$HOST_ALIAS' is empty in '$PASSWORD_FILE_PATH'."
        exit 1
    fi

    echo "Attempting password authentication for '$HOST_ALIAS' using password from '$PASSWORD_FILE_PATH'..."
    ACTUAL_HOSTNAME=$(get_ssh_config_value "$HOST_ALIAS" "HostName")
    ACTUAL_USER=$(get_ssh_config_value "$HOST_ALIAS" "User")
    ACTUAL_PORT_RAW=$(get_ssh_config_value "$HOST_ALIAS" "Port")
    if [ -z "$ACTUAL_HOSTNAME" ]; then
        echo "Error: HostName for '$HOST_ALIAS' not found in $SSH_CONFIG_FILE for password auth."
        exit 1
    fi
    SSH_CMD_ARGS=()
    if [ -n "$ACTUAL_PORT_RAW" ]; then SSH_CMD_ARGS+=("-p" "$ACTUAL_PORT_RAW"); fi
    SSH_CMD_ARGS+=("-o" "PubkeyAuthentication=no") 
    SSH_CMD_ARGS+=("-o" "PasswordAuthentication=yes")
    if [ -n "$ACTUAL_USER" ]; then SSH_CMD_ARGS+=("${ACTUAL_USER}@${ACTUAL_HOSTNAME}"); else SSH_CMD_ARGS+=("${ACTUAL_HOSTNAME}"); fi
    SSH_CMD_ARGS+=("$@") 
    sshpass -p "$PASSWORD" ssh -F /dev/null "${SSH_CMD_ARGS[@]}"
    exit $?

else
    echo "No 'pwfilepath' configured for '$HOST_ALIAS'. Attempting key-based SSH..."
    KEY_HOSTNAME=$(get_ssh_config_value "$HOST_ALIAS" "HostName")
    KEY_USER=$(get_ssh_config_value "$HOST_ALIAS" "User")
    KEY_PORT_RAW=$(get_ssh_config_value "$HOST_ALIAS" "Port")
    KEY_IDENTITYFILE_RAW=$(get_ssh_config_value "$HOST_ALIAS" "IdentityFile")

    if [ -z "$KEY_HOSTNAME" ]; then
        echo "Info: HostName for '$HOST_ALIAS' not found in $SSH_CONFIG_FILE for key-based auth."
        echo "This could also mean the host alias '$HOST_ALIAS' is not defined in the config file."
        echo "Falling back to direct ssh call for '$HOST_ALIAS', which might use system defaults or fail."
        ssh "$HOST_ALIAS" "$@"
        exit $?
    fi

    SSH_KEY_CMD_ARGS=()
    if [ -n "$KEY_PORT_RAW" ]; then SSH_KEY_CMD_ARGS+=("-p" "$KEY_PORT_RAW"); fi
    if [ -n "$KEY_IDENTITYFILE_RAW" ]; then
        EXPANDED_IDENTITYFILE=$(eval echo "$KEY_IDENTITYFILE_RAW")
        SSH_KEY_CMD_ARGS+=("-i" "$EXPANDED_IDENTITYFILE")
    fi
    if [ -n "$KEY_USER" ]; then SSH_KEY_CMD_ARGS+=("${KEY_USER}@${KEY_HOSTNAME}"); else SSH_KEY_CMD_ARGS+=("${KEY_HOSTNAME}"); fi
    SSH_KEY_CMD_ARGS+=("$@") 
    echo "Attempting key-based SSH for '$HOST_ALIAS' with explicitly parsed parameters (ignoring user ssh_config for pwfilepath compatibility)..."
    ssh -F /dev/null "${SSH_KEY_CMD_ARGS[@]}"
    exit $?
fi

使用方法

1. 获取帮助信息

nssh -h
# 或者
nssh --help

这将显示详细的用法和说明。

2. 列出并选择主机进行连接 nssh -l

Available hosts:
  1) dev-vm
  2) server-key
  3) server-pass-prod
Enter number to connect (or q to quit): 3
Selected host: server-pass-prod
Attempting password authentication for 'server-pass-prod' using password from '/home/user/.ssh/my_server_passwords.txt'...
[...连接成功...]

3. 直接连接主机

nssh server-pass-prod

与默认 ssh 用法保持一致,不管是使用密码还是使用密钥连接,都是兼容的。

安全注意事项

  • 密码文件安全: 将密码以明文形式存储在文件中存在固有风险。尽管 nssh 要求密码文件权限为 600 (只有所有者可读写),但这并不能防止拥有您用户账户访问权限的人读取密码。
  • sshpass: sshpass 工具通过非交互方式提供密码,密码会在执行期间存在于内存中。
  • SSH 密钥认证优先: 强烈建议尽可能使用 SSH 密钥对进行认证。密钥认证比密码认证更安全,也更方便(一旦设置好,通常可以免密登录)。nssh 的密码功能主要是为了应对那些由于限制无法使用密钥认证的场景,或者作为一种过渡方案。
  • 谨慎使用: 请充分理解此工具的工作方式和潜在安全风险后再使用。

特别注意

直接使用 ~/.ssh/confg 文件来作为 nssh 的配置文件会导致原 ssh 不可用,因为 ssh 不认识自定义配置 pwfilepath 导致报错无法继续。如果在使用 nssh 的同时需要继续使用 ssh,推荐使用一个特定的文件来作为 nssh 的配置文件,比如 ~/.ssh/nssh_config,同时删除 ~/.ssh/confg 中带有 pwfilepath 配置项的主机。

这样,脚本的开头配置文件路径需要修改为:

SSH_CONFIG_FILE="$HOME/.ssh/nssh_config"

总结

nssh 是一个便捷的 Shell 脚本,旨在通过智能处理密码认证和提供主机选择功能来简化 SSH 连接。简单的配置即可兼容密码连接,且与 SSH 原始用法保持一致。

标题:Linux 下让 .ssh/config 同时支持密钥和密码连接 SSH

原文链接:https://beltxman.com/4535.html

若无特殊说明本站内容为 行星带 原创,未经同意请勿转载。

发表评论

您的电子邮箱地址不会被公开。

Scroll to top