Netty4 单端口协议嗅探 TCP 网关:一个公网端口同时转发 HTTP/HTTPS 与数据库流量(含 Docker 部署与 token 鉴权实战)
痛点:内网服务多、公网端口贵、安全要求高?Nginx 只懂 HTTP,数据库/SSH 需要单独端口映射,还要防扫描、防渗透。
这篇实战文章从架构设计、源码解析到 Docker 部署,完整拆解一款基于 Netty4 的单端口协议嗅探 TCP 网关——一个端口搞定 HTTP、HTTPS、数据库、SSH 全流量转发,还带前置 token 鉴权,生产可用的那种。
目录
- 一、为什么要做单端口网关?
- 二、项目简介与核心能力
- 三、架构设计与协议识别原理
- 四、快速开始:本地 jar 五分钟跑起来
- 五、配置文件详解(YAML + 环境变量)
- 六、客户端接入:数据库工具零改造过鉴权
- 七、Docker 容器化部署与端口映射
- 八、源码解析:三个 Handler 的硬核细节
- 九、实战踩坑与问题排查
- 十、总结与适用场景
一、为什么要做单端口网关?
先抛一个运维和开发都会遇到的场景:
- 公网 IP 只有一个,端口却要开放 Nginx(80/443)、数据库(1521/3306/5236)、SSH(22)……安全和运维成本都很高;
- 直接暴露数据库端口到公网,等于把数据库密码送到黑客嘴边,扫描器 24 小时在打;
- 一个协议一个端口,防火墙策略、证书、监控全都翻倍。
单端口网关(单端口多协议转发) 的思路是:公网只开放一个 TCP 端口,网关读取连接的第一个数据包做协议嗅探,然后按协议把连接转发到不同的内网后端:
| 检测到的协议 | 转发目标 | 行为 |
|---|---|---|
| HTTP / HTTPS(TLS) | 内网 Nginx | 透明透传(不解析、不解密、不改写) |
| 其他普通 TCP 流量(数据库、SSH、自定义协议) | 可配置目标 IP+端口 | 透明透传,可选前置 magic-token 鉴权 |
由于网关只"嗅探 + 搬运"原始字节,它对协议零认知,所以 MySQL、PostgreSQL、Oracle、达梦、SSH、RDP、自定义二进制协议都能长连接透传。
二、项目简介与核心能力
项目 jquick-hub 是一个基于 Netty 4 的高性能协议嗅探 TCP 网关,核心特性:
- 单端口多协议路由:首包嗅探分流 HTTP/HTTPS 与普通 TCP 流量;
- 零拷贝透明透传:数据只搬运、不解析、不解密、不改写;
- 长连接友好:
- 后端建连期间数据零丢包缓冲,就绪后原子刷出;
- 任一端断开,另一端联动关闭;
- 背压控制:对端写缓冲满时暂停本端读取(
setAutoRead),内存不失控; - 引用计数安全,无缓冲泄漏;
- 前置 magic-token 鉴权(针对普通 TCP 流量):
- 定长 token 拼在首个业务包前,网关校验后剥离再转发;
MessageDigest.isEqual常量时间比较,防时序侧信道;- 鉴权失败/超时静默关闭,不返回任何应答,防信息泄露;
- 日志只输出掩码
前4****后2,绝不打印明文 token;
- 优雅停机:SIGTERM/SIGINT 触发 JVM shutdown hook;
- 可配置缓冲区泄漏检测(SIMPLE/ADVANCED/PARANOID/DISABLED);
- 内置迷你代理客户端:Navicat、DBeaver、psql、ssh 等原生工具零改造接入。
环境要求:JDK 1.8+、Maven 3.x;Docker 部署需要 Docker 19.03+(多阶段构建)。
三、架构设计与协议识别原理
公网 内网
+----------------+ 单端口 +------------------+ 透传 +----------------+
| HTTP/HTTPS +------------->+ +----------->+ Nginx |
| 客户端 | | jquick-hub | | (:52000) |
+----------------+ | TCP 网关 | +----------------+
| |
+----------------+ token+数据 | | 剥离token +----------------+
| DB/SSH 工具 | | | | 其他后端 |
| | | 本地代理 | (前置token鉴权) |----------->+ (:5236) |
| v +------------->+ | +----------------+
| 本地端口 | +------------------+
+----------------+
每条连接的处理器管线(Pipeline):
JQuickProtocolDetectHandler → JQuickAuthPrefixHandler → JQuickTcpRelayHandler
协议探测规则(ProtocolDetectHandler,只窥视不消费,getXXX 接口不改 readerIndex):
- HTTPS/TLS:首字节
0x16(TLS Handshake,RFC 5246)→ 1 个字节即可判定; - HTTP:前缀匹配已知方法(GET/POST/PUT/DELETE/HEAD/OPTIONS/PATCH/CONNECT/TRACE),且方法名后紧跟空格
0x20,大小写不敏感; - 其他流量:累积满 16 个探测字节仍不匹配任何特征 → 判定为普通 TCP 流量;
- 探测超时:超时后若已收到数据 → 按普通 TCP 流量转发;零数据 → 关闭连接。
这里有个很关键的细节:TCP 拆包。首包可能只收到 "GET" 三个字节(没有空格),此时不能否定 HTTP 可能,探测器会返回 UNKNOWN 继续等待,只有收到 "GET "(带空格)才确认是 HTTP——这正是很多"半吊子"嗅探器误判的根源。
四、快速开始:本地 jar 五分钟跑起来
mvn clean package
java -jar target/tcp-gateway.jar
启动日志:
TCP 流量网关启动成功
公网监听: 0.0.0.0:18080 (backlog=1024, so_timeout=5000ms)
Nginx 转发目标: 127.0.0.1:52000
普通TCP转发目标: 127.0.0.1:5236
普通TCP鉴权: 启用 (token长度=32, 超时=5000ms)
配置文件查找优先级:
命令行参数路径 → 环境变量 GATEWAY_CONFIG → 当前目录 ./gateway.yaml → classpath 内置默认
五、配置文件详解(YAML + 环境变量)
一份带注释的完整配置:
gateway:
listen:
host: 0.0.0.0 # 监听网卡 IP(默认所有网卡)
port: 18080 # 公网监听端口
backlog: 1024 # TCP 半连接/全连接队列
so_timeout: 5000 # 连接超时(ms)
probe_timeout_ms: 5000 # 首包探测等待上限(ms)
nginx:
target:
host: 127.0.0.1 # 内网 Nginx IP
port: 52000 # Nginx 端口
other:
traffic:
target:
host: 127.0.0.1 # 其他流量转发目标
port: 5236
auth:
enable: true # 鉴权开关
token-prefix-len: 32 # token 定长(字节)
timeout_ms: 5000 # 鉴权超时,静默关闭
valid-tokens:
- "0123456789abcdef0123456789abcdef"
netty:
boss:
threads: 1 # acceptor 线程
worker:
threads: 0 # I/O 线程,0 = 自动(CPU核数*2)
buffer:
leak:
detection: SIMPLE # SIMPLE | ADVANCED | PARANOID | DISABLED
默认值速查表:
| 配置项 | 默认值 | 说明 |
|---|---|---|
gateway.listen.host | 0.0.0.0 | 监听网卡 |
gateway.listen.port | 8080 | 公网端口 |
gateway.listen.backlog | 1024 | 必须为正整数 |
gateway.listen.so_timeout | 5000 | ms |
gateway.listen.probe_timeout_ms | 取 so_timeout | ≤0 时回退 5000 |
nginx.target.host/port | 127.0.0.1/80 | |
other.traffic.target.host/port | 127.0.0.1/9000 | |
other.traffic.auth.enable | true | 仅当存在 auth 段时生效;老配置无 auth 段则默认关闭(向后兼容) |
other.traffic.auth.token-prefix-len | 32 | 字节 |
other.traffic.auth.timeout_ms | 5000 | ms |
netty.boss.threads | 1 | 正整数 |
netty.worker.threads | 0(自动) | 0 = CPU 核数 × 2 |
netty.buffer.leak.detection | SIMPLE |
启动校验:鉴权开启时,若未配置任何合法 token,或 token 长度与
token-prefix-len不一致,程序拒绝启动;端口必须在 1–65535。
六、客户端接入:数据库工具零改造过鉴权
数据库连接工具(Navicat、DBeaver、psql)和 ssh 客户端不能自定义请求头,也无法在应用层协议里塞 token。解决方案是本地起一个迷你代理,在连接的第一个业务包前自动拼 token:
DB工具 ──连接──▶ 本地代理(监听 local.ports) ──首包前拼token──▶ 公网网关 ──剥离token──▶ 内网数据库
client/GatewayClient.java(零第三方依赖)配置 client/client.properties:
local.ports=15236 # 本地监听端口,逗号分隔可多个
gw.host=127.0.0.1 # 网关地址
gw.port=18080 # 网关端口
token=0123456789abcdef0123456789abcdef # 必须与网关 valid-token 一致
启动:
javac -encoding UTF-8 GatewayClient.java
java -Dfile.encoding=UTF-8 GatewayClient
之后数据库工具只需连接本地端口 15236,无需任何改造。命令行参数 -ports/-host/-port/-token 可覆盖配置文件。
七、Docker 容器化部署与端口映射
7.1 镜像设计原则
- 不写 EXPOSE:EXPOSE 只是文档声明,不固化任何端口,端口完全由运行时
-p决定; - 不硬编码 IP/端口:镜像内置
gateway.yaml仅为占位默认值,全部运行时参数通过环境变量传入; - JRE 基础镜像:Maven+JDK 构建阶段产出 fat-jar,运行时使用 Eclipse Temurin JRE,镜像更小更安全;
- 优先级:环境变量 > YAML > 代码默认值。
7.2 环境变量覆盖清单
| 环境变量 | 覆盖配置 | 默认值 |
|---|---|---|
JQUICK_LISTEN_HOST | gateway.listen.host | 0.0.0.0 |
JQUICK_LISTEN_PORT | gateway.listen.port | 8080 |
JQUICK_LISTEN_BACKLOG | gateway.listen.backlog | 1024 |
JQUICK_LISTEN_SO_TIMEOUT | gateway.listen.so_timeout | 5000 |
JQUICK_LISTEN_PROBE_TIMEOUT | gateway.listen.probe_timeout_ms | so_timeout |
JQUICK_NGINX_HOST / JQUICK_NGINX_PORT | nginx.target.host/port | 127.0.0.1/80 |
JQUICK_OTHER_HOST / JQUICK_OTHER_PORT | other.traffic.target.host/port | 127.0.0.1/9000 |
JQUICK_AUTH_ENABLE | other.traffic.auth.enable | true |
JQUICK_AUTH_TOKEN_PREFIX_LEN | other.traffic.auth.token-prefix-len | 32 |
JQUICK_AUTH_TIMEOUT_MS | other.traffic.auth.timeout_ms | 5000 |
JQUICK_AUTH_TOKENS | other.traffic.auth.valid-tokens | 逗号分隔 |
JQUICK_NETTY_BOSS_THREADS / JQUICK_NETTY_WORKER_THREADS | netty.boss/worker.threads | 1/0 |
JQUICK_LEAK_DETECTION | netty.buffer.leak.detection | SIMPLE |
7.3 Dockerfile(多阶段构建)
# Stage 1: Maven + JDK 8 构建 fat-jar
FROM maven:3.9-eclipse-temurin-8 AS builder
WORKDIR /build
COPY pom.xml .
COPY src ./src
RUN mvn -q -DskipTests package
# Stage 2: JRE 运行时(无 EXPOSE,端口由运行时 -p 决定)
FROM eclipse-temurin:8-jre-jammy
WORKDIR /app
COPY --from=builder /build/target/tcp-gateway.jar /app/tcp-gateway.jar
COPY docker/gateway.yaml /app/gateway.yaml
ENV TZ=Asia/Shanghai LANG=C.UTF-8
ENTRYPOINT ["java", "-jar", "/app/tcp-gateway.jar"]
CMD ["/app/gateway.yaml"]
7.4 构建与启动(重点:host.docker.internal 与端口映射)
docker build -t jquick-hub:1.0.0 .
容器内访问宿主机服务必须用 host.docker.internal:容器内的 127.0.0.1 是容器自己,不是宿主机!Nginx、数据库如果跑在宿主机上,要这样配:
docker run -d --name jquick-hub --restart unless-stopped \
-p 18080:18080 \
-e JQUICK_LISTEN_PORT=18080 \
-e JQUICK_NGINX_HOST=host.docker.internal -e JQUICK_NGINX_PORT=52000 \
-e JQUICK_OTHER_HOST=host.docker.internal -e JQUICK_OTHER_PORT=5236 \
-e JQUICK_AUTH_ENABLE=true -e JQUICK_AUTH_TOKEN_PREFIX_LEN=32 \
-e JQUICK_AUTH_TOKENS=0123456789abcdef0123456789abcdef \
jquick-hub:1.0.0
端口映射规则(-p 宿主机端口:容器端口):
- 容器端口必须等于
JQUICK_LISTEN_PORT(JVM 在容器内实际监听的端口); - 宿主机端口可自由选择,如
-p 8080:18080表示宿主机 8080 → 容器 18080,客户端连宿主机 8080 即可; - 网关是单端口嗅探分流,一条映射就能同时暴露 HTTP/HTTPS 和普通 TCP 流量;多监听端口才需要多条
-p; - Linux 下访问宿主机需要追加
--add-host=host.docker.internal:host-gateway。
7.5 docker-compose 一键部署
services:
jquick-hub:
build:
context: .
dockerfile: Dockerfile
image: jquick-hub:1.0.0
container_name: jquick-hub
restart: unless-stopped
ports:
- "18080:18080"
environment:
JQUICK_LISTEN_PORT: "18080"
JQUICK_NGINX_HOST: "host.docker.internal"
JQUICK_NGINX_PORT: "52000"
JQUICK_OTHER_HOST: "host.docker.internal"
JQUICK_OTHER_PORT: "5236"
JQUICK_AUTH_ENABLE: "true"
JQUICK_AUTH_TOKEN_PREFIX_LEN: "32"
JQUICK_AUTH_TOKENS: "0123456789abcdef0123456789abcdef"
# Linux 访问宿主机:
# extra_hosts:
# - "host.docker.internal:host-gateway"
docker compose up -d --build
docker compose logs -f jquick-hub
八、源码解析:三个 Handler 的硬核细节
8.1 ProtocolDetectHandler:只窥视不消费
探测期累积缓冲 pending,判定只读(getUnsignedByte/getByte),不改 readerIndex,判定完成后把整段原始字节原样移交,保证零丢包。几个容易踩坑的点:
- 零拷贝接管:pending 为空时直接接管
ByteBuf in,不拷贝; - 拆包合并:pending 非空时合并到新缓冲,释放旧缓冲;
- 防灌爆:累积超过 64KB 仍未判定,直接按普通 TCP 流量处理,防止恶意客户端疯狂灌数据拖垮内存。
8.2 AuthPrefixHandler:常量时间比较 + 静默关闭
// 只读窥视前 tokenLen 字节
byte[] head = new byte[tokenLen];
authBuffer.getBytes(authBuffer.readerIndex(), head);
// 常量时间比较,防时序侧信道
for (byte[] t : validTokens) {
if (t.length == head.length && MessageDigest.isEqual(head, t)) {
// 通过 → 剥离 token,剩余数据 slice+retain 零拷贝透传
}
}
// 失败 → 静默关闭,不返回任何应答
边界处理完备:token 跨多个 TCP 包到达(拆包)统一累积;首包可能 token+业务数据粘连(粘包)统一处理;通过后剩余数据用 slice(tokenLen, remaining).retain() 零拷贝切片透传。
8.3 TcpRelayHandler:零丢包 + 背压 + 断连联动
一个类两种角色:前端侧(发起后端建连、暂存数据)与后端侧(把后端数据写回前端)。
- 零丢包:后端未就绪期间前端数据全部暂存
buffered,就绪后一次性刷出; - 背压:
!backendChannel.isWritable()时setAutoRead(false)暂停从前端读,对端恢复可写(channelWritabilityChanged)再恢复读取; - 断连联动:前端断开关后端,后端断开关前端,写失败/异常双端关闭;
- 后端连接失败 → 立即关闭前端(这个行为是排查秒断问题的关键,见下节)。
九、实战踩坑与问题排查
场景:客户端连上网关后 7 毫秒连接就断了,客户端日志一切正常。
排查顺序:
| 现象 | 原因 | 解决 |
|---|---|---|
客户端正常拼 token、转发,网关日志显示 鉴权失败 | 客户端 token 与网关 valid-tokens 不一致 | 两端 token 对齐;注意 JQUICK_AUTH_TOKENS 逗号分隔 |
网关日志显示 鉴权通过...透传N字节,随后 后端连接失败 127.0.0.1:5236 - Connection refused | 容器内 127.0.0.1 是容器自己,不是宿主机 | 后端目标改 host.docker.internal |
网关日志 后端连接成功,开始透传,但客户端仍秒断 | 客户端工具配置的端口不对 | 检查 -p 映射的宿主机端口 |
| 启动即退出 | 鉴权开启但 token 长度与 token-prefix-len 不一致 | 校验所有 token 长度 |
经验总结:排查这种"秒断"问题,第一件事看网关侧日志而不是客户端日志——网关的每个决策(协议判定、鉴权、后端建连、透传)都有日志,链路一目了然。
十、总结与适用场景
这款 Netty4 单端口协议嗅探网关解决的核心问题是:用一个公网端口,安全地暴露多种 TCP 服务。
适合的场景:
- 只有一个公网 IP/端口资源受限,却要同时对外提供 HTTP、数据库、SSH 服务;
- 不想直接把数据库端口暴露公网,需要一层 token 鉴权兜底;
- 内网服务多端口转发配置繁琐,希望统一收敛到单端口;
- 已有 Nginx 但只懂 HTTP,普通 TCP 流量需要额外通道。
不适合的场景:对性能极致敏感(每连接多一跳)、协议需要深度解析/改造(网关是纯透传)。
安全性提示:token 是秘密,
client.properties、JQUICK_AUTH_TOKENS切勿提交到 git;生产环境请用随机生成的强 token,并配合防火墙把网关端口限制在可信来源 IP。
项目地址:jquick-hub(Netty4 单端口协议嗅探 TCP 网关,含 GatewayClient 迷你代理客户端)
如果这篇文章对你有帮助,欢迎 点赞、收藏、评论 交流;关注我不迷路,后续会持续输出 Netty 网络编程、内网穿透、容器化部署等实战文章。你的支持是我更新的最大动力!
本文标签:#Netty #TCP网关 #协议嗅探 #内网穿透 #单端口多协议 #Docker部署 #端口映射 #数据库安全 #Java #后端架构
更多推荐
所有评论(0)