注意, 此文章转载自我的公司内网博客.

简介

最近在调研如何能够提供为用户提供容器的shell登录, 本篇博客整理了一下思路, 探讨了两种方式, 一种基于代理方式访问Kubernetes集群中的pod的功能; 另一种方案, 基于webshell但允许用户从本地登录. 并就这两种方案的安全性, 与功能使用情况进行分析.

用户需求

虽然我们属于PaaS平台, 开发者提供了代码和配置由我们部署即可, 但是出现以下问题时, 开发者可能会希望自己登录到自己的容器中运行某些指令:

  1. 开发过程中对环境的调试: 环境变量查看, 数据库试连接, 日志打印及代码运行情况验证
  2. 已经上线的服务: 开发者期望手动执行一些脚本, 使用真实的shell执行脚本更快捷方便

解决方案 - 增加Shell容器

目前的环境中, 应用上线会创建一个kubernetes的Deployment, Service, Ingress这样三个服务. 由Ingress转发流量至对应的Pod.

为了避免影响已经在运行的线上服务, shell登录功能不在线上容器中进行, 而是单独启动新的Pod提供shell服务.

由于Kubernetes集群使用的不是host网络, 而是在flannel一个分配的内部IP. 那么这个Pod的启动后, Kubernetes集群外部如何访问这个服务:

  1. 增加跳板机或是代理的服务, 对外提供出口, 再由代理服务器访问Kubernetes的内部资源
  2. 使用WebShell, 由我们的平台分配一个该服务对应的域名, 再增加鉴权提高安全性

下面我会就这两种方案分别介绍调研结果.

ssh代理使用 - ProxyCommand

此部分探讨的ssh为OpenSSH, 并且需要支持ProxyCommand参数.

Service代理TCP端口

既然对外提供了代理, 那么内部如何区分每个应用呢. 此处打算采用Service代理TCP端口的功能. 整个服务如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
---
apiVersion: v1
kind: Service
metadata:
name: app01-ssh
spec:
selector:
app: app01-ssh
ports:
- protocol: TCP
port: 22
targetPort: 22
name: proxied-tcp-22

针对不同的应用, 只要是在k8s中的pod, 我们就使用telnet appXX-ssh 22的形式来访问对应的服务.

Dockerfile以及Kubernetes配置文件见附录部分.

使用跳板机

跳板机的ssh config配置如下

1
2
3
4
Host deb
User root
HostName app1.ssh
ProxyCommand ssh -q -W %h:%p jumpserver
1
2
3
4
5
6
7
8
9
                          +---------------+
+-------->+ app3.ssh |
| +---------------+
+---------------+ +---------------+
|ssh jump server+-------->+ app2.ssh |
+---------------+ +---------------+
| +---------------+
+-------->+ app1.ssh |
+---------------+

使用代理 – HTTPProxy

这是我调研的一种方案:

OpenSSH的ProxyCommand的作用是使用子进程建立TCP连接, 而后利用管道与父进程通信, 在明白了这一点之后, 我们其实可以依靠HTTP建立连接后转而传输TCP数据包.

1
2
# 注意, 这里的nc需要使用netcat-openbsd(openbsd版本的nc, gnu的nc没有-X功能)
ssh -o "ProxyCommand=nc -X connect -x 127.0.0.1:8888 %h %p" [email protected]

其中127.0.0.1:8888是本机上开放的http代理, 较为简单的代理服务器可以参考HTTP 代理原理及实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var http = require('http');
var net = require('net');
var url = require('url');

function connect(cReq, cSock) {
var u = url.parse('http://' + cReq.url);

var pSock = net.connect(u.port, u.hostname, function() {
cSock.write('HTTP/1.1 200 Connection Established\r\n\r\n');
pSock.pipe(cSock);
}).on('error', function(e) {
cSock.end();
});

cSock.pipe(pSock);
}

http.createServer().on('connect', connect).listen(8888, '0.0.0.0');

