跳转至

Hub 代理功能设计:从订阅节点到可故障切换的 HTTP CONNECT

背景

代理功能真正难的部分不是“启动一个代理进程”,而是把不稳定、格式不一且包含敏感信息的订阅节点,变成一个可管理、可观测、可恢复的运行时系统。

Hub 的代理插件把这条链路拆成四层:

  1. 订阅层:安全拉取和解析订阅内容。
  2. 数据层:加密保存订阅 URL 与完整节点配置,用指纹维护稳定身份。
  3. 运行时:为实际使用的节点按需启动 Xray。
  4. 转发层:对外提供 HTTP CONNECT,并在节点之间重试和切换。

一、总体架构

订阅 URL
  → URL 校验 / SSRF 防护 / 重定向检查
  → 拉取文本 / 大小限制 / Base64 解码
  → URI 解析 / 协议归一化 / fingerprint
  → 节点与分组持久化
  → 按需启动独立 Xray SOCKS 入站
  → GroupForwarder 接收 HTTP CONNECT
  → SOCKS5 握手 / 节点重试 / 首选节点提升
  → 目标站点

代理作为独立服务 hub-proxy 通过 manifest.json 注册接入 Hub,使用 core SDK 的统一数据库、日志和缓存能力。配置数据和运行时状态分离:数据库保存“应该如何运行”,内存对象和子进程保存“现在正在运行什么”。

二、订阅解析:先把输入变成稳定内部模型

订阅内容存在两种常见形态:

  • 每行一个节点 URI
  • 整段 Base64 编码后再传输

解析器先判断并解码订阅体,再逐行调用节点解析器。当前支持:

export type Protocol =
  | 'vless'
  | 'trojan'
  | 'hysteria2'
  | 'ss'
  | 'unknown'

不支持的协议不会被假装解析成可用节点,而是保留为 unknown,便于用户知道订阅里确实存在无法处理的内容;运行时则不会把它交给 Xray。

这种“解析层保留事实、运行层过滤能力”的做法,比直接丢弃异常行更容易排查订阅问题。

三、用 fingerprint 解决节点顺序变化

订阅刷新时,节点顺序可能变化,名称也可能被供应商修改。如果用数组下标关联节点,刷新一次就可能把历史测速和分组关系挂到另一个节点上。

Hub 为节点敏感配置计算 SHA-256 指纹:

协议 + 地址 + 端口 + 用户标识 + 传输参数 + TLS 参数
  → 规范化字符串
  → SHA-256
  → fingerprint

刷新流程变成:

新订阅节点
  → 计算 fingerprint
  → 与已有节点匹配
  → 已存在:更新配置,保留节点 ID
  → 不存在:创建新节点
  → 旧节点:标记或移除

稳定 ID 的价值不只在数据库关系,还包括:

  • Xray 进程池复用
  • 测速结果关联
  • 分组成员关系
  • 首选节点状态
  • 故障记录和运维排查

指纹不能包含订阅 URL、密码或完整 URI 等不必要的可逆信息;它的用途是稳定比较,不是秘密存储。

四、敏感信息保护:加密保存,不把密文当作鉴权

代理功能至少有两类敏感数据:

  • 订阅 URL
  • 节点完整配置,例如 UUID、密码、传输参数

Hub 使用 AES-256-GCM 保存这些内容,存储格式为:

iv:authTag:ciphertext

密钥来自部署环境中的加密密钥配置。生产环境缺少有效密钥时,应拒绝继续处理敏感数据,而不是生成临时密钥,因为临时密钥会让重启后所有历史数据无法解密。

需要区分两件事:

  • 加密存储:降低数据库泄露后的直接暴露风险。
  • 访问控制:限制谁能读取、刷新、启动和使用代理。

加密并不会自动解决日志泄密、进程内存暴露、错误堆栈打印完整 URI 等问题,因此日志中只能记录订阅 ID、节点 ID、协议和脱敏地址。

五、订阅拉取的 SSRF 防护

订阅 URL 是服务端主动发起请求的入口,不能把它当作普通的 fetch(url)。攻击者可能利用它访问本机管理接口、容器元数据、内网数据库或云平台保留地址。

