在我的职业生涯中,我在网络托管业务中有多个项目。被工程赋予了自豪感的我始终希望给予客户的最大自由度,他们可以自由地做他们想做的事情。早在2009年,我领导的团队就提供了完整的SSH访问权限来维护其站点。那时,甚至FTP加密都很罕见。但是,提供SSH访问并非没有挑战。
我已经编写了启动容器的功能齐全的SSH微服务。GitHub上也提供了这篇文章的示例代码。
[站外图片上传中...(image-1c490f-1597646525583)]
传统上提供SSH的Web托管环境是通过提供按站点或按客户的环境来实现的。这些环境通过为每个环境创建单独的彼此分离的Linux用户。如果每个站点都有自己的环境,则此方法效果很好,但是当客户在同一环境中托管多个站点时,则存在危险。如果这些站点中的任何一个受到感染,则存在交叉污染的风险。
两种方法都有一个明显的缺点:无法选择允许用户从单个SSH用户访问一组特定的站点。用户可以看到一个环境中的所有站点,也可以看到一个站点。没有使用户看到特定站点集的选项。

使用容器
传统方法使用Linux系统用户将站点彼此分开,因此由于权限问题,几乎不可能创建可以访问多个站点的SSH用户。那么如果我们采用 “新”技术来解决这个问题呢?
你知道我要如何处理,这并不新鲜。在Linux上,容器已经存在15年了,在其他操作系统上甚至更长。但是,直到最近才达到广泛采用和普遍使用的阶段。
[站外图片上传中...(image-de7eb4-1597646525583)]
如果我们将每个站点放在一个容器中而不是创建单独的用户呢?如果,PHP,Python或你的网站在容器中运行的并且挂载到容器所在的数据目录呢?
如果你是Docker或Kubernetes的用户,这看似微不足道,但重要的是要强调在短时间内已经发展了多少技术。早在2009-2010年,安装点数就达到了1万个,这意味着服务器重新启动花费了20至30分钟。
除了技术,这如何解决我们的SSH问题?好吧,你会看到,使用这种方法,站点是由容器而不是Linux系统用户分隔的。从主机操作系统的角度来看,所有站点都以同一用户身份运行,因此所有文件将归同一用户所有。
这使我们能够为每个用户创建一个特殊的容器。这个特殊的容器仅安装有问题的,用户有权访问的站点。用户打开SSH会话时,他们应该进入该容器,而不能访问其他容器。由于实际的运行时环境在其他容器中运行,因此用户也不会意外终止正在运行的Web服务器。干净整洁。
破解SSH守护程序
在过去6到7年的时间里,上述方法仅仅是一种理论。虽然我在2011年编写了使用 OpenVZ容器(或称为虚拟环境)的概念,后来又实现了该概念,但SSH访问仍然是一个困难的话题。
Python是编写SSH服务器的主要语言,SSH服务器将根据用户输入的用户名来代理连接,但该项目从未成功。如果放在今天我使用Python用于此目的,我将研究paramiko之类的库。
输入:Apache Mina
停顿了几年之后我在2017年又开始涉足网络托管业务,并决心解决这个问题。幸运的是我碰到了一些出色的Java开发人员,这些开发人员促进了我对Apache Mina的了解。
Apache Mina提供了使用纯Java环境创建SSH(和FTP)服务器的功能。Mina SSHD允许定义处理程序和用户认证的钩子。这样做不需要操作系统级别的用户。整个连接处理可以完全用Java完成。
这为我提供了一个机会:如果我可以将SSH服务器与 Docker API结合使用,那么我可以将通过SSH连接获得的所有信息直接发送给Docker API,反之亦然。实际上,我可以绕过常规的SSH守护程序。
令我惊讶的是它的工作原理。实现PasswordAuthenticator
和PublickeyAuthenticator
允许我为用户提供自定义身份验证,而编写DockerizedCommand
实现Command
接口的类使我可以启动Docker容器。 ContainerAttach操作允许我获取任何通过SSH通道来的数据,反之亦然。
但是,Java实现并非没有问题。当时存在的Docker库不完整,并且使用了与Apache Mina不同的异步IO模型。当用户启动SFTP会话下载数据时,我的SSH服务器将尽快从Docker API中提取数据并将其推送到SSH通道中。然后它将静静等待用户从内存中完成数据下载。
在极少数情况下,用户会耗尽服务器上的4 GB RAM,从而导致崩溃。当然,添加更多的RAM很有帮助,但是这绝对不是理想的解决方案。
用Go重写
当我最近开始学习Go时,我发现扩展标准库包含一个 功能齐全的SSH库。这为我提供了一个完美的学习项目:在Go中重新实现SSH服务器。
注意:你可以从GitHub中获取本文中的代码。请注意,它强调学习。如果你希望看到更适合生产的版本,请查看我的ContainerSSH项目。
首先,我们从一个简单易用的TCP服务器开始。
func main() {
listener, err := net.Listen(
"tcp",
"0.0.0.0:2222",
);
if err != nil {
log.Fatalf(
"Failed to listen on port 2222 (%s)",
err,
)
}
log.Printf("Listening on 0.0.0.0:2222")
}
容易吧?现在,该Listen
仅打开一个监听套接字,但不接受连接。这就是Accept
调用的目的。Accept
调用将阻塞,直到有新的连接进入。
func main() {
//...
for {
tcpConn, err := listener.Accept()
if err != nil {
log.Printf(
"Failed to accept (%s)",
err,
)
//Continue with the next loop
continue
}
}
}
建立SSH连接
到目前为止,我们已经可以正常使用TCP连接,但是没有进行SSH协议解码。这就是接下来要做的事情:初始化SSH连接。在此之前,我们需要构建一个SSH配置。首先让我们添加一个密码验证方法:
func main() {
//...
sshConfig := &ssh.ServerConfig{}
// region SSH authentication
sshConfig.PasswordCallback = func(
conn ssh.ConnMetadata,
password []byte,
) (
*ssh.Permissions,
error,
) {
if conn.User() == "foo" &&
string(password) == "bar" {
return &ssh.Permissions{}, nil
} else {
return nil, fmt.Errorf(
"authentication failed",
)
}
}
//endregion
for {
//The previously written Accept code here
}
}
在PasswordCallback
和PublicKeyCallback
允许使用数据库中的密码和公钥认证。接下来,我们必须创建一个主机密钥。在Linux系统上,可以使用ssh-keygen -t rsa
。密钥的加载过程如下所示:
func main() {
//...
sshConfig := &ssh.ServerConfig{}
//...
// region Host key
hostKeyData, err := ioutil.ReadFile(
"ssh_host_rsa_key",
)
if err != nil {
log.Fatalf(
"failed to load host key (%s)",
err,
)
}
signer, err := ssh.ParsePrivateKey(
hostKeyData,
)
if err != nil {
log.Fatalf(
"failed to parse host key (%s)",
err,
)
}
sshConfig.AddHostKey(signer)
// endregion
for {
//The previously written Accept code here
}
}
现在我们可以接受SSH连接了:
func main() {
//...
for {
//...
sshConn, chans, reqs, err := ssh.NewServerConn(
tcpConn,
sshConfig,
)
if err != nil {
log.Printf(
"handshake failed (%s)",
err,
)
continue
}
}
}
这将执行SSH握手并建立安全连接。它将返回很多东西:

