0x00 基础
本文将描述如何实现一个具备安全认证的 WebConsole,基于 Golang-SSH 库 实现。WebConsole 的核心实现是打通了 WebSocket+
SSH 的输入输出流,使得用户直接使用浏览器就可以运行 SSH 终端,非常适合于轻便运维的场景。WebSocket 基于 TCP 传输协议,并复用 HTTP 的握手通道,关于 WebSocket 和 Golang 的开发可以参见:How to Use Websockets in Golang: Best Tools and Step-by-Step Guide。
0x01 WebConsole 数据流
一个具备远程登陆的功能的 Web-Console,其数据流向大概如下:
User<--->
Browser<--->
WebSocket<--->
SSH<--->
(TTY)RemoteServer
+---------+ http +--------+ ssh +-----------+
| browser | <==========> | webssh | <=======> | ssh server|
+---------+ websocket +--------+ ssh +-----------+
数据流
中间的 Proxy 代理层,负责将 websocket 流转换为 SSH 流(核心是输入和输出的转发):
0x02 实现
作为一个 SSH 远程登陆系统,认证是及其重要的一环,将上面的数据流扩展下,加入必要的身份及票据认证,如下图
组件
- CGI+WEB,采用开源的框架 Gin 实现
- Websocket
- SSH
- 认证采用临时(一次性)Token 兑换真实 Token(如 SSH 证书 / 秘钥 / 口令等)的方式,这种方式简单易理解,当然了也可以使用
OAuth2/OpenID
这种开放认证协议
基本实现流程
-
用户 A 申请某一台机器的登录权限,后台服务返回一个一次性 token 构造的 url 给用户,如
https://1.2.3.4/cgi-bin/webconsole/check?token=token1
(这里假设以 GET 方式请求) -
在 IOA 认证(为了获取合法用户的身份信息,或者
Oauth2
认证)的前提下,用户使用浏览器访问上述登录 url, 后台服务先校验用户cookies
及 HTTP 签名,然后再校验token1
是否合法(使用次数 + 有效时间) -
当前一步的认证通过后,后台服务返回 Websocket 的地址,再加上另一个一次性票据,如
token2
,如ws://1.2.3.4/cgi-bin/webconsole/login?token=token2
,改地址返回给用户端浏览器 -
当 Websocket 请求(上一步的Response)到达后端,服务校验票据
token2
,校验通过后,后台将 HTTP 请求升级为 WebSocket 协议, 后续的数据交换则遵照 WebSocket 协议(这里就得到一个和浏览器数据交换的连接通道) -
后台服务使用
token2
换取真实票据后, 与远端 Server 建立一个 SSH Channel。然后后台将终端的大小等信息通过 SSHChannel
请求远程主机创建PTY
, 请求启动默认Shell
-
后台服务通过 Socket 连接通道获取用户(键盘)输入, 再通过 SSH
Channel
将输入传给PTY
,PTY
将这些数据交给远程主机处理后按照前面指定的终端标准输出到 SSHChannel
中 -
后台服务从 SSH
Channel
中拿到按照终端大小的标准输出后又通过 Socket 连接将输出返回给浏览器,至此一个 Web Terminal 建立成功
0x03 一些代码细节
升级 TCP 连接为 SSH 连接
对 Tcp 连接进行升级,这是 Golang 中非常常见的做法:
tcpConn, err := listener.Accept()
if err != nil {
log.Printf("failed to accept incoming connection (%s)", err)
continue
}
// Before use, a handshake must be performed on the incoming net.Conn.
sshConn, chans, reqs, err := ssh.NewServerConn(tcpConn, sshServerConfig)
if err != nil {
log.Printf("failed to handshake (%s)", err)
continue
}
升级 HTTP 连接为 Web Socket
定义常量,web socket 升级器:
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true
},
}
websock_conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
if err != nil {
c.Error(err)
return
}
SSH 的层次结构 Client/Channel/Request
下图直观展示了 SSH 的架构:
- Client: 实现了 SSH 抽象的客户端
- Channel 和 Request:
这二者是 SSH 协议里面的链接层, 该层主要是将多个加密隧道分成逻辑通道,通道可以复用
常见通道类型有:session
、x11
、forwarded-tcpip
、direct-tcpip
。通道里面的 Requests
是用于接收创建 SSH Channel
的请求的,而 SSH Channel
就是里面的 Connection
, 数据的交互是基于 Connection
完成。
构建非交互式 SSH 客户端
现在看下如何创建一个非交互式的 SSH 客户端,作为 WebConsole 和用户的交互模块:
1、通过 ssh.Dial()
创建一个 SSH 客户端连接
sshclient,err = ssh.Dial("tcp", addr, clientConfig)
2、通过 SSH 客户端创建 SSH Channel, 并请求一个 pty 伪终端, 并开启用户的默认 Shell
channel, inRequests, err := sshclient.OpenChannel("session", nil)
if err != nil {
log.Println(err)
return nil
}
ok, err := channel.SendRequest("pty-req", true, ssh.Marshal(&req))
if !ok || err != nil {
log.Println(err)
return nil
}
ok, err = channel.SendRequest("shell", true, nil)
if !ok || err != nil {
log.Println(err)
return nil
}
Remote Server 与 Browser 实时数据交换
现在为止建立了两个 IO
通道,一个是 WebSocket
通道,另外一个是 SSH Channel
。由于需要双向转发数据,这里新建 2
个 groutine
:
groutine1
不停的从 WebSocket 通道里读取用户的输入, 并通过 SSH Channel 传给远程主机groutine2
负责将远程主机的数据(主要是终端屏显数据)传递给浏览器
1、groutine1
主要完成从 Websocket
中读取数据,通过 ssh 的 Channel
发送到目标服务器
go func() {
for {
// p 为用户输入
_, p, err := ws.ReadMessage()
if err != nil {
return
}
// 获取用户的输入,写入 Channel
_, err = this.channel.Write(p)
if err != nil {
return
}
}
}()
2、groutine2
主要完成从 SSH Channel
中读取数据,写入 Websocket
,这样用户在浏览器上可以看到操作回显了:
go func() {
br := bufio.NewReader(this.channel)
buf := []byte{}
t := time.NewTimer(time.Microsecond * 100)
defer t.Stop()
// 构建一个信道, 一端将数据远程主机的数据写入, 一段读取数据写入 ws
r := make(chan rune)
// 另起一个协程, 一个死循环不断的读取 SSH Channel 的数据, 并传给 r-chan 直到连接断开
go func() {
defer this.Client.Close()
defer this.Session.Close()
for {
x, size, err := br.ReadRune()
if err != nil {
log.Println(err)
ws.WriteMessage(1, []byte("\033[31m 已经关闭连接!\033[0m"))
ws.Close()
return
}
if size > 0 {
r <- x
}
}
}()
// 主循环
for {
select {
// 每隔 100 微秒, 只要 buf 的长度不为 0 就将数据写入 ws, 并重置时间和 buf
case <-t.C:
if len(buf) != 0 {
err := ws.WriteMessage(websocket.TextMessage, buf)
buf = []byte{}
if err != nil {
log.Println(err)
return
}
}
t.Reset(time.Microsecond * 100)
// 前面已经将 SSH Channel 里读取的数据写入创建的通道 r, 这里读取数据, 不断增加 buf 的长度, 在设定的 100 microsecond 后由上面判定长度是否返送数据
case d := <-r:
if d != utf8.RuneError {
p := make([]byte, utf8.RuneLen(d))
utf8.EncodeRune(p, d)
buf = append(buf, p...)
} else {
buf = append(buf, []byte("@")...)
}
}
}
}()
0x04 登录效果验证
直接在浏览器中输入已认证的 url
,成功通过 WebConsole
连上远端的 SSH 服务器,大功告成
0x05 总结
- 在整个系统中,最关键的点是怎样防止用户的身份被伪造,直观点,就是在第 2 步中,后台服务如何确定,当前的接口调用方就是用户 A。另外,如何解决共享权限的场景,假设 A 申请了某台机器的登录权限,假设 A 授权 B 也可以使用该票据登录,那么系统的认证如何完成呢?这个是很有趣的问题,待后面在工作中慢慢思考和实现吧。
- 此外,作为 SSH 连接代理的服务(本文中以
CGI
服务承担)的稳定性也很重要,因为 WebConsole 的所有流量都会经由 SSH 连接代理转发,TCP 连接也由代理维持,一旦代理故障,所有的 WebConsole 连接都会断开,所以可用性的设计也是非常重要的一环。 - 整个 Web 页面需要前置认证机制,比如接入 Github 的
Oauth
、Onelogin
等等
转载请注明出处,本文采用 CC4.0 协议授权