Hub 的拉取函数需要完成以下检查:

  1. 只允许 http: 和 https:。
  2. 拒绝 URL 中的用户名和密码。
  3. DNS 解析后检查所有地址。
  4. 阻止 loopback、私网、链路本地和保留地址。
  5. 手动处理重定向,每一跳重新检查目标。
  6. 限制重定向次数、请求超时和响应体大小。

可抽象为:

原始 URL
  → 语法与协议检查
  → 解析域名
  → DNS 地址分类
  → 建立连接
  → 检查 Location
  → 逐跳重复检查
  → 返回不超过上限的文本

当前约束包括 15 秒超时、2 MiB 响应上限和最多 3 次重定向。默认拒绝解析到 loopback、私网、链路本地和保留地址的订阅;受控测试环境可以显式设置 HUB_PROXY_ALLOW_PRIVATE_SUBSCRIPTIONS=true 放宽这一限制,但不应作为公网部署默认值。关键原则是“检查最终连接地址,而不是只检查用户输入的域名”。DNS rebinding、恶意重定向和 IPv4/IPv6 混用都必须纳入测试。

六、Xray 进程池:按节点隔离运行时

每个可用节点按需启动一个独立 Xray 子进程,并给它分配 loopback SOCKS 端口。

节点 fingerprint
  → ManagedXrayPool.ensureNode()
  → 生成临时 Xray 配置
  → 配置文件权限 0600
  → xray -test 校验
  → 启动子进程
  → 等待 SOCKS 入站可用
  → 删除临时配置
  → 返回 socksPort

独立进程的好处:

  • 单节点崩溃不会直接拖垮转发器。
  • 每个节点有清晰的端口和生命周期。
  • 运行时可以单独重启失效节点。
  • 节点配置不会混在一个不断膨胀的全局配置中。

临时配置必须使用 0600,启动后尽快删除。删除配置文件不等于进程不再持有敏感信息,因此仍需避免 core dump、调试日志和错误信息泄露。

进程池还需要处理:

  • 启动超时
  • 端口冲突
  • Xray 配置校验失败
  • 子进程异常退出
  • 节点被删除后的回收
  • 分组停止时的批量清理

6.1 Xray 运行时管理(2026-08-12 更新:已移除 Docker 控制)

早期版本通过 docker-control.ts 调 Docker CLI 承载 Xray 容器;重构后改为直接管理 xray-core 子进程,不再依赖 Docker:

  • xray-config.ts:把解析后的节点(ParsedNode)生成为 xray-core 26.x 的 config.json。默认入口 127.0.0.1:1111(SOCKS5)+ 127.0.0.1:1112(HTTP CONNECT),路由保持简单、不做复杂规则;不兼容节点(xrayCompatible=false)直接拒绝生成配置。
  • xray-process.ts:使用 spawn 启动/管理单个 xray-core 子进程(关闭 shell 解释),提供状态查询(running/stopped/missing)、进程号与启动时间、500 行环形日志缓冲;assertInside(stateDir, file) 用路径约束阻止把 HUB_PROXY_STATE_DIR 之外的任意文件作为配置挂载/加载。
  • 进程池(xray-pool.ts)仍负责端口分配、启动超时、异常退出回收、节点删除与分组停止时的批量清理,以及空闲回收(HUB_PROXY_XRAY_IDLE_TTL_MS)与进程数上限(HUB_PROXY_XRAY_MAX_PROCESSES)。

Docker 控制层移除后,HUB_PROXY_STATE_DIR 仍是敏感文件与配置文件的唯一允许目录;日志与错误输出统一经过 log-sanitizer.ts 脱敏(sanitizeProxyError / sanitizeProxyTarget),避免完整订阅 URL 与节点凭据进入日志。

七、为什么外部是 HTTP CONNECT、内部是 SOCKS5

很多客户端和系统工具更容易配置 HTTP 代理,尤其是 HTTPS 代理实际使用的就是 HTTP CONNECT:

CONNECT example.com:443 HTTP/1.1
Host: example.com:443

而 Xray 为每个节点提供的是本机 SOCKS5 入站。Hub 在中间做协议适配:

HTTP CONNECT 客户端
  → GroupForwarder
  → 127.0.0.1:<node-socks-port>
  → SOCKS5 no-auth greeting
  → SOCKS5 CONNECT target
  → 双向转发

