SSH

Secure Shell Protocol,简称SSH。加密的网络传输协议。

SSH使用客户端-服务器模型,标准端口为22。

SSH最常见的用途是远程登录系统。

服务器端需要开启SSH守护进程以便接受远端的连接,而用户需要使用SSH客户端与其建立连接。

(来自维基百科)

公私钥 免密登录

ssh-keygen

用于生成公私钥。直接输入,会进入交互模式:

  • 决定在哪里保存将生成的「公私钥文件」
  • 是否为私钥设置密码

默认在~/.ssh/生成RSA类型的密钥文件:私钥文件id_rsa与公钥文件id_rsa.pub

ssh-keygen -t rsa -f /path/id_rsa_test -P '' -b 1024 -C "xxx@gmail.com"
  • -f 指定密钥生成路径及名称。可以直接文件名,则默认放在~/.ssh/下。
  • -P 设置私钥密码。空字符串表示无密码。若未指定-P,会交互式提示设置密码。
  • -p 交互式修改私钥密码。
  • -t 指定密钥类型,即使用的加密算法,默认rsa
  • -b 指定密钥的位数。
  • -C 公钥备注信息。

自定义密钥名,在ssh连接对应主机时,必须用-i指定对应的私钥文件才能连接!

ssh -i /xxx/id_rsa_xxx xxx@192.168.1.1

免密码登录远程主机

SSH除了常规「用户名+密码」登录远程主机之外,更常用的方式是采用「密钥对」来免密登录,特别是运维自动化,这是很重要的。

免密登录的前提:将本机的公钥内容,添加到远程主机的authorized_keys文件中。

手动添加:

将公钥内容复制给有权限上远程主机的人,由Ta来手动单台添加。或者借助自动化工具批量主机添加。

命令添加:

若自己有用户密码可以上远程主机,那么可以本机执行命令来远程添加公钥,更方便:

ssh-copy-id -i ~/.ssh/id_rsa.pub username@remote-IP

输入一次远程主机用户的密码,完成提供公钥的功能后,就不再需要输入了。注意这里是公钥文件!

若远程主机未使用SSH默认22端口,则需要:

centos6中:ssh-copy-id -i ~/.ssh/id_rsa.pub "username@remote-IP -p 23456"

centos7中:ssh-copy-id -i ~/.ssh/id_rsa.pub username@remote-IP -p 23456

若不用这个ssh-copy-id的命令,就要输入一长串命令:

cat ~/.ssh/id_rsa.pub | ssh -p 22 username@remote-IP "umask 077;mkdir -p ~/.ssh;cat – >> ~/.ssh/authorized_keys"

免密登录失败的可能原因

假设已经将公钥加入到远程的authorized_keys中:

  • ssh服务端sshd_config配置问题:不支持基于公钥认证,或不再是默认的公钥认证文件。

    • PubkeyAuthentication配置项,控制是否基于公钥进行认证,默认值为yes
      • AuthorizedKeysFile配置项,指定公钥认证文件。默认为authorized_keys作为存储用户公钥的文件
  • ssh服务端相关目录或者文件的权限过大。

    • ~/.ssh/目录的权限正常700
      • ~/.ssh/authorized_keys文件的权限正常为600
  • ssh服务端authorized_keys文件中包含windows字符。手动复制容易出现。
  • ssh客户端连接时,未指定公私钥对应的用户名。
  • ssh客户端的私钥权限过大。

    • 私钥的正常权限设置为600
  • ssh客户端连接时,对于使用了自定义名称的密钥对,未在参数中用-i指定对应的私钥。

SSH代理 ssh-agent

ssh代理服务ssh-agent,在本机后台运行,为ssh客户端管理本机的公私钥。

能解决的问题:

  • 若本地有多对密钥,且使用不同密钥登录不同远程主机。此时的密钥名称往往是自定义的,每次SSH连接都必须手动指定对应的私钥文件,会很麻烦。
  • 若私钥还设置了密码,则每次SSH连接都要输入一遍密码。

目的:简化繁琐的身份验证交互过程。

  • 自动选择对应的私钥。
  • 仅初次添加私钥时输入一次密码,后续创建连接不用再输入。(若代理进程挂掉重启了,还是需要重新添加密钥。就比如常见的重启电脑)

