自签证书内网穿透

自签证书内网穿透

这里是基于内网的自签名证书对外开放服务功能, 主要流程:

  1. Linux 定时自动 生成自签证书生成放置于 Nginx 特定目录, 建议每日|每月自动更新证书数据
  2. 在生成证书的同时挂载自签证书提供对外服务, 所有服务都必须经由自签证书访问
  3. 在生成证书的同时写入到公钥和证书数据到 Redis 之中保存
  4. 对外挂起单独 Web 服务提供登录服务用于统一登录授权
  5. 用户登录认证之后服务器返回 地址+端口+证书+公钥进行 从而保存到本地挂起 Web 通过自签证书访问服务
  6. 用户从登录多个返回授权服务列表可以直接访问到内部自签名服务

具体的请求时序图如下:

Program UML

这种访问方式可以在外网防止中间人窃取访问数据, 从而保证内部服务的安全可靠性;
这里采用 Python|Bash 脚本处理都行,
另外还需要知道怎么 构建自签名证书.

上面的构建自签证书需要手动输入必要的信息, 这里采用连贯命令直接单行全部编写( 先测试脚本: /etc/nginx/auto.cer.sh ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
#!/bin/bash

# 注意: 这个脚本最后采用 root 方式管理, 因为要Nginx重载配置
if [ $UID -ne 0 ]; then
echo "require permissions, please run as root"
exit 1
fi


# 证书有效天数, 最好采用动态生成不断周期更新
CER_DAY=60

# 构建的动态端口起始值
CER_INDEX=2

# 转发的内网服务地址
CER_PROXY="http://127.0.0.1:3000"

# 证书输出路径, 这里默认在 Nginx 上构建出来, 注意必须先创建好目录
# mkdir -p /etc/nginx/auto_ssl
CER_PATH="/etc/nginx/auto_ssl"

# Nginx 放置的配置加载目录, 注意必须要创建好目录
# mkdir -p /etc/nginx/auto.d
NGINX_CONF_PATH="/etc/nginx/auto.d"

# Nginx 替换内容数据模板文件
# 这个模板文件后续会提供用于替换数据
NGINX_TPL_FILE="/etc/nginx/auto.conf.tpl"

# 判断模板是否存在
if [ ! -f "${NGINX_TPL_FILE}" ];then
echo "file not exists: ${NGINX_TPL_FILE}"
exit 1
fi


# 证书的相关信息
CER_COUNTRY="CN"
CER_STATE="GuangDong"
CER_CITY="GuangZhou"
CER_ORGANIZATION="HaiZhu"
CER_ORGANIZATIONAL_UNIT="101"
CER_COMMON_NAME="MeteorCat"
CER_EMAIL="[email protected]"

# 这里先测试采用按照当天生成证书确定脚本可用, 也就是不断在当天产生 `2024.03.22.auto.cer|2024.03.22.auto.key` 之类
# 这里先不考虑多端口挂起多服务, 先命令脚本跑出自动签名证书效果, 验证完成之后再做进一步配置

# 构建出当天年月日格式和端口时间
CER_YMD=`date +'%Y.%m.%d'`
NGINX_YMD=`date +'%y%m'`

# 构建文件名
CER_FILE="${CER_YMD}.auto.cer"
KEY_FILE="${CER_YMD}.auto.key"
P12_FILE="${CER_YMD}.auto.p12"

# 这里使用动态端口访问: 采用 起始单值+短年+长月 作为端口号, 端口号最高 65535
# 假如 CER_INDEX 输入 2, 按照年份就是 2403, 最后结果就是 22403 作为最后端口
# 如果固定是端口号直接写入到变量即可, 直接长期固定端口号
NGINX_CONF_PORT="${CER_INDEX}${NGINX_YMD}"
NGINX_CONF_FILE="${NGINX_CONF_PORT}.auto.conf"

# 打印文件路径和动态端口加载
echo "create cer: ${CER_PATH}/${CER_FILE}"
echo "create key: ${CER_PATH}/${KEY_FILE}"
echo "create p12: ${CER_PATH}/${P12_FILE}"
echo "create conf: ${NGINX_CONF_PATH}/${NGINX_CONF_FILE}"
echo "create proxy: ${CER_PROXY}"

# 构建命令, 直接等待让其执行
openssl req -x509 -nodes -days ${CER_DAY} \
-keyout ${CER_PATH}/${KEY_FILE} \
-out ${CER_PATH}/${CER_FILE} \
-subj "/C=${CER_COUNTRY}/ST=${CER_STATE}/L=${CER_CITY}/O=${CER_ORGANIZATION}/OU=${CER_ORGANIZATIONAL_UNIT}/CN=${CER_COMMON_NAME}/emailAddress=${CER_EMAIL}"

# 转化成通用 p12 证书
openssl pkcs12 -export -in ${CER_PATH}/${CER_FILE} \
-inkey ${CER_PATH}/${KEY_FILE} \
-out ${CER_PATH}/${P12_FILE} -passout pass:

# 构建Nginx的服务配置文件, 直接替换模板服务文本
# 直接覆盖对应的标签内容写入到 nginx 配置
awk -v _port_="${NGINX_CONF_PORT}" -v _cer_="${CER_PATH}/${CER_FILE}" -v _key_="${CER_PATH}/${KEY_FILE}" -v _proxy_="${CER_PROXY}" \
'{gsub("__PORT__", _port_);gsub("__CER__", _cer_);gsub("__KEY__", _key_);gsub("__PROXY__", _proxy_); print $0}' \
${NGINX_TPL_FILE} > ${NGINX_CONF_PATH}/${NGINX_CONF_FILE}


# 这里是需要构建 Nginx 加载配置, 因为运行在 root 所以直接 `systemctl reload nginx.service`
# 也可以直接修改证书的可访问权限, 这里默认给了 444 方便给只读处理
chmod 444 ${CER_PATH}/${KEY_FILE}
chmod 444 ${CER_PATH}/${CER_FILE}
systemctl reload nginx


# 这里可以将CER和KEY数据写入 Redis, 然后让独立的授权登录接口返回 RestApi 让用户再次去连接对应服务

这里需要补充个模板 /etc/nginx/auto.conf.tpl 用来文本替换写入到具体配置目录:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
server {
# 设置监听端口, __PORT__ 就是替换模板变量
listen __PORT__ ssl http2;

# 设置服务器访问域名
server_name _;

# http support versions,
ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3;

# check server cipher
ssl_prefer_server_ciphers on;

# server cipher methods
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";


# certificate path, CER 文件变量
ssl_certificate __CER__;

# certificate key path, KEY 文件变量
ssl_certificate_key __KEY__;

# bidirectional check
ssl_verify_client on;
ssl_client_certificate __CER__;

# 声明代理转发给服务器池, 地址模板变量模板
location / {
proxy_pass __PROXY__;
proxy_set_header Host $host:$server_port;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-real-ip $remote_addr;
}
}

注: 这里 proxy| 是我自己编写的 Web 服务, 这里用于测试是否能够访问

现在就是在 /etc/nginx/nginx.conf 当中引入启动转发自签证书服务:

1
2
3
4
5
6
7
# 注意是 http 区块
http {
# 前面代码略

# 引入自签证书服务模块
include /etc/nginx/auto.d/*.conf;
}

测试执行脚本确认挂起自签证书协议, 确认是否访问安全:

1
2
3
4
5
6
7
8
9
10
11
12
13
# 只授权给 root 管理, 22404 是动态构建证书的端口
sudo bash /etc/nginx/auto.cer.sh
sudo lsof -i :22404

# 这里服务器测试访问自签证书协议的地址
curl -I http://192.168.1.100:22404 # 提示 400 错误请求
curl -I https://192.168.1.100:22404 # 提示 ERR_CERT_AUTHORITY_INVALID|SSL certificate problem: self-signed certificate, 要求加载自签证书
curl -I -k https://192.168.1.100:22404 # 采用 -k 忽略证书会被打回提示 400 错误请求

# 使用自签证书访问, 注意自签证书必须要开启 -k 忽略认证证书
# 最后确认访问到状态200, 代表可以直接证书访问到数据
# 注意这里是在 Linux 环境下测试, Window 环境很复杂可能没办法访问, 可以查看后续利用其他编程脚本来读取
curl -k -I --key /etc/nginx/auto_ssl/2024.04.24.auto.key --cert /etc/nginx/auto_ssl/2024.04.24.auto.cer https://192.168.1.100:22404

之后就是挂载在 root 的定时脚本运行来更新签名, 设置 每天凌晨 00:01:00 运行下脚本构建出来并且让 nginx 加载.

后续需要结合系统将数据放置与 Redis|MariaDB 之中, 方便和另外独立授权接口进行认证联调, 需要注意 Window 平台采用p12证书来进行访问

总所周知, https 证书访问能够防止中间人监听到你访问的网页内容, 中间人只能了解你访问的 url 信息和证书加密之后的网页内容,
所以也就无法得知你在传输的数据内容, 也因为周期性自动更新证书导致了证书并不是一成不变, 从而加大了破解证书内容的难度.

授权暴露服务

这里的授权服务是单独部署的 Web 服务, 需要和之前动态证书部署的服务区分开来, 这里展示该接口请求和响应数据:

lines
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
// [用户端:请求] 登录接口: POST /auth/login 
{
"username": "meteorcat",
"password": "meteorcat"
}

// [服务端:响应] 登录接口
{
"error": 0,
"message": "success",
"data": {
"uid": 1,
"token": "MD5TOKEN",
// 返回的服务列表, 注意这里列表保存本地
// 保存分为 'server_1.json,server_2.json', 后续访问就直接加载指定服务去校验接口数据
// 这里也可以单独提供下载接口, 用户去下载端口信息放置于自己配置文件路径下读取
"servers": [
{
"id": 1,
"address": "https://192.168.1.100:22404",
// 其他证书相关数据
"cer": "CER_DATA",
"key": "KEY_DATA",
"p12": "P12_DATA"
}
]
}
}

// =====================================================

// [用户端:请求] 配置下发: POST /auth/download (这个接口并不是必要的)
{
"token": "MD5TOKEN",
// 需要下载的配置ID
"id": 1
}

// [服务端:响应] 这里直接构建推送下载

// =====================================================

// [用户端:请求] 校验接口: POST /auth/check
{
"token": "MD5TOKEN",
"id": 1,
// 服务ID
"address": "https://192.168.1.100:22404",
// 其他证书相关数据
"cer": "CER_DATA",
"key": "KEY_DATA",
"p12": "P12_DATA"
}

// [服务端:响应] 校验接口
{
// 如果可用, 错误码应该返回0, 否则其他情况取 message 错误消息显示给用户要求重新登录
"error": 0,
"message": "success",
"data": {}
}

用户首次登录的时候需要确认是否本地缓存 server.json 之类的登录授权, 如果没有需要提示用户跳转登录等待获取授权;
如果已经带有授权需要访问校验接口, 同步确认下证书授权信息是否过期, 如果过期则删除本地授权文件再次跳转到授权服务页面.

这里的 Web 服务更像是构建代理访问接口用来代理到内网的服务, 用户拿到授权之后可以直接本地加载证书访问内网服务.

这里的 Web 服务没有限定什么编程语言, 基本上是主流编程语言就行了, 因为这里可以用太多语言实现所以只需要接口格式就行了.

和传统 RestApi 其实差不多, 只是常规的时候请求都是单个地址持续请求, 而上面就是分离成两个请求端并采用自签名证书访问.

这里提供 SpringBoot 测试样例挂起 Web 的授权服务监听:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
package com.meteorcat.cer.api;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.util.DigestUtils;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.Map;


/**
* 授权登录, 先单独集成该文件, 后续重构直接分离处理
* 这里仅仅作为示例编写
*/
@RestController
@RequestMapping("/auth")
public class AuthApi {


/**
* 日志句柄
*/
final Logger logger = LoggerFactory.getLogger(getClass());


/**
* 先暂时手动编写的返回的响应体
*
* @param error 错误码
* @param message 消息内容
* @param data 返回数据
* @return JsonMap
*/
protected Map<String, Object> response(int error, String message, Object data) {
return new HashMap<>(3) {{
put("error", error);
put("message", message);
put("data", data == null ? new HashMap<>(0) : data);
}};
}


/**
* 暂时放置的请求提交体
*/
public static class UserForm implements Serializable {
private String username;
private String password;

public String getPassword() {
return password;
}

public String getUsername() {
return username;
}

public void setPassword(String password) {
this.password = password;
}

public void setUsername(String username) {
this.username = username;
}

@Override
public String toString() {
return "UserForm{" +
"username='" + username + '\'' +
", password='" + password + '\'' +
'}';
}
}


/**
* 已经完成登录授权的玩家
*/
private final Map<String, Integer> online = new HashMap<>();

/**
* 登录接口
*
* @param userForm 玩家提交格式
* @return JSON
*/
@PostMapping("/login")
public Object login(@RequestBody UserForm userForm) {

logger.debug("User: {}", userForm);

// Todo:这里先手动验证账号信息, 后续如果想数据库挂载出内容这里再处理
if (!"meteorcat".equals(userForm.username) || !"meteorcat".equals(userForm.password)) {
return response(1, "找不到玩家信息", null);
}


// todo: 临时写死的玩家ID
Integer uid = 1;


// 生成登录Token
String format = String.format("%s-%d", userForm.username, System.currentTimeMillis());
String hash = DigestUtils.md5DigestAsHex(format.getBytes(StandardCharsets.UTF_8));

// 构建出具体的暴露穿透的内容
ArrayList<Map<String, Object>> servers = new ArrayList<>();


// Todo: 这里先写死返回的数据内容, 后续通过加载本地来处理
servers.add(new HashMap<>() {{
put("id", 1);
put("address", "https://192.168.1.100:22404");
// 下面的内容需要去读取内容返回
put("cer", "CER_DATA内容");
put("key", "KEY_DATA内容");
put("p12", "P12_DATA内容");
}});


// 确认已经在线的数据
if (online.containsValue(uid)) {
for (Map.Entry<String, Integer> active : online.entrySet()) {
if (active.getValue().equals(uid)) {
online.remove(active.getKey());
break;
}
}
}

// 写入在线数据
online.put(hash, uid);

// 响应出内容
return response(0, "success", new HashMap<>() {{
put("uid", uid);
put("token", hash);
put("servers", servers);
}});
}


/**
* 暂时放置的请求提交体
*/
public static class CheckForm implements Serializable {
private String token;

private Integer id;

private String address;

private String cer;

private String key;

private String p12;

public String getToken() {
return token;
}

public void setToken(String token) {
this.token = token;
}

public void setId(Integer id) {
this.id = id;
}

public Integer getId() {
return id;
}

public void setAddress(String address) {
this.address = address;
}

public void setCer(String cer) {
this.cer = cer;
}

public void setKey(String key) {
this.key = key;
}

public void setP12(String p12) {
this.p12 = p12;
}

public String getAddress() {
return address;
}

public String getCer() {
return cer;
}

public String getKey() {
return key;
}

public String getP12() {
return p12;
}

@Override
public String toString() {
return "CheckForm{" +
"token='" + token + '\'' +
", id=" + id +
", address='" + address + '\'' +
", cer='" + cer + '\'' +
", key='" + key + '\'' +
", p12='" + p12 + '\'' +
'}';
}
}


/**
* 检查最新授权验证
*
* @param checkForm 请求结构体
* @return JSON
*/
@PostMapping("/check")
public Object check(@RequestBody CheckForm checkForm) {
logger.debug("Check: {}", checkForm);

// 首先确认授权是否存在
String token = checkForm.token;
if (!online.containsKey(token)) {
return response(1, "用户登录授权过期, 请重试", null);
}

// todo: 去服务器本地比较授权数据

// 最后确认授权验证通过
return response(0, "success", null);
}

}

重点要记住, 独立的 Web 登录授权服务 https 必须要公网CA授权证书而不要自签证书访问.

这里就是简单编写的 SpringBoot 授权样例, 后续可以按照这方向去细化处理.

用户端自签证书访问

之前编写完服务端相关工作, 而现在用户已经可以下载所有证书内容到本地, 之后就是怎么在用户端使用这些证书文件.

这里假设的前提是已经跑通获取到 cer/key/p12 证书所有信息, 这才是构建整套内网访问体系的基础

这里实际上就是本地挂起另外 Web 服务来做转发, 用户通过访问本地端口然后通过自签证书转发到远程服务.

这里列举些其他编程语言调用自签证书访问的样例( 摘录网路, 不对其有效保证 ).

PHP 版本转发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

// PHP 版本带证书转发
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, "https://192.168.1.100:22404");
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 关闭验证证书, 自签证书必须要用到
curl_setopt($ch, CURLOPT_SSLCERT, "2024.04.24.auto.cer"); // 这里采用cer而不是p12证书, 只有 window 流量器直接访问才需要import
curl_setopt($ch, CURLOPT_SSLKEY, "2024.04.24.auto.key"); // 采用自签证书Key
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 0); // 关闭主机名验证
$response = curl_exec($ch); // 执行cURL会话
if(curl_errno($ch)){
// 检查是否有错误发生
echo 'cURL error: ' . curl_error($ch);
exit(1);
}