SOCKS5 握手不能假设一次 data 事件就能收到完整响应,因此实现了定长读取、超时和多余字节回塞。网络协议解析必须按协议边界读取,不能依赖 TCP 分包恰好符合预期。

八、分组路由与故障切换

一个代理分组可以包含多个候选节点。候选排序综合考虑:

  • priority
  • 是否是短期首选节点
  • 节点健康和测速结果
  • 每个节点允许的重试次数

请求处理流程:

收到 CONNECT
  → 解析目标 host:port
  → 获取可用候选
  → 依次准备 Xray 节点
  → 建立 SOCKS5 隧道
  → 返回 200 Connection Established
  → 发送已缓存的客户端首包
  → 等待上游首个响应
  → 记录成功并提升节点
  → 开始双向转发

节点切换时,需要注意 CONNECT 响应时机。只有在至少一个候选节点已经建立可用隧道后,才应向客户端承诺 200 Connection Established。否则客户端会以为隧道已经成功,却只能收到一个已经失效的连接。

成功节点会被提升为短期首选,默认 TTL 为 10 分钟。它是优化,不是永久绑定:过期后仍需重新参与排序,失败时也必须允许回退其他节点。

节点管理还提供独立的连通性测速。测速通过节点自己的 SOCKS 隧道访问一个无凭据的 HTTPS 测试地址,默认目标为 https://api.openai.com/v1/models,也可以通过 HUB_PROXY_TEST_URL 配置。实现使用 TLS 加 HTTP HEAD 请求,默认超时 12 秒,并拒绝带用户信息、查询凭据或非 HTTPS 的测试 URL。节点测试并发默认为 4,范围限制为 1 到 12,可通过 HUB_PROXY_TEST_CONCURRENCY 调整。

测速结果与节点一起保存,至少包括:

  • last_test_status
  • last_test_latency_ms
  • last_test_error
  • last_test_at

转发尝试则单独记录:

  • 分组 ID、节点 ID
  • 目标地址
  • 尝试序号
  • 成功或失败
  • 延迟
  • 错误原因

测速用于提前判断节点连通性,转发日志用于解释真实请求为什么切换;两者不能互相替代。没有这些记录,用户只会看到“代理偶尔失败”,无法知道是订阅失效、节点不可达、Xray 启动失败、SOCKS 握手失败还是目标站点不可达。

九、并发与运行时状态

订阅刷新、分组启动、分组停止和节点进程回收会共同修改运行时。代理插件使用串行 Promise 队列保护这些操作:

let runtimeOperationQueue: Promise<unknown> = Promise.resolve()

function withRuntimeLock<T>(operation: () => Promise<T>): Promise<T> {
  const result = runtimeOperationQueue.then(operation, operation)
  runtimeOperationQueue = result.then(() => undefined, () => undefined)
  return result
}

这里的锁只保护单个进程内的运行时状态。如果未来部署多个后端实例,还需要把“谁负责启动某分组”和“谁拥有某节点进程”的协调放到共享控制面,或通过明确的实例粘性避免重复管理。

十、安全边界

代理功能的主要安全风险不只是节点配置泄露,还包括:

  • SSRF 访问内网资源
  • 未授权用户借代理访问外部网络
  • 监听地址绑定到不应暴露的网卡
  • 错误日志包含完整订阅 URL
  • 临时 Xray 配置权限过宽
  • 节点进程无限增长造成资源耗尽

部署时应额外确认:

  • 转发监听地址是否只绑定到需要的网卡
  • 是否有上层鉴权或网络 ACL
  • 是否限制分组数量、节点数量和并发连接
  • 是否设置连接、响应和空闲超时
  • 是否对管理接口与数据转发接口分别授权

十一、故障排查

现象 排查方向
订阅刷新失败 URL 协议、DNS、SSRF 检查、重定向、响应大小和超时
节点数量为零 Base64 解码、URI 协议、节点格式和 unknown 数量
节点测速失败 HUB_PROXY_TEST_URL 是否为无凭据 HTTPS、SOCKS 隧道、TLS、12 秒超时和测试并发
Xray 无法启动 临时配置、配置校验、二进制路径(getXrayExecutablePath)、端口冲突和启动日志
CONNECT 返回 503 分组无成员、节点均不兼容或候选全部失败
建立 CONNECT 后无数据 客户端首包缓存、SOCKS5 握手、上游首响应超时
同一节点反复重启 进程退出事件、配置错误和节点健康状态
重启后订阅无法解密 加密密钥未持久化或环境变量改变
内网地址被访问 SSRF 检查是否覆盖 DNS 解析结果和每次重定向

