Featured image of post 用go实现一个支持SSH隧道转发 (SSH port forwarding) 的 redis-cli 工具

用go实现一个SSH隧道转发的redis-cli工具

本文介绍了如何使用Go语言实现一个支持SSH隧道转发的Redis客户端工具。通过SSH隧道转发,可以安全地访问内网的Redis实例,解决了无法直接在公网中访问云上Redis实例的问题。文章详细讲解了SSH隧道转发的原理、Go代码实现以及Redis协议解析,帮助读者理解并应用这一技术。

先聊一下什么是SSH隧道转发吧,隧道转发也被称作是SSH 端口转发,是一种通过SSH协议将本地端口转发到远程主机的技术。它可以实现安全的网络连接,常用于穿越防火墙或访问内网服务。

SSH隧道转发的工作原理是,在SSH连接建立后,通过SSH协议将本地端口的流量加密后转发到远程主机的指定端口。这样,用户就可以通过本地端口访问远程主机的服务,而不必直接暴露远程主机的端口。最常见的应用场景是在本地连接云服务的数据库,或者访问公司内网的资源。一般云MySQL数据库都是不开放公网的,只能在内网中访问,为了在公网能访问这些资源,就需要使用SSH隧道转发。

在 Navicat 中连接MySQL 数据库时,可以通过SSH隧道转发来连接没有公网IP的数据库,连接配置如下图

为什么要用go实现一个SSH 隧道转发代理呢?主要是我们公司的很多redis实例都在云上,无法直接在公网中访问。通过SSH隧道转发,可以实现在公网中访问这些云上的redis实例。为什么不用现成的工具呢?

首先 redis-cli 工具不支持SSH隧道转发,要解决问题,有两种选择

  1. 选择一些GUI的redis客户端工具,但是这些工具往往需要额外的配置,使用起来不够方便。所以考虑自己写一个简单的命令行工具来实现这个功能。
  2. 直接使用SSH命令进行端口转发,然后再使用redis-cli连接本地端口。这种方式比较简单,但需要手动管理SSH连接

使用这两个方案还有一个问题,要对所有人公开SSH机器的账号密码,存在安全隐患。

所以自己实现一个简单的 支持 SSH 隧道转发的 redis-cli 工具是一个不错的选择,正好可以借这个机会学习一下相关的技术。这个工具要有以下功能:

  1. 支持通过SSH隧道连接到远程Redis实例
  2. 对使用者透明SSH连接的建立和断开
  3. 支持配置文件,或者直接把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 协议有五种数据类型:

  1. 简单字符串:以 + 开头,直到 \r\n 结束。
  2. 错误:以 - 开头,直到 \r\n 结束。
  3. 整数:以 : 开头,直到 \r\n 结束。
  4. 数组:以 * 开头,后接数组长度,直到 \r\n 结束。数组中的每个元素都是一个 RESP 类型。
  5. 二进制安全字符串:以 $ 开头,后接字符串长度,直到 \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)
发表了70篇文章 · 总计178.69k字
本博客已稳定运行
© QX
使用 Hugo 构建
主题 Stack 由 Jimmy 设计