Golang | RPC & TLS 鉴权
基于 Golang 标准库 net/rpc,同时基于 TLS/SSL 实现服务器端和客户端的单向鉴权、双向鉴权。
RPC 介绍
RPC (Remote Procedure Call
) 是一种远程通信协议,用于让不同服务之间进行通信。相比 HTTP,RPC 在传输信息时会减少一些额外的信息。HTTP 是实现 RPC 的一种手段,RPC 常见的实现有很多,比如 Dubbo、gRPC、Hessian 等。
远程过程调用,实际上最终是一个调用。相比于本地调用,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 个条件:
- 方法类型(T)是导出的(首字母大写);
- 方法名(MethodName)是导出的;
- 方法有两个参数(argType T1, replyType *T2),均为导出/内置类型;
- 方法的第二个参数一个指针(replyType *T2);
- 方法的返回值类型是 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 中输出以下内容:
这段代码中,启动 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 正常调用了。
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
RPC 中 TLS 证书鉴权
对于 RPC 调用,如果使用 HTTP 协议,那么安全性是应该考虑的。我们可以通过使用证书来保证通信过程的安全。
Windows 安装 openssl
Windows 环境中,首先为了生成 TLS 相关的证书,需要安装 openssl 环境。
① 在网站中下载对应的程序:slproweb.com/products/Wi…
选择合适的版本进行下载,Windows 一般选择下载 64 位的就行,推荐下载 MSI 文件。
② 下载之后执行安装。
③ 配置 openssl 的环境变量,选择新建系统环境变量。
其中,key = OPENSSL_HOME
, value = C:\Program Files\OpenSSL-Win64\bin
。
在 Path 中添加新的值,%OPENSSL_HOME%
。
④ 去 CMD 查看是否安装成功,输入指令 openssl version
即可。
⑤ 注意 cmd 关闭之后,需要打开 openssl 应用。
生成密钥和自签名证书
生成私钥和自签名的证书,并将 server.key 权限设置为只读,保证私钥的安全。
# 生成私钥
openssl genrsa -out server.key 2048
# 生成证书
openssl req -new -x509 -key server.key -out server.crt -days 3650
# 只读权限
chmod 400 server.key
执行上述命令,选择部分程序进行执行。
此时选择用户目录,进去可以看到 server.crt
和 server.key
两个文件。将文件复制到程序所在目录,如下:
此时服务器端可以使用生成的 server.crt
和 server.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)
}
远程握手失败
这个错误通常是由于 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
- 极客兔兔 Go-RPC 教程:geektutu.com/post/quick-…
转载自:https://juejin.cn/post/7380650414414987298