Golang | Go RPC & TLS 鉴权

基于 Golang 标准库 net/rpc,同时基于 TLS/SSL 实现服务器端和客户端的单向鉴权、双向鉴权。

RPC 介绍

RPC (Remote Procedure Call) 是一种远程通信协议,用于让不同服务之间进行通信。相比 HTTP,RPC 在传输信息时会减少一些额外的信息。HTTP 是实现 RPC 的一种手段,RPC 常见的实现有很多,比如 Dubbo、gRPC、Hessian 等。

image.png

远程过程调用,实际上最终是一个调用。相比于本地调用,RPC 还需要知道对向的地址信息。本地调用就如同两个人面对面交流,RPC 则像是两个人打电话,需要知道对方的手机号码,但是并不关心语音怎么编码、传输、解码的过程。

提供服务的一端,我们称为服务端,就好比是接听电话的一端。首先双方要都能通信,都有可用的网络信号,其次要知道对方的手机号才能拨打电话。使用 RPC(拨打电话)并不关心整个服务的调用细节(语音信息如何传输、编解码),只需要关心具体的过程和服务实现(关注对方的人在电话里说了啥)。

在 Go 语言中,RPC 使用 Golang 的 net/rpc 标准库。

本地调用的实现

以一个简单的本地调用为例,这里实现一个计算某个数的二次方的程序。

/**
 * @author Real
 * @since 2023/11/20 23:30
 */
package main

import (
	"golang.org/x/tools/container/intsets"
	"log"
)

type Result struct {
	Num, Ans int
}

type Calc int

func (cal *Calc) CalcSquare(num int) *Result {
	if num > intsets.MaxInt/2 {
		panic("error argument: num is too big......")
	}

	return &Result{
		Num: num,
		Ans: num * num,
	}
}

func main() {
	calc := new(Calc)
	square := calc.CalcSquare(10)
	log.Printf("%d^2 = %d", square.Num, square.Ans)
}

在这个程序中,我们做了以下几件事:

  • Cal 结构体,提供了 Square 方法,用于计算传入参数 num 的 二次方。
  • Result 结构体,包含 Num 和 Ans 两个字段,Ans 是计算后的值,Num 是待计算的值。
  • main 函数,测试我们实现的 Square 方法。

运行 main.go,将会输出:

$ go run main.go
2023/11/20 23:44:38 10^2 = 100

RPC 需要的条件

如果一个方法需要支持远程过程调用,需要满足一定的约束和规范。不同 RPC 框架的约束和规范是不同的,如果使用 Golang 的标准库 net/rpc,方法的定义通常遵循下列结构:

func (t *T) MethodName(argType T1, replyType *T2) error

即需要满足以下 5 个条件:

  1. 方法类型(T)是导出的(首字母大写);
  2. 方法名(MethodName)是导出的;
  3. 方法有两个参数(argType T1, replyType *T2),均为导出/内置类型;
  4. 方法的第二个参数一个指针(replyType *T2);
  5. 方法的返回值类型是 error;

net/rpc 对参数个数的限制比较严格,仅能有 2 个,第一个参数是调用者提供的请求参数,第二个参数是返回给调用者的响应参数,也就是说,服务端需要将计算结果写在第二个参数中。如果调用过程中发生错误,会返回 error 给调用者。

改造之前的 Square 方法,使其符合上述的模版。

package main

import "log"

type Result struct {
	Num, Ans int
}

type Calc int

func (cal *Calc) Square(num int, result *Result) error {
	result.Num = num
	result.Ans = num * num
	return nil
}

func main() {
	calc := new(Calc)
	var result Result
	_ = calc.Square(11, &result)
	log.Printf("%d^2 = %d", result.Num, result.Ans)
}

至此,方法 Calc.Square 满足了 RPC 调用的 5 个条件。

RPC 服务与调用

RPC 是一个典型的客户端-服务器(Client-Server,CS)架构模型,很显然,需要将 Calc.Square 方法放在服务端。服务端需要提供一个套接字服务,处理客户端发送的请求。通常可以基于 HTTP 协议,监听一个端口,等待 HTTP 请求。

实现服务端

新建一个文件夹 server,将 Calc.Square 方法移动到 server/main.go 中,并在 main 函数中启动 RPC 服务。

package main

