Go实现SSH-Server服务端(1)

Admin 2022-05-07 17:18:35 GoLang

1、前言概述

Go有一个SSH库(golang.org/x/crypto/ssh)提供了丰富的接口,我们将基于此包实现SSH-Server服务端(SSHD)(也可实现SSH Client、SSH Proxy等),基于此包可实现SSH体系的扩展等。

2、SSH架构

ssh.png

  • TCP传输层:建立TCP连接,后进行SSH协议处理

  • Handshake:SSH协议的数据传输(主要是提供数据加密传输)

  • Authentication:用户认证处理

  • Channel:SSH协议的连接建立层,主要是多个加密隧道分层逻辑孙道,可复用通道,常见的有session、x11、forwarded-tcpip、direct-tcpip

  • Request:用于接收创建的Channel请求

3、开发流程图

ssh-flow.png

4、流程说明

  • 配置认证回调及其他配置(注意:NoClientAuth: true,无认证连接建议关闭)

  • SSH交互秘钥(私钥)双方交换验证(Diffie-hellman)

  • 监听TCP服务(ssh.NewServerConn 实现TCP转SSH连接)

  • 建立SSH隧道(SSH链接层)一个连接可包含channel(handleChannel针对每个channel都单独处理)

  • 处理连接上的每个channel(SSH协议交互的核心逻辑),在处理channel之前, 需要先获取 channel 的类型, 因不同类型的 channel 里面是不同类型的数据,对应不同的处理逻辑。

Channel 的类型如下:

Channel type                  Reference
------------                  ---------
session                       [SSH-CONNECT]
x11                           [SSH-CONNECT]
forwarded-tcpip               [SSH-CONNECT]
direct-tcpip                  [SSH-CONNECT]

5、数据交互

交互式 tty 是打开一个终端 tty,然后在 tty 上通过键盘输入命令执行,那么数据交互的过程就是打通 tty 和 bash 的通道。

首先,使用 exec.Command 打开一个bash, 然后将 bash 与 SSH Channel 对接, 从而实现和 bash 的远程交互。

注意:

  1. 这里可以使用定制化 tty(比如 git 也可通过 ssh 连接来交互)。如gliderlabs的 sshd 库代码,对外提供了 pty 接口,该库使用 https://github.com/kr/pty 包来实现 sshd 的 tty 交互功能。

  2. 这里如果直接将 bash 的输入和输出直接对接 terminal这是错误的操作,因为 bash 没有运行在 tty 中,这里需要一个模拟 tty 来运行 bash。

tty 和 bash 的关系如下(便于理解):

tty-bash.png

最后需将 bash 的管道(输入和输出)和 Connection 的管道对接,即完成了 SSHD 的 tty 实现逻辑。

6、指令交互

主要有如下几种Request:

  • shell/exec/subsystem:

    • shell:启动的一个程序或者 shell

    • exec:启动的是用户的默认 shell

    • subsystem:启动一个子程序来执行 connection 里面的命令(如 sftp)

  • pty-req: 准备一个 pty 等待输入

  • window-change: 监听 tty 窗口改变事件,及时更新 tty size

7、完整代码

package main

import (
    "fmt"
    "golang.org/x/crypto/ssh"
    "github.com/creack/pty"
    "io/ioutil"
    "net"
    "log"
    "sync"
    "io"
    "os/exec"
    "encoding/binary"
    "syscall"
    "unsafe"
)

func privateDiffie() (b ssh.Signer, err error) {
    private, err := ioutil.ReadFile("~/.ssh/id_rsa")
    if err != nil {
        return
    }
    b, err = ssh.ParsePrivateKey(private)
    return
}

// 开启goroutine, 处理连接的Channel
func handleChannels(chans <-chan ssh.NewChannel) {
    for newChannel := range chans {
        go handleChannel(newChannel)
    }
}

// parseDims extracts two uint32s from the provided buffer.
func parseDims(b []byte) (uint32, uint32) {
    w := binary.BigEndian.Uint32(b)
    h := binary.BigEndian.Uint32(b[4:])
    return w, h
}

// Winsize stores the Height and Width of a terminal.
type Winsize struct {
    Height uint16
    Width  uint16
    x      uint16 // unused
    y      uint16 // unused
}

// SetWinsize sets the size of the given pty.
func SetWinsize(fd uintptr, w, h uint32) {
    log.Printf("window resize %dx%d", w, h)
    ws := &Winsize{Width: uint16(w), Height: uint16(h)}
    syscall.Syscall(syscall.SYS_IOCTL, fd, uintptr(syscall.TIOCSWINSZ), uintptr(unsafe.Pointer(ws)))
}

