先聊一下什么是SSH隧道转发吧,隧道转发也被称作是SSH 端口转发,是一种通过SSH协议将本地端口转发到远程主机的技术。它可以实现安全的网络连接,常用于穿越防火墙或访问内网服务。
SSH隧道转发的工作原理是,在SSH连接建立后,通过SSH协议将本地端口的流量加密后转发到远程主机的指定端口。这样,用户就可以通过本地端口访问远程主机的服务,而不必直接暴露远程主机的端口。最常见的应用场景是在本地连接云服务的数据库,或者访问公司内网的资源。一般云MySQL数据库都是不开放公网的,只能在内网中访问,为了在公网能访问这些资源,就需要使用SSH隧道转发。
在 Navicat 中连接MySQL 数据库时,可以通过SSH隧道转发来连接没有公网IP的数据库,连接配置如下图
为什么要用go实现一个SSH 隧道转发代理呢?主要是我们公司的很多redis实例都在云上,无法直接在公网中访问。通过SSH隧道转发,可以实现在公网中访问这些云上的redis实例。为什么不用现成的工具呢?
首先 redis-cli 工具不支持SSH隧道转发,要解决问题,有两种选择
- 选择一些GUI的redis客户端工具,但是这些工具往往需要额外的配置,使用起来不够方便。所以考虑自己写一个简单的命令行工具来实现这个功能。
- 直接使用SSH命令进行端口转发,然后再使用redis-cli连接本地端口。这种方式比较简单,但需要手动管理SSH连接
使用这两个方案还有一个问题,要对所有人公开SSH机器的账号密码,存在安全隐患。
所以自己实现一个简单的 支持 SSH 隧道转发的 redis-cli 工具是一个不错的选择,正好可以借这个机会学习一下相关的技术。这个工具要有以下功能:
- 支持通过SSH隧道连接到远程Redis实例
- 对使用者透明SSH连接的建立和断开
- 支持配置文件,或者直接把SSH相关的信息编译在代码中
大概的原理如图:
tun redis-cli 是要通过SSH隧道连接到远程Redis实例的工具。 SSH server 是我们用来建立SSH连接的中间服务器。 redis server 是我们要访问的远程Redis实例。
tun redis-cli 通过 SSH 先连接到 具有公网IP的 SSH server 上,然后再通过 SSH 隧道连接到 redis server。
下面开始撸代码,实现这个工具
SSH 隧道转发
先来实现最核心的功能,SSH 隧道转发。我们需要使用 Go 的 golang.org/x/crypto/ssh
包来实现 SSH 连接和端口转发。
1package main
2
3import (
4 "errors"
5 "fmt"
6 "golang.org/x/crypto/ssh"
7 "os"
8)
9
10type SSHConfig struct {
11 SSHHost string //SSH服务器地址
12 SSHUser string //SSH用户名
13 SSHPassword string //SSH密码
14 SSHKey string //秘钥文件路径
15}
16
17func GetSSHClient(config *SSHConfig) (*ssh.Client, error) {
18 if config.SSHHost == "" {
19 return nil, errors.New("SSHHost must be provided")
20 }
21 if config.SSHUser == "" {
22 return nil, errors.New("SSHUser must be provided")
23 }
24 if config.SSHKey == "" && config.SSHPassword == "" {
25 return nil, errors.New("SSHKey or SSHPassword must be provided")
26 }
27
28 sshConfig := &ssh.ClientConfig{
29 User: config.SSHUser,
30 HostKeyCallback: ssh.InsecureIgnoreHostKey(),
31 }
32
33 if config.SSHPassword != "" {
34 sshConfig.Auth = []ssh.AuthMethod{ssh.Password(config.SSHPassword)}
35 } else {
36 if config.SSHKey != "" {
37 keyContent, err := os.ReadFile(config.SSHKey)
38 if err != nil {
39 return nil, fmt.Errorf("failed to read SSH key file:%s: error:%w", config.SSHKey, err)
40 }
41 signer, err := ssh.ParsePrivateKey(keyContent)
42 if err != nil {
43 return nil, fmt.Errorf("failed to parse SSH key: %w", err)
44 }
45 sshConfig.Auth = append(sshConfig.Auth, ssh.PublicKeys(signer))
46 }
47 }
48
49 sshConfig.HostKeyCallback = ssh.InsecureIgnoreHostKey()
50
51 client, err := ssh.Dial("tcp", config.SSHHost, sshConfig)
52 if err != nil {
53 return nil, err
54 }
55 return client, nil
56}
上面的代码定义了一个 SSHConfig
结构体来存储 SSH 连接的配置信息,包括 SSH 服务器地址、用户名、密码和秘钥文件路径。GetSSHClient
函数根据这些配置信息创建并返回一个 SSH 客户端。
ssh有多种身份验证方式,包括密码验证和公钥验证。在本例中,我们支持这两种方式。用户可以通过设置 SSHPassword
或 SSHKey
字段来选择身份验证方式。如果同时设置了这两个字段,程序将优先使用密码验证。
现在已经创建好了一个SSH的客户端,可以用来进行后续的操作,建立 redis 连接。
1type RedisClient struct {
2 conn net.Conn
3 writeBuff *bytes.Buffer
4 read *bufio.Reader
5 outPut *bytes.Buffer
6}
7
8// GetRedisClient 创建一个 RedisClient 实例
9// address 是 Redis 服务器的地址, IP:port 格式
10func GetRedisClient(sshClient *ssh.Client, address string) (*RedisClient, error) {
11 if sshClient == nil {
12 return nil, errors.New("SSH client is nil")
13 }
14 remoteConn, err := sshClient.Dial("tcp", address)
15 if err != nil {
16 return nil, fmt.Errorf("failed to connect to Redis server at %s: %w", address, err)
17 }
18 redisCli := &RedisClient{
19 conn: remoteConn,
20 writeBuff: bytes.NewBuffer(make([]byte, 0, 1024)),
21 read: bufio.NewReaderSize(remoteConn, 4096),
22 outPut: bytes.NewBuffer(make([]byte, 0, 4096)),
23 }
24 return redisCli, nil
25}
通过SSH客户端的 Dial
方法,可以连接到内网的 Redis 服务器。GetRedisClient
函数接受一个 SSH 客户端和 Redis 服务器地址,返回一个 RedisClient
实例。RedisClient
结构体封装了与 Redis 服务器的连接,并提供了读写操作的缓冲区。
到这,基本实现了 SSH 隧道转发的功能,可以访问内网的redis服务了。但是还缺少redis协议解析功能。
redis 协议解析
redis 协议简称 RESP,是 Redis 使用的通信协议。RESP 协议简单高效,易于解析。
RESP协议现在有 RESP2
和 RESP3
两个版本。现在常用的还是 RESP2
。
RESP2
协议有五种数据类型:
- 简单字符串:以
+
开头,直到\r\n
结束。 - 错误:以
-
开头,直到\r\n
结束。 - 整数:以
:
开头,直到\r\n
结束。 - 数组:以
*
开头,后接数组长度,直到\r\n
结束。数组中的每个元素都是一个 RESP 类型。 - 二进制安全字符串:以
$
开头,后接字符串长度,直到\r\n
结束。
下面就自己实现一个简单的 RESP 协议解析器。
1func (r *RedisClient) Read() error {
2 b, err := r.read.ReadBytes('\n')
3 if err != nil {
4 return fmt.Errorf("failed to read from Redis connection: %w", err)
5 }
6 if len(b) <= 2 || b[len(b)-2] != '\r' || b[len(b)-1] != '\n' {
7 return errors.New("invalid Redis response format")
8 }
9 bf := b[0]
10 b = b[1 : len(b)-2] // Remove the trailing \r\n
11 switch bf {
12 case '+':
13 // Simple string reply
14 r.outPut.Write(b)
15 case '-':
16 // Error reply
17 r.outPut.WriteString("(error) ERR ")
18 r.outPut.Write(b)
19 case ':':
20 // Integer reply
21 r.outPut.WriteString("(integer) ")
22 r.outPut.Write(b)
23 case '$':
24 l, err := strconv.Atoi(string(b))
25 if err != nil {
26 return fmt.Errorf("failed to parse bulk string length: %w", err)
27 }
28 if l == -1 {
29 r.outPut.WriteString("(nil)")
30 return nil
31 }
32 rl := l + 2 // +2 for \r\n
33 bulkData := make([]byte, rl)
34 rl = 0
35 for rl < l {
36 n, err := r.read.Read(bulkData[rl:])
37 if err != nil {
38 return fmt.Errorf("failed to read bulk string data: %w", err)
39 }
40 rl += n
41 if rl >= l {
42 break
43 }
44 }
45 r.outPut.Write(bulkData[:l]) // Write only the bulk string data
46
47 case '*':
48 l, err := strconv.Atoi(string(b))
49 if err != nil {
50 return fmt.Errorf("failed to parse array length: %w", err)
51 }
52 if l == -1 {
53 r.outPut.WriteString("(nil)")
54 return nil
55 }
56 r.outPut.WriteString("(array)\n")
57 if l == 0 {
58 r.outPut.WriteString("[]")
59 return nil
60 }
61 for i := 0; i < l; i++ {
62 if err = r.Read(); err != nil {
63 return err
64 }
65 if i < l-1 {
66 r.outPut.WriteByte('\n')
67 }
68 }
69 default:
70 return fmt.Errorf("unknown Redis response type: %c", bf)
71 }
72 return nil
73}
74
75//把用户输入的命令写入 Redis
76func (r *RedisClient) Write(str string) error {
77 split := strings.Split(str, " ")
78 r.writeBuff.Reset()
79 for i, s := range split {
80 r.writeBuff.WriteString(s)
81 if i < len(split)-1 {
82 r.writeBuff.WriteByte(' ')
83 }
84 }
85 r.writeBuff.WriteString("\r\n")
86
87 _, err := r.conn.Write(r.writeBuff.Bytes())
88 return err
89}
90
91//输出 Redis 响应结果
92func (r *RedisClient) Print() {
93 fmt.Printf("%s\n", r.outPut.String())
94 r.outPut.Reset()
95}
把redis协议的解析功能集成到 RedisClient
结构体中。Read
方法读取并解析 Redis 服务器的响应,Write
方法将用户输入的命令发送到 Redis 服务器,Print
方法输出解析后的结果。在解析数据时,把需要实现的内容同步写入到 outPut
中。
命令行的输入数据因目前不会涉及到复杂的命令,一般就是查询指定key的内容,所以可以直接用 RESP 的 inline 命令格式,比如 SET key value
,GET key
,HGETALL key
等等。
整合
基本功能模块完成后,就可以把这些功能组合起来,然后测试了
1package main
2
3import (
4 "bufio"
5 "flag"
6 "fmt"
7 "io"
8 "os"
9 "strings"
10)
11
12var sshHost = flag.String("sshHost", "", "SSH server address")
13var sshUser = flag.String("sshUser", "", "SSH username")
14var sshPassword = flag.String("sshPassword", "", "SSH password")
15var sshKey = flag.String("sshKey", "", "Path to SSH private key file path")
16
17var redisHost = flag.String("h", "127.0.0.1", "Redis server address")
18var redisPort = flag.Int("p", 6379, "Redis server port")
19
20func main() {
21 flag.Parse()
22 if *redisHost == "" || *redisPort == 0 {
23 fmt.Println("Redis server address and port must be provided")
24 os.Exit(1)
25 }
26 sshConfig := GetSSHConfig()
27
28 sshClient, err := GetSSHClient(sshConfig)
29 if err != nil {
30 fmt.Printf("Failed to create SSH client: %v\n", err)
31 os.Exit(1)
32 }
33 fmt.Printf("Connecting to SSH server at %s\r", *sshHost)
34 defer sshClient.Close()
35
36 redisClient, err := GetRedisClient(sshClient, fmt.Sprintf("%s:%d", *redisHost, *redisPort))
37 if err != nil {
38 fmt.Printf("Failed to connect to Redis server: %v\n", err)
39 os.Exit(1)
40 }
41 defer redisClient.Close()
42
43 fmt.Printf("Connected to Redis server at %s:%d via SSH\r", *redisHost, *redisPort)
44
45 fmt.Printf("input 'exit' or 'quit' to exiting process!!!\n")
46
47 //从标准输入读取数据
48 reader := bufio.NewReader(os.Stdin)
49 for {
50 line, err := reader.ReadString('\n')
51 if err != nil {
52 if err == io.EOF {
53 fmt.Println("Exiting...")
54 break
55 }
56 _, _ = fmt.Fprintf(os.Stderr, "读取错误: %v\n", err)
57 }
58 line = strings.TrimSpace(line)
59 if line == "" {
60 continue // Skip empty lines
61 }
62 if line == "exit" || line == "quit" {
63 fmt.Println("Exiting...")
64 break
65 }
66 //处理输入的命令
67 if err = redisClient.Write(line); err != nil {
68 _, _ = fmt.Fprintf(os.Stderr, "写入错误: %v\n", err)
69 continue
70 }
71 redisClient.outPut.Reset()
72 if err = redisClient.Read(); err != nil {
73 _, _ = fmt.Fprintf(os.Stderr, "读取错误: %v\n", err)
74 continue
75 }
76
77 redisClient.Print()
78 }
79}
80
81func GetSSHConfig() *SSHConfig {
82 sshConfig := &SSHConfig{
83 SSHHost: *sshHost,
84 SSHUser: *sshUser,
85 SSHPassword: *sshPassword,
86 SSHKey: *sshKey,
87 }
88 return sshConfig
89}
通过命令行获取相关的参数,循环读取用户输入的命令并处理,最终将redis返回的数据输出到标准输出。
到这,一个简单的 SSH 隧道转发的 redis-cli 工具就完成了。
最后附上 go.mod 文件的内容:
1module tun_redis_cli
2
3go 1.23.1
4
5require (
6 golang.org/x/crypto v0.41.0 // indirect
7 golang.org/x/sys v0.35.0 // indirect
8)