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有多种身份验证方式,包括密码验证和公钥验证。在本例中,我们支持这两种方式。用户可以通过设置 SSHPasswordSSHKey 字段来选择身份验证方式。如果同时设置了这两个字段,程序将优先使用密码验证。

现在已经创建好了一个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协议现在有 RESP2RESP3 两个版本。现在常用的还是 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 valueGET keyHGETALL 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)
发表了64篇文章 · 总计157.99k字
本博客已稳定运行
© QX
使用 Hugo 构建
主题 StackJimmy 设计