HTTPS 已经是互联网服务准入的基本门槛了,同时 443 端口作为 HTTPS 请求的默认端口,在虚拟主机服务的支持下,搭配的天衣无缝。

但是 Trojan 比较特殊,因为它的工作方式导致了其必须直接对接流量入口,否则其协议无法被服务端正常识别,同时为了增加服务的隐蔽性,一般会把它配置在 443 端口。但是 443 端口只有一个,虽然 Trojan 提供了「非标请求」的转发功能,但是毕竟是一个新生事物,所有流量都过它手,在稳定、性能、灵活等等方面都不够好,而且还不支持 TLS 转发。

在此背景下,我设计了一套 Trojan 和 Nginx 公用 443 端口的方案,同时也支持 Docker 部署。

首页说一下最后的成品架构:

graph LR A["Client"] -->|HTTPS Request| B("Nginx") B --> C{"dispatch"} C -->D["Trojan"] C -->E["V2Ray"] C -->F["Web"] D -->|Invalid Requst| G["Default Page"] E -->|Invalid Requst| G["Default Page"]

主要的工作就是在 dispatch 阶段的流量分发。因为都是 HTTPS 流量,所以在确定分发策略前得有一点相关知识。

在虚拟主机流行前,一个服务 IP 只会有一个 TLS 服务站点,只会有一张 SSL 证书,所以请求来了就只有一张证书,没得选直接用就好了。

后面虚拟主机流行起来,一个服务 IP 可以有多个 TLS 服务站点,那就有多个 SSL 证书,那怎么明确这次请求用哪张证书呢?

于是就有了 SNI(TLS 服务器名称指示),它要求在一个 IP 有多个 TLS 服务站点的情况下,客户端在初始 TLS 握手期间指定要连接到哪个站点,数据上的实现就是在 Client Hello 阶段里面新增一个 server_name 字段。

TLS SNI

所以它是现在支持同一个 IP 上配置多个 HTTPS 主机的基础:

server {
    listen          443 ssl;
    server_name     www.chengxiaobai.cn;
    ssl_certificate www.chengxiaobai.cn.crt;
}

server {
    listen          443 ssl;
    server_name     nothing.chengxiaobai.cn;
    ssl_certificate nothing.chengxiaobai.cn.crt;
}

我们的流量分发策略也是基于这个数据来的。

首先得明确,Trojan 是无法通过 Nginx 在 7 层进行代理的,所以它设定必须在流量入口,Nginx 都只能挂在它的后面。

但是我仍然有个疑惑:为什么不能在 http 模块下使用 proxy_pass 进行 HTTPS 反向代理 Trojan?

我尝试了一下,发现是建立连接失败了,我怀疑是 Trojan 本身对建立连接的过程做了数据校验或者本身没有完全实现 HTTP 协议(HTTP CONNECT 部分)?

http 模块的 proxy_pass 是在七层做的转发,可能需要挂载证书进行流量的解密识别,再加密请求,相当于做了一次中间人(MITM, Man-in-the-Middle)代理,又或者因为使用 HTTP CONNECT 来建立隧道而导致的链接失败?里面涉及到的东西就太多了,有空翻源码看看吧。

那无法在 7 层转发,就在 4 层转发吧。

Nginx 支持基于 SNI 的 4 层转发。简单说就是:识别 SNI 信息,然后直接转发 TCP/UDP 数据流。这个可以比 7 层的虚拟主机转发厉害太多了,该功能由 ngx_stream_ssl_preread_module 模块提供,但是 Nginx 默认不启用该模块,配置起来也很简单,需要注意的是该模块属于 stream ,不是大家常用的 http

Nginx 配置:

user  nginx;

pid   /var/run/nginx.pid;

# 其他配置保持默认即可

# 流量转发核心配置
stream {
    # 这里就是 SNI 识别,将域名映射成一个配置名
    map $ssl_preread_server_name $backend_name {
        web.cn.chengxiaobai web;
        vmess.cn.chengxiaobai vmess;
        trojan.cn.chengxiaobai trojan;
    # 域名都不匹配情况下的默认值
        default web;
    }

    # web,配置转发详情
    upstream web {
        server 127.0.0.1:10240;
    }

    # trojan,配置转发详情
    upstream trojan {
        server 127.0.0.1:10241;
    }

    # vmess,配置转发详情
    upstream vmess {
        server 127.0.0.1:10242;
    }

    # 监听 443 并开启 ssl_preread
    server {
        listen 443 reuseport;
        listen [::]:443 reuseport;
        proxy_pass  $backend_name;
        ssl_preread on;
    }
}