import (
	"log"
	"net/http"
	"net/rpc"
)

type Result struct {
	Num, Ans int
}

type Calc int

func (cal *Calc) Square(num int, result *Result) error {
	result.Num = num
	result.Ans = num * num
	return nil
}

func main() {
	_ = rpc.Register(new(Calc))
	rpc.HandleHTTP()

	log.Printf("Serving RPC server on port %d", 1234)
	if err := http.ListenAndServe(":1234", nil); err != nil {
		log.Fatal("Error serving: ", err)
	}
}

启动之后,可以看到在 Terminal 中输出以下内容:

image.png

这段代码中,启动 RPC 服务有三个核心步骤:

  • 使用 rpc.Register,发布 Calc 中满足 RPC 注册条件的方法 Calc.Square
  • 使用 rpc.HandleHTTP,注册用于处理 RPC 消息的 HTTP Handler
  • 使用 http.ListenAndServe,监听 1234 端口,等待 RPC 请求

执行,得到:

$ go run main.go
2023/11/21 21:41:41 Serving RPC server on port 1234

此时 RPC 服务已经启动,只需要等待客户端的调用。

实现客户端

接着在 client 目录中新建文件 client/main.go,创建 HTTP 客户端,调用 Calc.Square 方法。

/**
 * @author Real
 * @since 2023/11/21 22:17
 */
package main

import (
	"log"
	"net/rpc"
)

type Result struct {
	Num, Ans int
}

func main() {
	client, _ := rpc.DialHTTP("tcp", "localhost:1234")

	var result Result
	if err := client.Call("Calc.Square", 12, &result); err != nil {
		log.Fatalf("Failed to call to Calc.Square, error = %v\n", err)
	}

	log.Printf("%d^2 = %d\n", result.Num, result.Ans)
}

在客户端的实现中,因为要用到 Result 类型,简单起见,我们拷贝了 Result 的定义。

  • 使用 rpc.DialHTTP 创建了 Client 客户端,并且建立了与 localhost:1234 服务的连接,1234 是之前服务端启动时监听的端口;
  • 使用 rpc.Call 调用远程方法,第一个参数是方法名,后面两个与方法定义的参数对应;

执行程序,可以看到 rpc 正常调用了。

image.png

2023/11/21 22:21:14 12^2 = 144

异步客户端

上述的调用方式是同步的,除了同步之外,还有异步的调用方式。

异步的调用,需要使用 client.Go 方法进行调用。该方法最后一个参数,可以使用 channel 来接收异步的返回值。因为这里我们直接使用 result 去接收结果,所以并不需要 chan 来处理。

/**
 * @author Real
 * @since 2023/11/22 23:34
 */
package main

import (
	"log"
	"net/rpc"
)

type Result struct {
	Num, Ans int
}

func main() {
	client, _ := rpc.DialHTTP("tcp", "localhost:1234")

	var result Result
	asyncCall := client.Go("Calc.Square", 12, &result, nil)
	log.Printf("before call:%d^2 = %d", result.Num, result.Ans)

	<-asyncCall.Done
	log.Printf("after call:%d^2 = %d", result.Num, result.Ans)
}

因为 client.Go 是异步调用,因此第一次打印 result,result 没有被赋值。而通过调用 <-asyncCall.Done,阻塞当前程序直到 RPC 调用结束,因此第二次打印 result 时,才能看到正确赋值。

运行结果:

2023/11/22 23:39:24 before call:0^2 = 0
2023/11/22 23:39:24 after call:12^2 = 144

image.png

RPC 中 TLS 证书鉴权

对于 RPC 调用,如果使用 HTTP 协议,那么安全性是应该考虑的。我们可以通过使用证书来保证通信过程的安全。

Windows 安装 openssl

Windows 环境中,首先为了生成 TLS 相关的证书,需要安装 openssl 环境。

① 在网站中下载对应的程序:https://slproweb.com/products/Win32OpenSSL.html

选择合适的版本进行下载,Windows 一般选择下载 64 位的就行,推荐下载 MSI 文件。

image.png

② 下载之后执行安装。

1700833968324.png

③ 配置 openssl 的环境变量,选择新建系统环境变量。

其中,key = OPENSSL_HOME, value = C:\Program Files\OpenSSL-Win64\bin

image.png