十二、验证清单

  1. 使用脱敏的明文 URI 列表刷新订阅。
  2. 使用 Base64 订阅刷新,确认解析结果一致。
  3. 混入不支持协议,确认被标记为 unknown 且不进入运行时。
  4. 刷新时改变节点顺序,确认既有节点 ID 和分组关系稳定。
  5. 检查数据库,确认订阅 URL 和完整节点配置不是明文。
  6. 使用无效加密密钥启动,确认不会静默生成新密钥覆盖历史数据。
  7. 使用重定向到私网地址的测试 URL,确认每一跳都被拒绝;在受控测试环境验证 HUB_PROXY_ALLOW_PRIVATE_SUBSCRIPTIONS=true 的显式放宽行为。
  8. 执行节点测速,确认状态、延迟、错误和时间戳写入 last_test_* 字段,并验证并发上限。
  9. 启动一个分组,确认 Xray 配置文件权限为 0600 且启动后被清理,assertInside 拒绝 HUB_PROXY_STATE_DIR 之外的配置路径。
  10. 让首选节点失败,确认请求会按候选顺序切换。
  11. 检查尝试日志和 /api/plugins/proxy/xray-logs,确认包含延迟、错误和运行时诊断信息。
  12. 停止分组,确认监听端口、连接和 Xray 子进程全部释放。

十三、模块化部署(2026-08-12 更新)

重构后代理独立为服务 hub-proxy(版本 0.2.0),是五服务架构之一:

  • 默认端口 5202(HUB_PLUGIN_PORT),通过 manifest.json(id: proxy,权限 proxy:subscription:* / proxy:node:* / proxy:group:* / proxy:process:* / proxy:log:read)注册到门户。
  • service.ts 是独立 Fastify 入口(数据落独立库 hub_proxy),main.ts 保留订阅、转发器与进程池逻辑。
  • 新增运行时控制类环境变量:HUB_PROXY_CIRCUIT_FAILURE_THRESHOLD / HUB_PROXY_CIRCUIT_COOLDOWN_MS(节点级熔断)、HUB_PROXY_MAX_CONNECTIONS / HUB_PROXY_MAX_CONNECTIONS_PER_NODE / HUB_PROXY_NODE_QUEUE_LIMIT / HUB_PROXY_NODE_QUEUE_TIMEOUT_MS(并发与排队)、HUB_PROXY_XRAY_IDLE_TTL_MS / HUB_PROXY_XRAY_MAX_PROCESSES(进程池回收)、HUB_PROXY_CONNECT_USERNAME / HUB_PROXY_CONNECT_PASSWORD / HUB_PROXY_CONNECT_ALLOWED_IPS(转发端口鉴权与 IP 白名单)、HUB_PROXY_LOG_REDACT_IPS / HUB_PROXY_LOG_TARGET_MODE(日志脱敏策略)。SSRF 与测速相关开关(HUB_PROXY_ALLOW_PRIVATE_SUBSCRIPTIONS / HUB_PROXY_TEST_URL / HUB_PROXY_TEST_CONCURRENCY)保持不变。

代码索引

  • hub-proxy/backend/src/main.ts(订阅、分组转发、运行时管理入口)
  • hub-proxy/backend/src/service.ts(独立服务入口:Fastify + 门户注册)
  • hub-proxy/backend/src/sub-parser.ts(订阅解码与节点解析)
  • hub-proxy/backend/src/crypto.ts(订阅 URL 与节点配置加密)
  • hub-proxy/backend/src/xray-pool.ts(Xray 进程池)
  • hub-proxy/backend/src/xray-config.ts(生成 xray-core 26.x 配置)
  • hub-proxy/backend/src/xray-process.ts(单进程 spawn / 状态 / 日志 / 路径约束)
  • hub-proxy/backend/src/group-forwarder.ts(HTTP CONNECT → SOCKS5 转发与故障切换)
  • hub-proxy/backend/src/node-tester.ts(节点连通性测速)
  • hub-proxy/backend/src/log-sanitizer.ts(日志脱敏)
  • hub-proxy/manifest.json(插件清单与权限声明)