http {
  # 这块保持不变即可
}

简简单单几行配置,就完成了流量分发,最后将 Trojan 和 V2Ray 的配置端口修改一下和上面的配置保持一致即可。

V2Ray 搭配的 Nginx 配置,用于解密 TSL 加密信息提升整体链路性能,并且实现站点伪装:

server {
    # 开启 HTTP2 支持
    listen 10242 ssl http2;
    server_name  vmess.cn.chengxiaobai;

    gzip on;
    gzip_http_version 1.1;
    gzip_vary on;
    gzip_comp_level 6;
    gzip_proxied any;
    gzip_types text/plain text/css application/json application/javascript application/x-javascript text/javascript;

    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_certificate      /etc/ssl/vmess.cn.chengxiaobai.crt;
    ssl_certificate_key  /etc/ssl/private/vmess.cn.chengxiaobai.key;
    ssl_session_cache    shared:SSL:1m;
    ssl_session_timeout  5m;
    ssl_ciphers  HIGH:!aNULL:!MD5;
    ssl_prefer_server_ciphers  on;

    # WS 协议转发
    location /fTY9Bx7c {
        proxy_redirect off;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $http_host;
        proxy_pass http://127.0.0.1:16881;
    }

    # 非标请求转发伪装
    location / {
       proxy_pass http://127.0.0.1:80;
    }
}

V2Ray 配置:

{
  "api": {
    "tag": "api"
  },
  "dns": {
    "servers": [
      "https://1.1.1.1/dns-query",
      "https://8.8.8.8/dns-query",
      "localhost"
    ]
  },
  "inbounds": [
    {
      "allocate": {
        "strategy": "always"
      },
      "port": 16881,
      "protocol": "vmess",
      "settings": {
        "clients": [
          {
            "alterId": 64,
            "id": "唯一ID",
            "level": 1
          }
        ]
      },
      "sniffing": {
        "enabled": false
      },
      "streamSettings": {
        "network": "ws",
        "security": "none", // 注意这里没有 TLS,因为 TSL 已经交给 Nginx 处理
        "wsSettings": {
          "path": "/fTY9Bx7c" // 可以复杂一点,避免嗅探
        }
      }
    }
  ],
  "log": {
    "access": "/var/log/v2ray/access.log",
    "error": "/var/log/v2ray/error.log",
    "loglevel": "error"
  },
  "outbounds": [
    {
      "protocol": "freedom",
      "settings": {
        "domainStrategy": "AsIs"
      },
      "tag": "direct"
    },
    {
      "protocol": "blackhole",
      "settings": {
        "response": {
          "type": "none"
        }
      },
      "tag": "blocked"
    }
  ],
  "policy": {},
  "reverse": {},
  "routing": {
    "domainStrategy": "IPIfNonMatch",
    "rules": [
      {
        "ip": [
          "0.0.0.0/8",
          "10.0.0.0/8",
          "100.64.0.0/10",
          "127.0.0.0/8",
          "169.254.0.0/16",
          "172.16.0.0/12",
          "192.0.0.0/24",
          "192.0.2.0/24",
          "192.168.0.0/16",
          "198.18.0.0/15",
          "198.51.100.0/24",
          "203.0.113.0/24",
          "::1/128",
          "fc00::/7",
          "fe80::/10"
        ],
        "outboundTag": "blocked",
        "type": "field"
      }
    ]
  },
  "stats": {},
  "transport": {}
}

Trojan 配置:

