部署 自签证书内网穿透 MeteorCat 2024-04-24 2025-12-05 自签证书内网穿透 这里是基于内网的自签名证书对外开放服务功能, 主要流程:
Linux 定时自动 生成自签证书生成放置于 Nginx 特定目录, 建议每日|每月自动更新证书数据
在生成证书的同时挂载自签证书提供对外服务, 所有服务都必须经由自签证书访问
在生成证书的同时写入到公钥和证书数据到 Redis 之中保存
对外挂起单独 Web 服务提供登录服务用于统一登录授权
用户登录认证之后服务器返回 地址+端口+证书+公钥进行 从而保存到本地挂起 Web 通过自签证书访问服务
用户从登录多个返回授权服务列表可以直接访问到内部自签名服务
具体的请求时序图如下:
这种访问方式可以在外网防止中间人窃取访问数据, 从而保证内部服务的安全可靠性; 这里采用 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_sslCER_PATH="/etc/nginx/auto_ssl" # Nginx 放置的配置加载目录, 注意必须要创建好目录 # mkdir -p /etc/nginx/auto.dNGINX_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="guixin2010@live.cn" # 这里先测试采用按照当天生成证书确定脚本可用, 也就是不断在当天产生 `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 { listen __PORT__ ssl http2; server_name _; ssl_protocols TLSv1 TLSv1.1 TLSv1.2 TLSv1.3 ; ssl_prefer_server_ciphers on ; ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH" ; ssl_certificate __CER__; ssl_certificate_key __KEY__; 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 { 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 { "username" : "meteorcat" , "password" : "meteorcat" } { "error" : 0 , "message" : "success" , "data" : { "uid" : 1 , "token" : "MD5TOKEN" , "servers" : [ { "id" : 1 , "address" : "https://192.168.1.100:22404" , "cer" : "CER_DATA" , "key" : "KEY_DATA" , "p12" : "P12_DATA" } ] } } { "token" : "MD5TOKEN" , "id" : 1 } { "token" : "MD5TOKEN" , "id" : 1 , "address" : "https://192.168.1.100:22404" , "cer" : "CER_DATA" , "key" : "KEY_DATA" , "p12" : "P12_DATA" } { "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()); 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 <>(); @PostMapping("/login") public Object login (@RequestBody UserForm userForm) { logger.debug("User: {}" , userForm); if (!"meteorcat" .equals(userForm.username) || !"meteorcat" .equals(userForm.password)) { return response(1 , "找不到玩家信息" , null ); } Integer uid = 1 ; 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 <>(); 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 + '\'' + '}' ; } } @PostMapping("/check") public Object check (@RequestBody CheckForm checkForm) { logger.debug("Check: {}" , checkForm); String token = checkForm.token; if (!online.containsKey(token)) { return response(1 , "用户登录授权过期, 请重试" , null ); } 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 import sysimport requestsdef 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 () { 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" ; 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 ); } }; 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); } }; 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) => { 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) })); 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 就是被代理起来的本地穿透内网服务