具体的线上服务, 我个人倾向使用HTTPProxy. 有两点好处:

  1. Jump Server的建立需要维护一个OpenSSH server, http代理只需要维护比较小范围的代码
  2. http代理中, 可以方便的控制用户想可以到达的服务, 可以在程序中建立白名单, 安全性要比Jump Server来的高

WebShell - 网页与本地功能

基础的WebShell

WebShell部分的调研工作主要围绕gotty, 主要依靠gotty+tmux实现对机器访问的支持.

1
2
# 其功能是使用tmux新建一个window
gotty -w tmux new -A -s gotty

访问http://127.0.0.1:9000即可看到

gotty客户端

在gotty的README中, 有介绍如何从终端访问到GoTTY的服务. 推出了gotty-client

1
2
3
go get github.com/moul/gotty-client/cmd/gotty-client

gotty-client http://127.0.0.1:9000/
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                                                               ┌─────────────────┐
┌──────➤│ /bin/bash │
│ └─────────────────┘
┌──────────────┐ ┌──────────┐
  │ │ │ Gotty │
┌───────┐ ┌──➤│ Browser │───────┐ │ │
│ │ │ │ │ │ │ │
│ │ │ └──────────────┘ │ │ │ ┌─────────────────┐
│ Bob │───┤ websockets─➤│ │─➤│ emacs /var/www │
│ │ │ ╔═ ══ ══ ══ ══ ╗ │ │ │ └─────────────────┘
│ │ │ ║ ║ │ │ │
└───────┘ └──➤║ gotty-client ║───────┘ │ │
║ ║ │ │
╚═ ══ ══ ══ ══ ╝ └──────────┘
│ ┌─────────────────┐
└──────➤│ tmux attach │
└─────────────────┘

基本原理是复用了GoTTY的websocket, 不过我试用下来, 发现对tmux的支持实在太差.

对于tmux的重度用户来讲, 这个几乎是完全不可用的状态, 字符的打印太离谱了.

两种方案对比

有了以上两种可选方案, 一种是基于原生ssh, 另一种方案是类似于WebShell的形式. 下面我就两种方案的难易度, 安全性, 以及维护成本方面进行讨论.

功能以及实现难度讨论

  • 功能上: 两种方案均能实现用户连接到容器内部的要求.

    我平时用原生的ssh太多了, 个人期望能够利用OpenSSH提供的各种功能. WebSSH给人一种花里胡哨的感觉, 快捷键支持有问题, 一个Ctrl-W就意味着页面被关闭.

  • 实现难度上:

    1. 前者需要增加服务作为代理服务器, 另外需要做好私钥的管理
    2. 后者需要结合gotty等WebShell工具, 同样也需要做好权限控制, 本身也支持密码登录

维护与下线成本讨论

前者需要维护代理服务器, 以及本地连接时的ProxyCommand

后者可能需要维护gotty未来可能会有gotty-client

后者维护成本较高.

安全性问题讨论

  • 数据传输加密:

    1. 前者通过ssh本身进行加密
    2. 后者可以通过websocket over https, 利用https加密传输
  • 集群安全性保证(未登录状态):

    1. 前者由于提供了一个4层代理, 需要着重注意安全性, 否则可能将集群中不想暴露的服务端口暴露(下方提出解决方案)
    2. 后者的登录完全从网页中进行, 安全性与PaaS平台一致
  • 集群安全性保证(登录状态): 两套系统中用户操作可以记录, 但提供root权限后, 用户可能自行改掉 两套系统中用户均可以访问集群中的所有其他服务, 目前来看很难限制

关于四层代理服务的安全性, 建议通过以下方式保证(以http代理服务器为例):

  1. 此代理服务器仅允许内部网络访问
  2. 此代理服务器增加basic auth, 要求用户连接时给出用户名密码
  3. 此代理仅允许部分域名类似于app01-ssh这样的ssh服务的域名通过, 不允许访问除ssh域名之外的其他域名或ip

如果能做到以上3点, 代理服务器与WebShell的安全性基本一致.

总结