curl_close($ch); // 关闭cURL资源,并释放系统资源
echo $response; // 打印响应内容

Python 版本转发

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# !/usr/bin/python
# -*- coding: UTF-8 -*-

import sys

# 安装请求库: pip install requests
import requests


# 入口方法
def main(argv):
cer_file = "2024.04.24.auto.cer"
key_file = "2024.04.24.auto.key"
response = requests.get("https://192.168.1.100:22404", cert=(cer_file, key_file), verify=False)
print(response.text)


if __name__ == "__main__":
main(sys.argv[1:])

注意: 上面的都是需要运行环境的, 比如 php/python 之类都是要求用户本地必须要安装好执行二进制, 这无疑增大的用户处理使用的成本.

原生平台转发

排除掉所有需要安装执行的方案, 那么直接只有编译语言编译二进制处理的方案, 这里目前常见方案如下:

  • Golang: Google 的跨平台编译方案, 内部的高级处理比较简陋
  • Rust: Mozilla 的跨平台编译方案, 上手所有权概念比较复杂

尽可能减少用户使用成本, 甚至直接采用单个配置运行最好: run.exe server.ini

这里具体最后选中方案采用比较熟练的 Rust 开发, 模块库也相对比较广泛可以直接调用.

Cargo.toml 配置库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 其他略