func handleChannel(ch ssh.NewChannel)  {
    // 仅处理session类型的channel(交互式tty服务端)
    if t := ch.ChannelType(); t != "session" {
        ch.Reject(ssh.UnknownChannelType, fmt.Sprintf("unknown channel type: %s", t))
        return
    }
    // 返回两个队列,connection用于数据交换,requests用户控制指令交互
    connection, requests, err := ch.Accept()
    if err != nil {
        log.Printf("Could not accept channel (%s)", err.Error())
        return
    }

    // 为session启动一个bash
    bash := exec.Command("bash")
    // 关闭连接和session
    close := func() {
        connection.Close()
        _, err := bash.Process.Wait()
        if err != nil {
            log.Printf("Failed to exit bash (%s)", err.Error())
        }
        log.Println("Session closed")
    }

    // 为channel分配一个terminal
    log.Print("Creating pty...")
    tty, err := pty.Start(bash)
    if err != nil {
        log.Printf("Could not start pty (%s)", err)
        close()
        return
    }

    // 管道session到bash和visa-versa
    // 使用 sync.Once 确保close只调用一次
    var once sync.Once
    go func() {
        io.Copy(connection, tty)
        once.Do(close)
    }()
    go func() {
        io.Copy(tty, connection)
        once.Do(close)
    }()

    // session out-of-band请求有"shell"、"pty-req"、"env"等几种
    go func() {
        for req := range requests {
            switch req.Type {
            case "shell":
                if len(req.Payload) == 0 {
                    req.Reply(true, nil)
                }
            case "pty-req":
                termLen := req.Payload[3]
                w, h := parseDims(req.Payload[termLen+4:])
                SetWinsize(tty.Fd(), w, h)
                req.Reply(true, nil)
            case "window-change":
                w, h := parseDims(req.Payload)
                SetWinsize(tty.Fd(), w, h)
            }
        }
    }()
}

func main()  {
    config := &ssh.ServerConfig{
        // 密码验证回调函数
        PasswordCallback: func(c ssh.ConnMetadata, pass []byte) (*ssh.Permissions, error) {
            if c.User() == "demo" && string(pass) == "123456" {
                return nil, nil
            }
            return nil, fmt.Errorf("password rejected for %q", c.User())
        },
        // NoClientAuth: true, // 客户端不验证,即任何客户端都可以连接
        // ServerVersion: "SSH-2.0-OWN-SERVER", // "SSH-2.0-",SSH版本
    }
    // 秘钥用于SSH交互双方进行 Diffie-hellman 秘钥交换验证
    if b, err := privateDiffie(); err != nil {
        log.Printf("private diffie host key error: %s",err.Error())
    }else{
        config.AddHostKey(b)
    }

    // 监听地址和端口
    listener, err := net.Listen("tcp", "0.0.0.0:19022")
    if err != nil {
        log.Fatalf("Failed to listen on 1022 (%s)", err.Error())
    }
    log.Println("listen to 0.0.0.0:19022")

    // 接受所有连接
    for {
        conn, err := listener.Accept()
        if err != nil {
            log.Printf("Failed to accept incoming connection (%s)", err)
            continue
        }
        // 使用前,必须传入连接进行握手 net.Conn
        sshConn, chans, reqs, err := ssh.NewServerConn(conn, config)
        if err != nil {
            log.Printf("Failed to handshake (%s)", err)
            continue
        }
        log.Printf("New SSH connection from %s (%s)", sshConn.RemoteAddr(), sshConn.ClientVersion())
        go ssh.DiscardRequests(reqs)
        // 接收所有channels
        go handleChannels(chans)
    }
}

8、测试登录

# 输入密码 123456 登录
ssh -p19022 demo@127.0.0.1
The authenticity of host '[127.0.0.1]:19022 ([127.0.0.1]:19022)' can't be established.
RSA key fingerprint is SHA256:kcIoiszRlDALxK2AQyWLNVDYVWWDy89mDNPXqHRX8LQ.
Are you sure you want to continue connecting (yes/no)? yes
Warning: Permanently added '[127.0.0.1]:19022' (RSA) to the list of known hosts.
demo@127.0.0.1's password:

9、开源项目

利用(golang.org/x/crypto/ssh)此包也可以完成一些其他有趣的项目,如:

https://github.com/zachlatta/sshtron    实现了一个在线多人贪吃蛇

https://github.com/shazow/ssh-chat    基于ssh实现一个聊天工具

https://github.com/gliderlabs/ssh    一个通用的sshd框架


参考

https://blog.gopheracademy.com/advent-2015/ssh-server-in-go/

https://scalingo.com/blog/writing-a-replacement-to-openssh-using-go-12.html

https://scalingo.com/blog/writing-a-replacement-to-openssh-using-go-22.html

https://github.com/gliderlabs/ssh

https://github.com/Scalingo/go-ssh-examples

https://datatracker.ietf.org/doc/html/rfc4250#section-4.9.1

相关文章
最新推荐