本文探讨了两种在外部连接Kubernetes的pods的方案: 一种基于ssh代理方式访问Kubernetes集群中的pod的功能; 另一种基于WebShell但允许用户从本地登录. 并就这两种方案的功能性, 实现难度与安全性进行分析.

从个人角度来看, 更倾向于使用代理方式, 毕竟原生的ssh有许多配套软件, 例如scp, sshfs, 也可以利用LocalForward这些配置.

已知问题与解决方案

在我测试远程服务器的连接过程中, 出现了如下的问题:

1
2
3
~ ❤  ssh -o "ProxyCommand=nc -X connect -x 10.202.37.229:8081 %h %p" [email protected]
Bad packet length 1349676916.
ssh_dispatch_run_fatal: Connection to UNKNOWN port 65535: message authentication code incorrect

在使用wireshark抓包之后看到, netcat将tcp包拆分, 导致远程服务器解析到的ssh数据包不完整, 无法应答.

鉴于出现以上问题, 我决定自己写一个简易的代理工具替换掉nc, 以下为该工具的简易版:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# -*- coding: utf-8 -*-

"""
使用方式, python3:

ssh -o "ProxyCommand=python3 tun.py 10.202.37.229:8081 %h %p" [email protected]
"""

import sys
import socket
from threading import Thread

import http.client as httplib
from urllib.parse import urlparse, urlencode
from urllib.request import urlopen, Request
from urllib.error import HTTPError


class ProxyHTTPConnection(httplib.HTTPConnection, object):
def __init__(self, *args, **kwargs):
super(ProxyHTTPConnection, self).__init__(*args, **kwargs)
self.stop = False

def connect(self):
httplib.HTTPConnection.connect(self)
# send proxy CONNECT request
self.send(b"CONNECT %s:%d HTTP/1.0\r\n\r\n" %
(self._real_host.encode('utf8'), self._real_port))
# expect a HTTP/1.0 200 Connection established
response = self.response_class(self.sock, method=self._method)
(version, code, message) = response._read_status()
if code != 200:
# proxy returned and error, abort connection, and raise exception
self.close()
raise socket.error(
"Proxy connection failed: %d %s" % (code, message.strip()))
self.interpreterloop(self.sock)

def interpreterloop(self, sock):
Thread(target=self.readloop, args=(sock, )).start()
self.writeloop(sock)

def readloop(self, sock):
while not self.stop:
try:
sock.send(sys.stdin.buffer.read(1))
except socket.error as e:
print(e)
return

def writeloop(self, sock):
while not self.stop:
try:
c = sock.recv(1)
except KeyboardInterrupt:
self.stop = True
return
if not c:
break
sys.stdout.buffer.write(c)
sys.stdout.flush()


if __name__ == '__main__':
proxy = sys.argv[1]
remote_host = sys.argv[2]
remote_port = sys.argv[3]

p = ProxyHTTPConnection(proxy)
p._real_host = remote_host
p._real_port = int(remote_port)
p.connect()

附录

Kubernetes中启用sshd服务

Dockerfile

1
2
3
4
5
6
7
8
9
10
11
12
# ssh-test
FROM python:3.7.4-stretch

RUN apt-get update && apt-get install -y openssh-server
RUN mkdir /var/run/sshd
RUN echo 'root:hello' | chpasswd
RUN echo 'PermitRootLogin yes' >> /etc/ssh/sshd_config

# RUN echo "export VISIBLE=now" >> /etc/profile

EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]
  • Kubernetes配置文件
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
---
apiVersion: apps/v1
kind: Deployment
metadata:
name: app01-ssh
spec:
replicas: 1
selector:
matchLabels:
app: app01-ssh
template:
metadata:
labels:
app: app01-ssh
spec:
containers:
- name: app01-ssh
image: test
imagePullPolicy: Never
ports:
- containerPort: 22
name: app01-ssh
---
apiVersion: v1
kind: Service
metadata:
name: app01-ssh
spec:
selector:
app: app01-ssh
ports:
- protocol: TCP
port: 22
targetPort: 22
name: proxied-tcp-22