agent通常已启动,若没有启动则可以使用2种方式之一:

  • 启动一个shell作为当前shell的子shell:ssh-agent $SHELL
  • 启动一个新ssh-agent进程:eval ssh-agent``

向agent添加私钥:

ssh-add  ~/.ssh/id_rsa_xxx

查看agent中已管理的私钥:

ssh-add -l

-L则显示对应公钥内容。

从agent中删除XXX密钥的管理:

ssh-add -d XXX

使用代理进行建立连接的简单过程:

当ssh客户端需要与ssh服务端进行认证时,

  1. 服务端会发送用于验证客户端身份的数据
  2. 此时,ssh客户端会跟ssh-agent进行交互,通过agent中的私钥对服务端发送过来的数据进行处理
  3. 然后将经过私钥处理的认证数据发送到服务端
  4. 服务端通过对应公钥检验数据,验证成功后,建立连接

SSH代理转发

参考文章:https://www.zsythink.net/archives/2422

假设:

有3台主机 A、B、C。

仅A上创建了密钥对,并将「公钥」添加到B、C的authorized_keys。可以免密登录:

  • A -> B
  • A -> C

但B、C之间相互没有基于密钥的认证。

那么:

把B上的文件远程传到C上,能否成功?

scp youker@B-IP:/tmp/test.txt dq@C-IP:/tmp/test.txt

结论,无法成功。

因为SSH连接的顺序:A -> B -> C。C主机会要求B验证登录身份。

如果,B可以做中间代理,将C的验证请求转发给A,让A验证完再转发给C,这样就能连接了。

这就是「SSH 代理转发」提供的能力。

并且,C只能看到是B连接过来的!而B无需放A的私钥,或者让B仅为了连接C而另外创建一对公私钥!

这在工作中很常见。B往往不仅连接C,还会连接D、E、F…..

A好比个人电脑,B相当于跳板机(企业中在跳板机之前还会有堡垒机),C、D、E、F….就是一堆内网主机。

使用「SSH代理转发」后,仅需将个人的公钥放到远程主机的authorized_keys中,就可以一路连接过去了。(当然,一般会做限制,比如内网主机之间限制互相跳。)

启用方法:

  1. A主机ssh客户端启用「代理转发」。

    1. 修改ssh_config配置文件。将ForwardAgent的值设置为yes,默认值为no
  2. B的ssh服务端需要「允许代理转发」,当然,默认都是允许作为中间代理的。

    1. 修改的是sshd_config配置文件。注意是sshd。将AllowAgentForwarding配置项设置为yes。默认值就是yes

SSH端口转发

参考文章:https://www.zsythink.net/archives/2450

分为2种:

  • 本地转发
  • 远程转发(反向SSH隧道)。可以用作「内网穿透」。

SSH的Python库 paramiko

Python中使用paramiko第三方库实现ssh连接。

注意,在连接到目标主机之前,需要处理「连接到未知主机」的问题:

「手动方式」

  1. 先shell中连接目标主机,输入yes,将目标主机的host key保存到known_hosts文件中。
  2. client.connect()之前先执行加载known_hosts
client.load_system_host_keys()  # 从系统默认的"~/.ssh/known_hosts" 加载已信任的主机 host key
client.load_host_keys(filename="...")  # 从自定义路径加载
client.connect(...)

「其他策略类」

  • paramiko.AutoAddPolicy() 比较常用,自动保存未知主机host key到known_hosts
  • paramiko.WarningPolicy() 给出警告并记录到日志,但会进行连接。
  • paramiko.RejectPolicy() 直接抛出异常。
  • 也可以通过继承paramiko.MissingHostKeyPolicy来自定义策略类。

用法如下:

client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(...)

「关于执行远程命令」

  • 常用的是client.exec_command(cmd),不会打开虚拟终端shell,不出现交互式的命令提示符!
  • 若要模拟虚拟终端,进行交互输入,则用到client.invoke_shell()

几种示例:

from paramiko.client import SSHClient, AutoAddPolicy
from paramiko.config import SSH_PORT
from paramiko.rsakey import RSAKey
from io import StringIO
import paramiko
import getpass