[dependencies]
log = { version = "0.4" }
env_logger = { version = "0.11" }
native-tls = { version = "0.2.11" }
tokio = { version = "1", features = ["full"] }
tokio-native-tls = { version = "0.3.1" }
axum = { version = "0.7.5" }
axum-extra = { version = "0.9" }
hyper = { version = "1.3", features = ["full"] }
hyper-tls = { version = "0.6" }
hyper-util = { version = "0.1.3", features = ["client-legacy", "http2"] }
reqwest = { version = "0.12", features = ["http2", "native-tls", "stream"] }

之后就是业务逻辑代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
use axum::body::Body;
use axum::extract::{Request, State};
use axum::handler::Handler;
use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode};
use axum::response::Response;
use axum::Router;
use axum::routing::get;
use log::{error, info};
use reqwest::Method;

/// 客户端请求配置
#[derive(Clone)]
struct ClientConfig {
address: String,
cer: Vec<u8>,
key: Vec<u8>,
p12: Vec<u8>,
}

#[tokio::main]
async fn main() {
// todo:加载外部配置文件信息, 后续主要工作就是将这里的配置读取成配置加载
let address = "127.0.0.1:3000";
let remote = "https://192.168.1.100:22404";
let key_file = "2024.04.24.auto.key";
let p12_file = "2024.04.24.auto.p12";
let cer_file = "2024.04.24.auto.cer";

// 日志构建, 测试过程采用 debug 打印所有异常
let mut builder = env_logger::Builder::from_default_env();
builder.filter_level(log::LevelFilter::Debug);
builder.init();
info!("启动 CER 代理");
info!("已加载PEM: {}",cer_file);
info!("已加载KEY: {}",key_file);
info!("已加载P12: {}",p12_file);


// 构建共享数据
let cer_data = match tokio::fs::read(cer_file).await {
Ok(c) => c,
Err(e) => {
error!("{:?}",e);
std::process::exit(1);
}
};
let key_data = match tokio::fs::read(key_file).await {
Ok(c) => c,
Err(e) => {
error!("{:?}",e);
std::process::exit(1);
}
};

let p12_data = match tokio::fs::read(p12_file).await {
Ok(c) => c,
Err(e) => {
error!("{:?}",e);
std::process::exit(1);
}
};

let conf = ClientConfig {
address: remote.to_string(),
cer: cer_data,
key: key_data,
p12: p12_data,
};


// 挂起本地路由
let app = Router::new()
.route("/", get(process))
.route("/*path", get(process))
.with_state(conf);


// 挂起本地代理服务
let listener = match tokio::net::TcpListener::bind(&address).await {
Ok(l) => l,
Err(e) => {
error!("{:?}",e);
std::process::exit(1);
}
};

// axum 启动监听
info!("代理地址: {}",&address);
if let Err(e) = axum::serve(listener, app).await {
error!("{:?}",e);
std::process::exit(1);
}
}