-
sshConn
是实际的SSH连接。 -
chans
是一个Go通道,引入了新的SSH通道。一个SSH连接可以具有多个SSH通道,在同一SSH连接中可以处理不同种类的并行数据传输。 -
reqs
是一个处理请求进入的Go通道。请求是客户端请求更改某些内容的方法。 -
err
当然是错误(如果发生)。
Go channels: Go具有一个非常高效的称为goroutines的并发编程模型。Go通道是在这些goroutine之间发送数据的一种方式。你可以将它们想象为应用程序内消息队列。
至此,我们已经建立了SSH连接,我们需要处理请求。首先,我们将拒绝所有全局请求。全局请求将用于请求例如我们不支持的端口转发。
func main() {
//...
for {
//...
//Reject all global requests.
//Run this in a goroutine so it
//doesn't block.
go ssh.DiscardRequests(reqs)
}
}
接下来,我们需要处理传入的通道。等待通道将在goroutine中处理,因此我们不会阻塞主程序的执行。当通道进入时,我们将创建另一个goroutine来处理该特定通道。
func main() {
//...
for {
//...
//Reject all global requests.
//Run this in a goroutine so it
//doesn't block.
go handleChannels(sshConn, chans)
}
}
func handleChannels(
conn *ssh.ServerConn,
chans <-chan ssh.NewChannel,
) {
for newChannel := range chans {
go handleChannel(conn, newChannel)
}
}
func handleChannel(conn *ssh.ServerConn, newChannel ssh.NewChannel) {
//Handle new channel here
}
处理SSH通道
在handleChannel
函数中,我们有两个选择:我们接受通道还是拒绝通道。首先,我们的SSH服务器仅支持session
通道类型,因此我们拒绝其他所有内容:
//...
func handleChannel(
conn *ssh.ServerConn,
newChannel ssh.NewChannel,
) {
if t := newChannel.ChannelType();
t != "session" {
_ = newChannel.Reject(
ssh.UnknownChannelType,
fmt.Sprintf(
"unknown channel type: %s",
t,
),
)
return
}
}
接下来,我们需要创建与Docker引擎的连接。我们将使用以下github.com/docker/docker/client
执行此操作:
//...
func handleChannel(
conn *ssh.ServerConn,
newChannel ssh.NewChannel,
) {
//...
docker, err := client.NewClient(
"tcp://127.0.0.1:2375",
"",
nil,
make(map[string]string),
)
if err != nil {
_ = newChannel.Reject(
ssh.ConnectionFailed,
fmt.Sprintf(
"error contacting backend (%s)",
err,
),
)
return
}
}
到目前为止,一切都很好。现在,我们需要定义几个变量,稍后将使用它们:
//...
type channelProperties struct {
// Allocate pseudo-terminal for
// interactive sessions.
pty bool
// Store the container ID
// once it is started.
containerId string
// Environment variables passed
// from the SSH session.
env map[string]string
// Horizontal screen size
cols uint
// Vertical screen size
rows uint
// Context required by the Docker client.
ctx context.Context
// Docker client
docker *client.Client
}
func handleChannel(
conn *ssh.ServerConn,
newChannel ssh.NewChannel,
) {
//...
channelProps := &channelProperties{
pty: false,
containerId: "",
env: map[string]string{},
cols: 80,
rows: 25,
ctx: context.Background(),
docker: docker,
}
}
最后,让我们接受通道。如果通道接受失败,我们将关闭Docker连接并从通道处理中返回。
//...
func handleChannel(
conn *ssh.ServerConn,
newChannel ssh.NewChannel,
) {
//...
connection, requests, err :=
newChannel.Accept()
if err != nil {
log.Printf(
"could not accept channel (%s)",
err,
)
err := docker.Close()
if err != nil {
log.Printf(
"error while closing (%s)",
err,
)
}
return
}
}
特定于渠道的请求
建立通道后,客户端现在可以发送数据和特定于通道的请求。特定于渠道的请求可以是任何数量,包括自定义的事物。我们将讨论以下内容:
env
设置环境变量。
pty-req
为交互式会话分配伪终端。(你需要使用它来移动光标。)
window-change
更改窗口大小。
shell
执行默认的shell。
由于复杂性,我们将不介绍以下请求类型。你可以查看 ContainerSSH的源代码获取详细信息。
exec
执行自定义程序。
subsystem
启动子系统,例如SFTP。
signal
向进程发送信号。
因此,让我们来实现我们的通道处理程序:
//...
func handleChannel(
conn *ssh.ServerConn,
newChannel ssh.NewChannel,
) {
//...
removeContainer := func() {
if channelProps.containerId != "" {
//Remove container
removeOptions :=
types.ContainerRemoveOptions{
Force: true,
}
err := docker.ContainerRemove(
channelProps.ctx,
channelProps.containerId,
removeOptions,
)
if err != nil {
log.Printf(
"error while removing (%s)",
err,
)
}
channelProps.containerId = ""
}
}
closeConnections := func() {
removeContainer()
//Close Docker connection
err = docker.Close()
if err != nil {
log.Printf(
"error while closing Docker (%s)",
err,
)
}
//Close SSH connection
err := conn.Close()
if err != nil {
log.Printf(
"error while closing SSH (%s)",
err,
)
}
}
go func() {
for req := range requests {
reply := func(
success bool,
message []byte,
) {
if req.WantReply {
err := req.Reply(
success,
message,
)
if err != nil {
closeConnections()
}
}
}
handleRequest(
channel,
req,
reply,
closeConnections,
removeContainer,
channelProps
)
}
}()
}
我们基本上是在创建另一个 goroutine来处理通道请求,并且处理将在名为handleRequest
的单独函数中完成。如果需要,请求处理程序可以选择答复请求。我们还定义了一个称为closeConnections
的函数,如果出现问题或需要自然关闭连接时将调用该函数。
//TODO
实现请求处理程序
作为难题的最后一部分,让我们实现handleRequest
功能。首先让我们实现一个拒绝所有请求的默认类型:
//...
type envRequestMsg struct {
Name string
Value string
}
func handleRequest(
channel ssh.Channel,
req *ssh.Request,
reply func(success bool, message []byte),
closeConnections func(),
removeContainer func(),
channelProps * channelProperties,
) {
switch req.Type {
default:
reply(
false,
[]byte(fmt.Sprintf(
"unsupported request type (%s)",
req.Type,
)),
)
}
}
非常简单,让我们继续进行env
请求类型。这个用来设置环境变量:
//...
type envRequestMsg struct {
Name string
Value string
}
func handleRequest(
channel ssh.Channel,
req *ssh.Request,
reply func(success bool, message []byte),
closeConnections func(),
removeContainer func(),
channelProps * channelProperties,
) {
switch req.Type {
case "env":
if channelProps.containerId != "" {
reply(
false,
[]byte(fmt.Sprintf(
"cannot set env variables",
),
))
return
}
request := envRequestMsg{}
err := ssh.Unmarshal(req.Payload, request)
if err != nil {
reply(
false,
[]byte(fmt.Sprintf(
"invalid payload (%s)",
err,
)),
)
return
}
channelProps.env[request.Name] =
request.Value
default:
//...
}
}
如你所见,我们声明了一个envRequestMsg
结构。该结构是Payload
请求部分的格式,将使用ssh.Unmarshal()
函数调用进行解码。收到的环境变量将存储在env
变量中。
既然这很容易,让我们实现pty-req
用于处理交互式终端的类型:
//...
func handleRequest(
channel ssh.Channel,
req *ssh.Request,
reply func(success bool, message []byte),
closeConnections func(),
removeContainer func(),
channelProps * channelProperties,
) {
switch req.Type {
case "env":
//...
case "pty-req":
if channelProps.containerId != "" {
reply(
false,
[]byte(fmt.Sprintf(
"cannot set pty after shell",
)),
)
return
}
channelProps.pty = true
default:
//...
}
}
更简单的是,我们只是在收到PTY请求时设置一个布尔值。在深入研究丑陋的比特符之前,让我们快速实现window-change
处理程序:
type windowChangeRequestMsg struct {
Columns uint32
Rows uint32
Width uint32
Height uint32
}
func handleRequest(
channel ssh.Channel,
req *ssh.Request,
reply func(success bool, message []byte),
closeConnections func(),
removeContainer func(),
channelProps * channelProperties,
) {
switch req.Type {
case "env":
//...
case "pty-req":
//...
case "window-change":
request := windowChangeRequestMsg{}
err := ssh.Unmarshal(req.Payload, request)
if err != nil {
reply(
false,
[]byte(fmt.Sprintf(
"invalid payload (%s)",
err,
)),
)
return
}
channelProps.cols = uint(request.Columns)
channelProps.rows = uint(request.Rows)
if channelProps.containerId != "" {
err = channelProps.
docker.
ContainerResize(
channelProps.ctx,
channelProps.containerId,
types.ResizeOptions{
Height: channelProps.rows,
Width: channelProps.cols,
},
)
if err != nil {
reply(
false,
[]byte(fmt.Sprintf(
"failed to set wnd (%s)",
err,
)),
)
return
}
}
default:
//...
}
}
如你所见,我们已经开始与Docker API进行交互。当客户端(例如PuTTY)窗口大小更改时,我们还将新尺寸发送到容器。在midnightcommander类的软件以PTY模式运行时,这很有用, 因为它需要正确的尺寸才能缩放到整个窗口。
启动容器
在让我们的全新SSH服务器试用之前,我们还有最后一个任务:实现shell
请求。该请求将启动Docker容器并将该容器连接到SSH输入/输出。这将使我们能够实际使用SSH。
作为启动容器的第一步,我们将需要提取要运行的映像:
type windowChangeRequestMsg struct {
Columns uint32
Rows uint32
Width uint32
Height uint32
}
func handleRequest(
channel ssh.Channel,
req *ssh.Request,
reply func(success bool, message []byte),
closeConnections func(),
removeContainer func(),
channelProps * channelProperties,
) {
switch req.Type {
case "env":
//...
case "pty-req":
//...
case "window-change":
//...
case "shell":
if channelProps.containerId != "" {
reply(
false,
[]byte(fmt.Sprintf(
"cannot launch a second shell"
)),
)
break
}
pullReader, err := channelProps.
docker.
ImagePull(
channelProps.ctx,
"docker.io/library/busybox",
types.ImagePullOptions{},
)
if err != nil {
reply(
false,
[]byte(fmt.Sprintf(
"could not pull busybox (%s)",
err,
)),
)
return
}
_, err = ioutil.ReadAll(pullReader)
if err != nil {
reply(
false,
[]byte(fmt.Sprintf(
"could not pull busybox (%s)",
err,
)),
)
return
}
err = pullReader.Close()
if err != nil {
reply(
false,
[]byte(fmt.Sprintf(
"could not pull busybox (%s)",
err,
)),
)
return
}
default:
//...
}
}
现在我们可以确保预期的目标镜像在本地可用,并且我们可以创建容器:
func handleRequest(
channel ssh.Channel,
req *ssh.Request,
reply func(success bool, message []byte),
closeConnections func(),
removeContainer func(),
channelProps * channelProperties,
) {
switch req.Type {
//...
case "shell":
//...
var env []string
for key, value := range channelProps.env {
env = append(
env,
fmt.Sprintf(
"%s=%s",
key,
value,
),
)
}
body, err := channelProps.
docker.
ContainerCreate(
channelProps.ctx,
&container.Config{
Image: "busybox",
AttachStdout: true,
AttachStderr: true,
AttachStdin: true,
Tty: channelProps.pty,
StdinOnce: true,
OpenStdin: true,
Env: env,
},
&container.HostConfig{},
&network.NetworkingConfig{},
"",
)
if err != nil {
reply(
false,
[]byte(fmt.Sprintf(
"failed to launch (%s)",
err,
)),
)
return
}
channelProps.containerId = body.ID
default:
//...
}
}
容器已创建,因此让我们在开始之前准备附件:
func handleRequest(
channel ssh.Channel,
req *ssh.Request,
reply func(success bool, message []byte),
closeConnections func(),
removeContainer func(),
channelProps * channelProperties,
) {
switch req.Type {
//...
case "shell":
//...
attachResult, err := channelProps.
docker.
ContainerAttach(
channelProps.ctx,
channelProps.containerId,
types.ContainerAttachOptions{
Logs: true,
Stdin: true,
Stderr: true,
Stdout: true,
Stream: true,
},
)
if err != nil {
removeContainer()
reply(
false,
[]byte(fmt.Sprintf(
"failed to attach (%s)",
err,
)),
)
return
}
default:
//...
}
}
完成启动后,只需一个简单的API调用即可:
func handleRequest(
channel ssh.Channel,
req *ssh.Request,
reply func(success bool, message []byte),
closeConnections func(),
removeContainer func(),
channelProps * channelProperties,
) {
switch req.Type {
//...
case "shell":
//...
err = channelProps.docker.ContainerStart(
channelProps.ctx,
channelProps.containerId,
types.ContainerStartOptions{},
)
if err != nil {
removeContainer()
reply(
false,
[]byte(fmt.Sprintf(
"failed to launch (%s)",
err,
)),
)
return
}
default:
//...
}
}
在开始与容器之间来回传输数据之前,我们必须根据之前的window-change
请求设置窗口大小 :
func handleRequest(
channel ssh.Channel,
req *ssh.Request,
reply func(success bool, message []byte),
closeConnections func(),
removeContainer func(),
channelProps * channelProperties,
) {
switch req.Type {
//...
case "shell":
//...
err = channelProps.
docker.
ContainerResize(
channelProps.ctx,
channelProps.containerId,
types.ResizeOptions{
Height: channelProps.rows,
Width: channelProps.cols,
},
)
if err != nil {
removeContainer()
reply(
false,
[]byte(fmt.Sprintf(
"failed to resize (%s)",
err,
)),
)
return
}
default:
//...
}
}
就是这样!容器正在运行并已连接,窗口具有正确的大小,设置了环境变量。
func handleRequest(
channel ssh.Channel,
req *ssh.Request,
reply func(success bool, message []byte),
closeConnections func(),
removeContainer func(),
channelProps * channelProperties,
) {
switch req.Type {
//...
case "shell":
//...
var once sync.Once
if channelProps.pty {
go func() {
_, _ = io.Copy(
channel,
attachResult.Reader,
)
once.Do(closeConnections)
}()
} else {
go func() {
//Demultiplex Docker stream
//into stdout/stderr
_, _ = stdcopy.StdCopy(
channel,
channel.Stderr(),
attachResult.Reader,
)
once.Do(closeConnections)
}()
}
go func() {
_, _ = io.Copy(
attachResult.Conn,
channel,
)
once.Do(closeConnections)
}()
default:
//...
}
}
这个需要一点解释。如果容器以交互方式运行,则来自应用程序的所有内容都会以二进制形式通过stdout传递。但是,如果应用程序非交互运行,则SSH通道希望数据通过stdout和stderr的两个独立流来传递。另一方面,Docker仍将使用多路复用的返回格式,因此我们需要使用stdcopy.StdCopy
多路复用将流复用为两个单独的流。
最后,当其中一个流结束时,我们使用先前定义的closeConnection
函数关闭所有内容。
测试
全部关键代码大约300行,因此现在是时候使用我们全新的SSH服务器了。在第一个终端上:
$ go run main.go
2020/06/09 15:50:31 Listening on 0.0.0.0:2222
在第二个:
$ ssh localhost -l foo -p 2222
foo@localhost's password: <--- enter "bar" here
/ #
我们有一个正在运行的SSH服务器!
如开头所述,你可以从GitHub上获取完整的代码,如果你想使用exec
,signal
和SFTP以及更多结构化的代码库进行更完整的实现,请查看我的ContainerSSH项目。
适配Kubernetes
到目前为止,我们仅讨论了Docker,但是Kubernetes无疑是容器协调竞赛的赢家。值得庆幸的是,Kubernetes API还具有类似于Docker工作原理的附加功能。这里我将把它留给玩Kubernetes SDK的玩家,期待你可以弄清楚如何在你的SSH服务器中实现它。
可用于生产环境吗?
毫无疑问,在谈到SSH安全性时我们会想到Go中的SSH实施足够安全吗?便于维护吗?
事实是,我不知道。当我尝试设置 Mozilla建议的密码套件时, 我立即遇到了崩溃。我现在已经提交了针对该问题的补丁程序,最终该崩溃并未导致安全问题,但是缺少基本配置检查的事实也令人担忧。
基础坚实, 支持的密码,密钥交换算法和MAC列表 似乎足以提供安全的连接,但我的直觉是该库需要成熟一点才能被考虑用于生产。
用例
创建一个可以启动容器的自定义SSH服务器远远超出了简单的Web托管。想象一所学校,学生需要进入临时环境,或Linux /安全教育环境。这些按需激发工作负载的情况基本上就是云和Kubernetes的目的,但是没有简单的方法可以将SSH插入其中。
网友评论