{
  "run_type": "server",
  "local_addr": "127.0.0.1",
  "local_port": 10241,
  "remote_addr": "127.0.0.1",//转发回 nginx 默认页面
  "remote_port": 80,
  "password": [
    "密码"
  ],
  "log_level": 3,
  "ssl": {
    "cert": "证书地址.crt",
    "key": "证书地址.key",
    "key_password": "",
    "cipher": "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384",
    "cipher_tls13": "TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256:TLS_AES_256_GCM_SHA384",
    "prefer_server_cipher": true,
    "alpn": [
      "http/1.1"
    ],
    "alpn_port_override": {
      "h2": 81
    },
    "reuse_session": true,
    "session_ticket": false,
    "session_timeout": 600,
    "plain_http_response": "",
    "curves": "",
    "dhparam": ""
  },
  "tcp": {
    "prefer_ipv4": false,
    "no_delay": true,
    "keep_alive": true,
    "reuse_port": false,
    "fast_open": false,
    "fast_open_qlen": 20
  },
  "mysql": {
    "enabled": false,
    "server_addr": "127.0.0.1",
    "server_port": 3306,
    "database": "trojan",
    "username": "trojan",
    "password": "",
    "cafile": ""
  }
}

其他服务按照之前使用正常配置即可,毕竟 Nginx 可以转发到 Trojan,还可以转发到 Nginx 自身从而实现其他服务的反向代理,当前笔者使用的流量分发架构:

graph LR A["Client"] -->|HTTPS Request| B("Nginx") B -->C{"dispatch"} C -->|TCP Forward| D["Trojan"] C -->|TCP Forward|E0["Nginx WS Port"] C -->|TCP Forward|F0["Nginx Web Port"] E0-->|WS Protocol|E["V2Ray"] E0-->|Invalid Requst| G0["Nginx Default Port"] F0-->|HTTP|F["PHP"] D -->|Invalid Requst| G0 G0-->G["Default Page"]

Nginx 层统一管理收敛流量入口,整个主机只用开启 443 和 SSH 端口即可,同时各个模块都做了伪装,「非标请求」看到的都是正常的页面,而且 WS 协议的 CDN 大法完美支持。

同时得益于 Nginx 的优秀性能和对 HTTP 协议的支持力度,全部请求都可以 HTTP2。

ALL HTTP2

Youtube 速度相比之前 Trojan 直连没有明显差异。

Youtube Speed

把专业的事交给专业的人 😎 。


最后补充一个「获取 real client IP 为 127.0.0.1 」问题的解决方案,其实这个问题已经完全可以独立一篇文章来说明了,为了方便大家阅读,就放在一起了。

这个是我疏忽了,因为我的 Web 服务就是本博客,最前面是 CDN,它已经在 header 里面包含了 real client IP ,而我针对这种情况做了适配,所以这个问题没有暴露出来。

我去掉 CDN 后发现获取的 real client IP 确实是 127.0.0.1。这是为什么呢?

首先得介绍下俩个知识点:

第一,一般获取 real client IP 都是通过 header 里面的 remote_addr 或者 http_x_forwarded_for

先说下 remote_addr 的来源,它不是 HTTP 特有的,是在建立 TCP 链接的时候就确定的,所以它不容易伪造。

graph LR A["Client"] -->|TCP: remote_addr| B("TCP") B --> C["HTTP"]

http_x_forwarded_for 是 HTTP 特有的,每经过一次七层代理就会往后 append 一次代理服务器 IP,所以的格式为: 用户真实IP(remote_addr),代理服务器 1 的 IP,代理服务器 2 的 IP ......

graph LR A["Client"] -->|TCP: remote_addr| B("TCP") B --> |HTTP1: http_x_forwarded_for| C["HTTP Proxy 1"] C --> |HTTP2: http_x_forwarded_for| D["HTTP Proxy 2"] D --> |HTTP3: http_x_forwarded_for| E["HTTP Proxy n"]

第二,4 层 TCP 负载均衡/代理的机制。

Nginx 直接转发 TCP 请求,实际上是针对各个后端服务各新起了一个 TCP 链接,直接在 TCP 层面转发数据,最大层度保证了数据完整性并且性能更高。但是因为前面介绍过的 remote_addr 的确定时机导致各个后端拿到的数据其实是 Nginx 的 IP。

graph LR A["Client"] -->|TCP1: remote_addr| B("Nginx") B -->C{"dispatch"} C -->|TCP2: remote_addr| D["Trojan"] C -->|TCP3: remote_addr| E["V2Ray"] C -->|TCP4: remote_addr| F["Web"]