/// 访问代理转发
async fn process(State(config): State<ClientConfig>, mut req: Request) -> Result<Response, StatusCode> {
let path = req.uri().path();
let url = req
.uri()
.path_and_query()
.map(|v| v.as_str())
.unwrap_or(path);

// 挂起请求
let remote = format!("{}{}", &config.address, url);
info!("挂起代理: {} -> {}",url,remote);


// 推送请求
let cert = match reqwest::Certificate::from_pem(&config.cer.as_slice()) {
Ok(c) => c,
Err(e) => {
error!("{:?}",e);
return Err(StatusCode::BAD_REQUEST);
}
};

let key = match reqwest::Identity::from_pkcs8_pem(&config.cer.as_slice(), &config.key.as_slice()) {
Ok(k) => k,
Err(e) => {
error!("{:?}",e);
return Err(StatusCode::BAD_REQUEST);
}
};

// P12证书暂时用不到
// let p12 = match reqwest::Identity::from_pkcs12_der(&config.p12.as_slice(), "") {
// Ok(k) => k,
// Err(e) => {
// error!("{:?}",e);
// return Err(StatusCode::BAD_REQUEST);
// }
// };


return match reqwest::ClientBuilder::new()
.add_root_certificate(cert)
.identity(key)
.https_only(true)
.danger_accept_invalid_certs(true)
.danger_accept_invalid_hostnames(true)
.build() {
Ok(c) => {
match c.request(Method::GET, remote).send().await {
Ok(r) => {
// Here the mapping of headers is required due to reqwest and axum differ on the http crate versions
let mut headers = HeaderMap::with_capacity(r.headers().len());
headers.extend(r.headers().into_iter().map(|(name, value)| {
let name = HeaderName::from_bytes(name.as_ref()).unwrap();
let value = HeaderValue::from_bytes(value.as_ref()).unwrap();
(name, value)
}));

// write remote headers
let mut builder = Response::builder()
.status(r.status().as_u16());
for header in headers {
if header.0.is_some() {
if let Some(active) = builder.headers_mut() {
active.insert(header.0.unwrap(), header.1);
}
}
}

match builder.body(Body::from_stream(r.bytes_stream())) {
Ok(res) => Ok(res),
Err(e) => {
error!("{:?}",e);
return Err(StatusCode::BAD_REQUEST);
}
}
}
Err(e) => {
error!("{:?}",e);
return Err(StatusCode::BAD_REQUEST);
}
}
}
Err(e) => {
error!("{:?}",e);
Err(StatusCode::BAD_REQUEST)
}
};
}

后续打出各自平台二进制执行文件就能直接本地只用转发了.

上面那些样例实际上都有隐藏问题, 那就是仅仅支持 GET 请求的转发

这里先实现初版 Rust 的转发功能, 确认最后请求能够被转发内网服务当中.

直接访问 http://127.0.0.1:3000 就是被代理起来的本地穿透内网服务