def ssh_by_username_passwd(host, port, user, cmd):
    """「用户名+密码」方式进行SSH远程连接,并执行一条简单命令。
    """
    # 实例化 ssh client 对象
    client = paramiko.SSHClient()

    # 自动添加未知主机到信任主机列表
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

    # * 创建SSH连接
    client.connect(
        hostname=host, port=port, username=user, password=getpass.getpass()
    )  # 通过getpass(),在执行脚本时交互输入密码,避免代码中写明文密码!

    # 在远程主机执行命令
    mystdin, mystdout, mystderr = client.exec_command(cmd)  # 结果放入stdout,有错误则放入stderr

    # 执行结果
    ret = [f'[{host}]']
    ret.extend(mystdout.readlines())
    # mystdout.readlines() 返回list,每行都是byte字节串,可decode('utf-8')
    # mystdout.read()  直接返回所有内容,为byte字节串,也可decode('utf-8')
    return_code = mystdout.channel.recv_exit_status()  # 命令执行结果的返回码

    # 关闭连接
    client.close()
    return ret


def ssh_by_keyfile(host, port, user, cmd, private_key_file_path=""):
    """「公私钥」方式进行SSH远程连接,并执行一条简单命令。

    该方式的前提,是需要两台主机间已经SSH授信。
    即本地机器(paramiko脚本所在)的公钥,已经添加到目标远程主机的 ~/.ssh/authorized_keys 文件中。
    所以若是无法公私钥方式远程连接,可能和 .ssh/目录、authorized_keys文件 的权限设置。
    """
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())

    # 读取私钥文件
    praivate_key = paramiko.RSAKey.from_private_key_file(
        private_key_file_path
    )  # 创建密钥对时,若设置了密码,需指定passwoer参数。
    # praivate_key = paramiko.RSAKey.from_private_key_file(
    #     private_key_file_path, password=getpass.getpass()
    # )

    # 建立连接,指定pkey
    client.connect(hostname=host, port=port, username=user, pkey=praivate_key)

    stdin, stdout, stderr = client.exec_command(cmd)
    ret = stdout.read().decode('utf-8')
    client.close()
    return ret


def ssh_multiple_cmds(host, port, user, cmds=None, private_key_file_path=""):
    """一次SSH会话连接,执行多条命令。

    每条命令若比较复杂,则可以借助 pipes 模块构造复杂 shell 命令行。
        from pipes import quote
        cmd_1 = quote("cat filname.txt")
        cmd_2 = quote("cat 'filname with spaces.txt'")
        cmd_3 = quote("cat filname\ with\ spaces.txt")
        cmd_4 = quote("echo 'danger!'; rm -r /tmp/*")
        cmd_5 = quote("echo `date` && df -hl")
        cmds = [cmd_1, cmd_2, cmd_3, cmd_4]
    """
    client = paramiko.SSHClient()
    client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
    praivate_key = paramiko.RSAKey.from_private_key_file(private_key_file_path)
    client.connect(hostname=host, port=port, username=user, pkey=praivate_key)

    ret = []
    for cmd in cmds:
        stdin, stdout, stderr = client.exec_command(cmd)
        stdin.close()
        ret.append(stdout.read().decode('utf-8'))
        stdout.close()
        stderr.close()

    client.close()


def sftp_by_keyfile(
    host,
    port,
    user,
    cmd,
    private_key_file="",
    local_path="",
    remote_path="",
):
    """「公私钥」方式,SFTP上传、下载文件。

    一些SSH的特性,需要借助更底层的对象Transport来实现操作:传输文件、端口转发...
    """
    # 用Transport建立连接
    trans = paramiko.Transport((host, port))
    praivate_key = paramiko.RSAKey.from_private_key_file(private_key_file)
    trans.connect(username=user, pkey=praivate_key)
    # trans.connect(username=user, password=getpass.getpass())  # 密码登录方式则替换为 password 参数进行连接

    # 实例化 ssh client 对象,并指定已连接的 Transport
    client = paramiko.SSHClient()
    client._transport = trans

    stdin, stdout, stderr = client.exec_command(cmd)

    # 从连接的Transport,实例化一个sftp对象。开启单独的 channel
    sftp = paramiko.SFTPClient.from_transport(trans)
    # 上传文件
    sftp.put(localpath=local_path, remotepath=remote_path)
    # # 下载文件
    # sftp.get(localpath=local_path, remotepath=remote_path)

    # 关闭 Transport 对象即可
    trans.close()