这是一个客观存在的问题,各个 4 层代理软件都有这个问题。

我查了下 Nginx 的官方文档,Nginx Plus 很早就给了方案,但是那是付费版,现在也免费了,就是两种方案。

方案一:使用 Proxy Protocol 协议进行通信。

前面说过各个 4 层代理软件都 real client IP 问题,所以针对这种情况专门设计了一种新的通信协议:代理协议(Proxy Protocol) 用来传递用户真实 IP 。

代理协议(Proxy Protocol),是 HAProxy 的作者 Willy Tarreau 于2010年开发和设计的一个 Internet 协议,通过为 TCP 添加一个很小的头信息,来方便的传递客户端信息(协议栈、源IP、目的IP、源端口、目的端口等),在网络情况复杂又需要获取用户真实 IP 时非常有用。其本质是在三次握手结束后由代理在连接中插入了一个携带了原始连接四元组信息的数据包。

Nginx 也支持了 Proxy Protocol,所以只用在转发端和接受端都配置上代理协议支持即可。

在转发层新增 proxy_protocol 配置。

stream {
    server {
        listen 443 reuseport;
        listen [::]:443 reuseport;
        proxy_pass  $backend_name;
        proxy_protocol on;
        ssl_preread on;
    }
}

同时后端服务配套也要在 listen 规则里面配置接受 proxy_protocol

server {
    listen 10242 ssl proxy_protocol;# 注意这里新增了协议类型
    server_name  vmess.cn.chengxiaobai;

    # ...  
    # 省略一些配置示例
    # ...
}

但是该方案也有弊端,就是一旦接受端配置了代理协议,那就不再支持非代理协议的请求。

Proxy Protocol 的接收端必须在接收到完整有效的 Proxy Protocol 头部后才能开始处理连接数据。因此对于服务器的同一个监听端口,不兼容非 Proxy Protocol 的连接。如果服务器接收到的第一个数据包不符合 Proxy Protocol 的格式,那么服务器会直接终止连接。

方案二:使用 proxy_bind 语法。

user root; # 注意这里的权限要求

#requires additional OS-level configuration!

#https://WWW.kernel.org/doc/Documentation/networking/tproxy.txt

stream {
    server {
        listen 443 reuseport;
        listen [::]:443 reuseport;
        proxy_bind $remote_addr transparent; # 此次新增
        proxy_pass  $backend_name;
        ssl_preread on;
    }
}

这个方案不修改通信协议,但是 Nginx 本身需要 root 用户权限来运行,并且需要配置后端服务机器上的本地路由表来起到 IP 重定向的作用。

真实的运转流程是这样的:这个配置修改了 TCP 连接的源地址为 real client IP 而非 Nginx 自己的 IP,但是这样后端服务返回数据的时候会有异常,因为真实的 client 并没有和后端服务建立起通信链接,所以必须在后端服务上配置路由把请求响应流量转发到 Nginx 机器上,同时 Nginx 机器本地配置 IP 拦截规则,将改流量重定向到 Nginx 服务上,完成一次「流量劫持」。

类比下大家常知的的中间人(MITM, Man-in-the-Middle)攻击更容易理解,相当于 Nginx 做了一次中间人,但是因为是 4 层 TCP 协议,所以比 7 层的 HTTP 协议用到的工具更底层一点。

小结一下,不建议大家使用方案二,毕竟安全性和操作性都不符合最佳实践。目前 V2Ray 和 Trojan 对用户的真实 IP 其实没有要求,如果你强依赖用户的真实 IP(更多的依赖场景是在 Web 服务上?),建议使用方案一。

参考资料:

TCP/UDP Load Balancing with NGINX: Overview, Tips, and Tricks

Accepting the PROXY Protocol

nginx 的透明代理实现

Nginx real client IP to TCP stream backend

七层LB-NGINX 客户端获取协议Proxy Protocol介绍

Nginx基于TCP/UDP端口的四层负载均衡(stream模块)配置梳理

本作品由 程小白 创作,采用 知识共享署名-相同方式共享 4.0 国际许可协议 进行许可,可自由转载、引用但需署名作者且注明文章出处。
原文地址:https://www.chengxiaobai.cn/record/trojan-shared-443-port-scheme.html