在 Path 中添加新的值,%OPENSSL_HOME%

image.png

④ 去 CMD 查看是否安装成功,输入指令 openssl version 即可。

image.png

⑤ 注意 cmd 关闭之后,需要打开 openssl 应用。

image.png

image.png

生成密钥和自签名证书

生成私钥和自签名的证书,并将 server.key 权限设置为只读,保证私钥的安全。

# 生成私钥
openssl genrsa -out server.key 2048
# 生成证书
openssl req -new -x509 -key server.key -out server.crt -days 3650
# 只读权限
chmod 400 server.key

执行上述命令,选择部分程序进行执行。

image.png

此时选择用户目录,进去可以看到 server.crtserver.key 两个文件。将文件复制到程序所在目录,如下:

image.png

此时服务器端可以使用生成的 server.crtserver.key 文件启动 TLS 的端口监听。

func main() {
	config := &tls.Config{
		InsecureSkipVerify: true,
	}
	conn, err := tls.Dial("tcp", "localhost:1234", config)
	if err != nil {
		fmt.Printf("Failed to dial. error: %v\n", err)
		return
	}

	defer func(conn *tls.Conn) {
		err := conn.Close()
		if err != nil {
			log.Fatal("Failed to close connection. ", err)
		}
	}(conn)
	client := rpc.NewClient(conn)

	var result Result
	if err := client.Call("Calc.Square", 12, &result); err != nil {
		log.Fatal("Failed to call Calc.Square. ", err)
	}

	log.Printf("%d^2 = %d", result.Num, result.Ans)
}

远程握手失败

https://juejin.cn/s/golang%20remote%20error%20tls%20handshake%20failure

这个错误通常是由于 SSL/TLS 证书不受信任或证书过期导致的。在 Go 语言中,可以使用 Transport 配置来指定 SSL/TLS 证书验证方法。你可以在 Transport 配置中设置 TLSClientConfig,以自定义证书验证。

客户端对服务端的鉴权

如果需要对服务端进行鉴权,则需要在 client 端将 server.crt 文件添加到信任证书池中。

func main() {
	certPool := x509.NewCertPool()

	certBytes, err := ioutil.ReadFile("C:\\Users\\Real\\GoProjects\\go_study\\go_rpc\\tls\\server\\server.crt")
	if err != nil {
		log.Fatalf("Failed to read server.crt: %v", err)
	}

	certPool.AppendCertsFromPEM(certBytes)

	config := &tls.Config{
		RootCAs: certPool,
	}

	connection, err := tls.Dial("tcp", "localhost:1234", config)
	if err != nil {
		log.Fatalf("Failed to dial: %v", err)
	}

	defer connection.Close()
	client := rpc.NewClient(connection)

	var result Result
	if err := client.Call("Calc.Square", 12, &result); err != nil {
		log.Fatalf("Failed to call Calc.Square: %v", err)
	}

	log.Printf("%d^2 = %d", result.Num, result.Ans)
}

服务端对客户端的鉴权

服务器端对客户端的鉴权是类似的,核心在于 tls.Config 的配置:

  • 把对方的证书添加到自己的信任证书池 RootCAs(客户端配置),ClientCAs(服务器端配置)中。
  • 创建链接时,配置自己的证书 Certificates

客户端的 config 做如下修改:

// client/main.go

cert, _ := tls.LoadX509KeyPair("client.crt", "client.key")
certPool := x509.NewCertPool()
certBytes, _ := ioutil.ReadFile("C:\\Users\\Real\\GoProjects\\go_study\\go_rpc\\tls\\server\\server.crt")
certPool.AppendCertsFromPEM(certBytes)
config := &tls.Config{
    Certificates: []tls.Certificate{cert},
    RootCAs:      certPool,
}

服务端的 config 做如下修改:

// server/main.go

cert, _ := tls.LoadX509KeyPair("server.crt", "server.key")
certPool := x509.NewCertPool()
certBytes, _ := ioutil.ReadFile("../client/client.crt")
certPool.AppendCertsFromPEM(certBytes)
config := &tls.Config{
    Certificates: []tls.Certificate{cert},
    ClientAuth:   tls.RequireAndVerifyClientCert,
    ClientCAs:    certPool,
}

Reference

  • 12
    点赞
  • 17
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值