随着折腾的设备和云服务器越来越多,我们本地的 SSH Config 配置越来越复杂,为了解决这个问题,最近做了一些简单的尝试。

写在前面

本文提到的开源软件是 soulteary/ssh-config,为了让使用更放心,项目代码的单元测试覆盖 100%,确保数据转换是幂等、可靠的,能够放心的长时间使用。感兴趣可以自取,欢迎一键三连。

SSH Config Tool

熟悉我的朋友知道我换电子设备比较勤快,这几年敲字使用的 Mac 应该也已经更换了四五台左右。但是,不论我的“打字机”换成了哪一款,我都会在当前设备中用 Git 初始化一个 ~/.ssh 目录,导入包含了我的 RSA 密钥和绝大多数的服务器 SSH 连接配置。

这个目录的大致情况如下(主要配置包含config 文件和 config.d/* 文件目录中的文件):

# tree -L 4 ~/.ssh/       

/Users/soulteary/.ssh/
├── config
├── config.d
│   ├── aliyun
│   ├── discard
│   ├── ...
│   ├── homelab
│   └── tencent
├── keys
│   ├── github
│   │   ├── id_rsa_github
│   │   └── id_rsa_github.pub
│   └── devices
│       ├── devices-2018
│       ├── devices-2018.pub
│       ├── devices-2020
│       ├── devices-2020.pub
│       ├── devices-2022
│       ├── devices-2022.pub
│       ├── devices-2024
│       └── devices-2024.pub
├── known_hosts
├── logo.png
└── template.d
    └── keep-connect.conf

随着时间推移,折腾的 HomeLab 设备、虚拟机和云服务器越来越多,这个 .ssh 目录中的配置文件也越来越冗余。

如果使用命令行来统计行数,配置内容有 500 多行的内容:

# cat ~/.ssh/config ~/.ssh/config.d/* | wc -l

528

而配置中的内容,其实非常的无聊,都是模版化的服务器连接参数堆叠:

# cat ~/.ssh/config                                             

Host *
  HostkeyAlgorithms +ssh-rsa
  PubkeyAcceptedAlgorithms +ssh-rsa

# 服务器 1
Host server1
  Hostname 123.123.123.123
  Port 1234
  IdentityFile ~/.ssh/keys/your-key
  ControlPath     ~/.ssh/server1-%r@%h:%p
  ControlPersist  yes
  TCPKeepAlive    yes
  Compression     yes
  ForwardAgent    yes

# 服务器 2
Host server2
  Hostname 123.234.234.234
  Port 1234
  IdentityFile ~/.ssh/keys/your-key
  ControlPath     ~/.ssh/server2-%r@%h:%p
  ControlPersist  yes
  TCPKeepAlive    yes
  Compression     yes
  ForwardAgent    yes

# 其他配置
Include config.d/homelab
Include config.d/aliyun
Include config.d/tencent
...

为了解决这个问题,我写了一个简单的命令行工具 ssh-config,在将工具转换后的配置进行简单的调整后,具备清晰明了结构的 YAML 配置文件行数缩短到了之前的 25%(还有进一步优化空间)。从这个 YAML 配置文件转换出的 OpenSSH 使用的 SSH Config 行数,也只有 140 行左右。

# cat test.yaml | wc -l

130

# cat test.yaml | ssh-config -to-ssh | wc -l

143

随便举个例子,如果我们想管理三台两两之间有相似配置的服务器,这个配置将类似这样,而非充斥大量冗余配置的 SSH Config,而这个配置可以 100% 稳定转换为幂等的 SSH Config:

global:
  HostKeyAlgorithms: +ssh-rsa
  PubkeyAcceptedAlgorithms: +ssh-rsa

default:
  Compression: "yes"
  ControlPersist: "yes"
  ForwardAgent: "yes"
  Port: "1234"
  TCPKeepAlive: "yes"

Group server1:
  Common:
    ControlPath: ~/.ssh/server-1-%r@%h:%p
  Hosts:
    server1:
      Notes: your notes here
      config:
        HostName: 123.123.123.123
        IdentityFile: ~/.ssh/keys/your-key1
    server3:
      Notes: new server
      config:
        HostName: 123.124.123.124
        IdentityFile: ~/.ssh/keys/your-key3

Group server2:
  Hosts:
    server2:
      config:
        ControlPath: ~/.ssh/server-2-%r@%h:%p
        HostName: 123.234.123.234
        IdentityFile: ~/.ssh/keys/your-key2
        User: ubuntu

接下来,我将分别聊聊这个工具如何使用,以及制作过程中的一些有趣的事情。

SSH Config 配置管理工具的使用

首先是软件的获取,如果你熟悉 Docker,那么我推荐你使用 Docker。如果你是极简主义者,也可以从 GitHub 发布页面下载合适你的系统、CPU 架构的二进制文件。

软件的使用有两种模式,第一种是普通的命令行加参数:

ssh-config [options] <input_file> <output_file>

如果我们想要把 ~/.ssh/ 目录中所有的配置都转换为 YAML 格式,并保存到当前目录的 test.yaml 文件中:

ssh-config -to-yaml -src ~/.ssh/ -dest test.yaml

如果你不想保存到某个文件中,而是想直接查看转换结果,只需要去掉 -dest 参数,转换文件的内容就直接展示出来啦:

# ssh-config -to-yaml -src /.ssh/

global:
  HostKeyAlgorithms: +ssh-rsa
  PubkeyAcceptedAlgorithms: +ssh-rsa
Group server1:
  Hosts:
    server1:
      Notes: your notes here
      config:
        Compression: "yes"
        ControlPath: ~/.ssh/server-1-%r@%h:%p
        ControlPersist: "yes"
        ForwardAgent: "yes"
        HostName: 123.123.123.123
        IdentityFile: ~/.ssh/keys/your-key1
        Port: "1234"
        TCPKeepAlive: "yes"
Group server2:
  Hosts:
    server2:
      config:
        Compression: "yes"
        ControlPath: ~/.ssh/server-2-%r@%h:%p
        ControlPersist: "yes"
        ForwardAgent: "yes"
        HostName: 123.234.123.234
        IdentityFile: ~/.ssh/keys/your-key2
        Port: "1234"
        TCPKeepAlive: "yes"
        User: ubuntu

当然,如果你喜欢 Linux 管道,那么你可以使用下面的方式,来实现上面的命令相同的功能:

# 保存到某个文件
cat ~/.ssh/config ~/.ssh/config.d/* | ssh-config -to-yaml > test.yaml

# 直接查看转换结果
cat ~/.ssh/config ~/.ssh/config.d/* | ssh-config -to-yaml

上面的配置是不是看起来有一些冗余?小学的时候,我们就学过一个技巧,叫做“提取公因数”,这个技巧,我们可以用来改进这个配置文件。

优化转换后的配置

上面的配置中,我们能够看到程序将每一个 Hosts 都转换为了一个单独的分组。每个分组中都有一些共同的配置,我们可以将它们挪到全局使用的配置 default 字段中。

global:
  HostKeyAlgorithms: +ssh-rsa
  PubkeyAcceptedAlgorithms: +ssh-rsa

default:
  Compression: "yes"
  ControlPersist: "yes"
  ForwardAgent: "yes"
  Port: "1234"
  TCPKeepAlive: "yes"

Group server1:
  Hosts:
    server1:
      Notes: your notes here
      config:
        ControlPath: ~/.ssh/server-1-%r@%h:%p
        HostName: 123.123.123.123
        IdentityFile: ~/.ssh/keys/your-key1

Group server2:
  Hosts:
    server2:
      config:
        ControlPath: ~/.ssh/server-2-%r@%h:%p
        HostName: 123.234.123.234
        IdentityFile: ~/.ssh/keys/your-key2
        User: ubuntu

当我们将文件保存为 test.yaml 后,执行转换命令:

cat test.yaml| ./ssh-config -to-ssh

就能够得到下面的配置转换结果啦:

Host *
    HostKeyAlgorithms +ssh-rsa
    PubkeyAcceptedAlgorithms +ssh-rsa

# your notes here
Host server1
    Compression yes
    ControlPath ~/.ssh/server-1-%r@%h:%p
    ControlPersist yes
    ForwardAgent yes
    HostName 123.123.123.123
    IdentityFile ~/.ssh/keys/your-key1
    Port 1234
    TCPKeepAlive yes

Host server2
    Compression yes
    ControlPath ~/.ssh/server-2-%r@%h:%p
    ControlPersist yes
    ForwardAgent yes
    HostName 123.234.123.234
    IdentityFile ~/.ssh/keys/your-key2
    Port 1234
    TCPKeepAlive yes
    User ubuntu

你或许会想问,如果我想给每个分组都有自己的额外的“共同配置”,那么该如何处理呢?举个例子,我们在上面的配置中,再添加一台服务器:

global:
  HostKeyAlgorithms: +ssh-rsa
  PubkeyAcceptedAlgorithms: +ssh-rsa

default:
  Compression: "yes"
  ControlPersist: "yes"
  ForwardAgent: "yes"
  Port: "1234"
  TCPKeepAlive: "yes"

Group server1:
  Hosts:
    server1:
      Notes: your notes here
      config:
        ControlPath: ~/.ssh/server-1-%r@%h:%p
        HostName: 123.123.123.123
        IdentityFile: ~/.ssh/keys/your-key1
    server3:
      Notes: new server
      config:
        ControlPath: ~/.ssh/server-1-%r@%h:%p
        HostName: 123.124.123.124
        IdentityFile: ~/.ssh/keys/your-key3

Group server2:
  Hosts:
    server2:
      config:
        ControlPath: ~/.ssh/server-2-%r@%h:%p
        HostName: 123.234.123.234
        IdentityFile: ~/.ssh/keys/your-key2
        User: ubuntu

上面的配置中,我们能够看到 “Group server 1” 有两台服务器连接配置,“server 1” 和 “server 3” 中有一些相似配置。这些配置和其他分组的服务器不一样,和上文一样,使用全局配置管理,有一些不合适。

我们可以在分组下的 Common 字段中,将小组内的“公因数”提取出来:

global:
  HostKeyAlgorithms: +ssh-rsa
  PubkeyAcceptedAlgorithms: +ssh-rsa

default:
  Compression: "yes"
  ControlPersist: "yes"
  ForwardAgent: "yes"
  Port: "1234"
  TCPKeepAlive: "yes"

Group server1:
  Common:
    ControlPath: ~/.ssh/server-1-%r@%h:%p
  Hosts:
    server1:
      Notes: your notes here
      config:
        HostName: 123.123.123.123
        IdentityFile: ~/.ssh/keys/your-key1
    server3:
      Notes: new server
      config:
        HostName: 123.124.123.124
        IdentityFile: ~/.ssh/keys/your-key3

Group server2:
  Hosts:
    server2:
      config:
        ControlPath: ~/.ssh/server-2-%r@%h:%p
        HostName: 123.234.123.234
        IdentityFile: ~/.ssh/keys/your-key2
        User: ubuntu

再次执行命令,转换为 SSH 配置看看?

# cat test.yaml| ./ssh-config -to-ssh

Host *
    HostKeyAlgorithms +ssh-rsa
    PubkeyAcceptedAlgorithms +ssh-rsa

# new server
Host server3
    Compression yes
    ControlPath ~/.ssh/server-1-%r@%h:%p
    ControlPersist yes
    ForwardAgent yes
    HostName 123.124.123.124
    IdentityFile ~/.ssh/keys/your-key3
    Port 1234
    TCPKeepAlive yes

# your notes here
Host server1
    Compression yes
    ControlPath ~/.ssh/server-1-%r@%h:%p
    ControlPersist yes
    ForwardAgent yes
    HostName 123.123.123.123
    IdentityFile ~/.ssh/keys/your-key1
    Port 1234
    TCPKeepAlive yes

Host server2
    Compression yes
    ControlPath ~/.ssh/server-2-%r@%h:%p
    ControlPersist yes
    ForwardAgent yes
    HostName 123.234.123.234
    IdentityFile ~/.ssh/keys/your-key2
    Port 1234
    TCPKeepAlive yes
    User ubuntu

符合预期的配置就出现啦,是不是还蛮简单的呢?

通过这个方式,我们就可以更清晰明了的管理我们的服务器连接配置啦。

Docker 使用

如果你使用 Docker ,可以使用下面的方式来下载软件的镜像:

docker pull soulteary/ssh-config:v1.1.0
# or
docker pull ghcr.io/soulteary/ssh-config:v1.1.0

和上文一样,使用的方式有直接操作文件,或者使用 Linux 管道。

如果你想直接操作文件,那么你需要将本地的文件映射到容器中,然后使用工具来处理文件即可:

docker run --rm -it -v `pwd`:/ssh soulteary/ssh-config:v1.1.0 ssh-config -to-yaml -src /ssh/test.yaml -dest /ssh/abc.yaml

如果你想使用管道来操作文件,我个人推荐先进入 Docker 交互式命令行环境,然后再使用工具操作配置文件:

docker run --rm -it -v `pwd`:/ssh soulteary/ssh-config:v1.1.0 bash

cat /ssh/test.yaml | ssh-config -to-yaml

工具制作背后的事情

在制作工具之前,第一件事是看能否不制作这个工具:或许开源社区有类似需求的人,实现了这样一个小工具。

虽然有一些工具,但是距离我的理想型,总归是差了一些。

SSH 配置管理工具方案

在进行了一番查找,以及群友的推荐下,大概可以使用下面四个路径来完成 SSH 配置文件的管理:

确定制作工具路径

我更换设备的频率保持在每年或最长每两年(有时候是半年),基于这个情况我越来越倾向使用最常见、最普适的高市场占比的软件,OpenSSH Client 是市占率非常高的家伙,除了路由器这类设备外,能够使用 SSH 进行交互的设备,几乎都在使用 OpenSSH 相关软件。所以,使用替代品,不一定是折腾后能够省心的事情。

因为我的服务器和设备数量并不少,基于可视化的界面方案在呈现效果和使用效率上来说,都会有一些体验上的损失。并且,除了 Web 技术栈之外的 “可视化” 方案在跨系统平台、CPU 架构、多设备的体验中需要付出额外的代价。即使我订阅的 Setapp 中有这个 SSH Config Editor 软件,考虑到上面的因素,我倾向选择不使用带 Web 界面之外的软件。

至于类似 Ansible 类似的持续集成常客的方案,管理配置不是不行,但是太过麻烦,上文中提到的功能,光看文档的时间就足够写完整个程序了。进行幂等的文件部署,是合适的,细粒度管理和更新配置,这类软件其实并不擅长。

让我开心的,但是又失望的软件,是一款 CLI 命令行软件。

开心的是,这款软件能够将 YAML 格式的 SSH Config 转换为 OpenSSH 能够使用的格式,定义了几乎就是我想要的清晰明了、很少冗余信息的配置。但,失望的是:

  1. 软件只能够从 YAML 结构,转换出 SSH Config,是单向处理。
  2. 软件的核心转换过程,没有任何单元测试覆盖保障正确性。
  3. 软件明明只有一个单一转换的功能,但是引用了一堆外部依赖。
  4. 软件或许是受到了 1.x 版本的 Python 程序的编写影响,实现的有一些不好修改,上面三个问题想要提 PR 改进,稍微有点难度。

为了解决这几个原因,我决定参考这个软件配置的思路,重新实现一个能够支持 SSH Config 多种格式保存、转换功能,核心逻辑 100% 测试覆盖,不需要复杂外部依赖的小工具。

更加稳定、可靠、小巧、高效,确保使用这个工具处理数据的结果是准确的、体验是舒服的。

实践经验分享

这个程序的本质是字符串处理工具,主要处理的字符串类型有下面三种:

  • OpenSSH Client 使用的 SSH Config
  • 通用的 YAML 格式
  • 通用的 JSON 格式

因为工具设计里,这三种类型之间可以自由转换,所以,我们需要关注一种数据格式,或者抽象一种更简单的,可以横跨三个数据格式中的中间格式(类似“中间代码(IR)”)。

我定义的基础的中间代码类似下面这样,一个包含了所有 OpenSSH Client 支持的配置项的数据结构(源代码soulteary/ssh-config/internal/parser/define.go,120 行+):

type HostConfig struct {
	Host  string `yaml:"Host,omitempty"`
	Match string `yaml:"Match,omitempty"`
...
	Ciphers                     string `yaml:"Ciphers,omitempty"`
	ClearAllForwardings         string `yaml:"ClearAllForwardings,omitempty"`
...
	HostName                    string `yaml:"HostName,omitempty"`
...
	Port                     string `yaml:"Port,omitempty"`
...
}

上面这个数据结构就是三种配置“数据流通”过程中的硬通货。由于三种格式的使用场景和客观要求原因,三种格式的最终数据结构是有一些差异的,所以我们还需要为三种数据格式定义新的数据结构(源代码 soulteary/ssh-config/internal/define/define.go):

...
// ssh config
type HostConfig struct {
	Name   string `yaml:"Name,omitempty"`
	Notes  string `yaml:"Notes,omitempty"`
	Config map[string]string
	Extra  HostExtraConfig `yaml:"Extra,omitempty"`
}

// json
type HostConfigDataForJSON map[string]string

type HostConfigForJSON struct {
	Name  string                `json:"Name,omitempty"`
	Notes string                `json:"Notes,omitempty"`
	Data  HostConfigDataForJSON `json:"Data,omitempty"`
}

// yaml
type GroupConfig struct {
	Prefix string                `yaml:"Prefix,omitempty"`
	Common map[string]string     `yaml:"Common,omitempty"`
	Hosts  map[string]HostConfig `yaml:"Hosts,omitempty"`
}
type YAMLOutput struct {
	Global  map[string]string      `yaml:"global,omitempty"`
	Default map[string]string      `yaml:"default,omitempty"`
	Groups  map[string]GroupConfig `yaml:",inline"`
}

在定义了“硬通货”和“最终数据格式”之后,我们就能够快速的展开工作了,只需要:

  • 完成不同格式到中间数据格式的相互转换,工具就能够支持多种文件格式的转换了。
  • 完成基础的 IO 操作,工具就能够实现跟进需求读取配置文件,转换并保存新的配置文件的功能了。
  • 完成标准管道操作,工具就能够支持 Linux 标准的 Pipeline 管道操作了。

虽然每个功能都很简单,但是因为我们可以选择某个目录或者某个文件文件作为数据源,可以选择设置保存文件路径,也可以不设置路径,可以将数据转换为 YAML、转换为 JSON、转换为 SSH 配置等等,加上各种异常判断,各种组合下来,想要写一个能跑的程序简单,想要写一个 100% bugfree 的软件,其实还是要花一些时间的。

而这里,能够减少时间花费的秘诀,就是优先完成函数的设计和单元测试覆盖。 100% 的单元测试覆盖,可以让你在使用软件的时候拥有非常强的安全感,在重构或者为软件新增功能的时候,花最少的时间去完成功能的添加和验证,而不需要担心引入额外的风险。

最后

最近的事情比较多,既然工具已经分享出来啦,那么这篇文章就先写到这里吧。

我们下篇文章再见。

–EOF