自签证书内网穿透
这里是基于内网的自签名证书对外开放服务功能, 主要流程:
- Linux
定时自动生成自签证书生成放置于Nginx特定目录, 建议每日|每月自动更新证书数据 - 在生成证书的同时挂载自签证书提供对外服务, 所有服务都必须经由自签证书访问
- 在生成证书的同时写入到公钥和证书数据到
Redis之中保存 - 对外挂起单独
Web服务提供登录服务用于统一登录授权 - 用户登录认证之后服务器返回
地址+端口+证书+公钥进行从而保存到本地挂起Web通过自签证书访问服务 - 用户从登录多个返回授权服务列表可以直接访问到内部自签名服务
具体的请求时序图如下:
这种访问方式可以在外网防止中间人窃取访问数据, 从而保证内部服务的安全可靠性;
这里采用 Python|Bash 脚本处理都行,
另外还需要知道怎么 构建自签名证书.
上面的构建自签证书需要手动输入必要的信息, 这里采用连贯命令直接单行全部编写( 先测试脚本: /etc/nginx/auto.cer.sh ):
#!/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 用来文本替换写入到具体配置目录:
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 当中引入启动转发自签证书服务:
# 注意是 http 区块
http {
# 前面代码略
# 引入自签证书服务模块
include /etc/nginx/auto.d/*.conf;
}
测试执行脚本确认挂起自签证书协议, 确认是否访问安全:
# 只授权给 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 服务, 需要和之前动态证书部署的服务区分开来, 这里展示该接口请求和响应数据:
// [用户端:请求] 登录接口: 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 的授权服务监听:
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 版本转发
<?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 版本转发
# !/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 配置库:
# 其他略
[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"] }
之后就是业务逻辑代码:
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 就是被代理起来的本地穿透内网服务